27 Commits

Author SHA1 Message Date
Bradley Axen
a3a47e6294 feat(relay): add an unlisted channel setting (#8579)
🤖

## Summary

Sharing a Solo chat with a large channel today means adding every person
as a member, one at a time, and the app blocks the share past 100
people. The plan is to share it the way Google Docs does with "anyone at
the company with the link". Buzz's `open` visibility already gives that
kind of access: anyone in the community can read and join an open
channel. The problem is that an open channel also shows up in everyone's
channel list and search, and a private chat shouldn't appear there just
because its link was posted somewhere.

This PR adds a channel setting, `unlisted`. An **unlisted open channel**
can be read and joined by anyone who names it by id (from a shared
link). For non-members, it is left out of every read that does not name
it. Members see it as before. This is the relay side only; the buzz-app
change comes later. #8575 (batch adds) remains the path for small
audiences.

Unlisted is not an access control. Anyone who has the id can read the
channel, and that is intended.

## Solo flows this supports

Each user action is one event. The buzz-app side comes later; the relay
supports all of these now.

| Flow | Event | Result |
| --- | --- | --- |
| New Solo chat | kind:9007 `visibility private` + `["unlisted",
"true"]` | The chat starts unlisted, so it stays unlisted when any
client later opens it. |
| Share | One kind:9002: `visibility open` + `unlisted true` +
`join_role guest\|member`. Then post the link. | Anyone with the link
can read the chat by id, both history and live. Nobody is added as a
member, so the chat isn't in anyone's channel list. A non-member can't
post; `join_role` decides whether someone who joins can. |
| View | A REQ that names the chat by `#h`, or by `#d` for its metadata
| A non-member can read it. It is never listed for them. |
| Reply | kind:9021 join (the app sends it before a viewer's first
reply) | Joining makes them a member with the role from `join_role`, so
with `member` they can post. A kind:9022 leave makes them a viewer
again; the chat stays readable by id. |
| Unshare | kind:9002 `visibility private` | The existing open→private
eviction closes non-members' live subscriptions. New reads by
non-members are refused. |
| Convert to a channel | One kind:9002: `["t", ""]` + `unlisted false`,
plus `visibility` | Clears the Solo label. Non-members can now find it
in discovery. |

**Sidebar pins are not relay state.** Pin and unpin live in the app's
existing kind:30078 sidebar preferences, which sync across devices and
don't touch membership. Don't map them to join/leave: someone a chat was
shared with by member add is already a member, so a leave would remove
their access.

Your own Solo chats keep showing up, because you're their owner member,
and members see unlisted channels everywhere. Existing Solo chats aren't
backfilled. They get the flag when they're shared, because the share
event sets it.

**Open question, not built here:** someone who only views a shared chat
isn't a member. A mention of them doesn't reach their inbox (`#p:[me]`
without `#h`) and sends them no push.

## Behavior

| Reader | Reads that don't name the channel (kind:39000 without `#d`,
`#P` creator queries, messages, COUNT, NIP-50 search, artifact queries,
channel list) | Reads that name it (`#h`, or `#d` on 39000–39003) |
kind:9021 join |
| --- | --- | --- | --- |
| Member | Listed | Readable | n/a |
| Non-member, unlisted open channel | **Not listed** | Readable | Works;
`join_role` still decides the role |
| Non-member, private channel (unlisted or not) | Not listed | Not
readable | Refused |

- **Setting it:** kind:9002 `["unlisted", "true"]` / `["unlisted",
"false"]`. Owner or admin only, like `visibility`. A missing tag means
no change. It can be sent in the same event as `["visibility", "open"]`,
so a private chat becomes open and unlisted in one step. Any other value
is refused before the event is stored. The relay posts an
`unlisted_changed` system message and clears the accessible-channels
cache across nodes. Nobody loses access when a channel is unlisted, so
no subscriptions are evicted.
- **At creation:** kind:9007 also takes `["unlisted", "true"|"false"]`.
A missing tag means listed. Any other value is refused before the event
is stored, as on kind:9002. It is stored with the channel row in the
create's transaction, on both the client-UUID path and the
relay-chosen-UUID path.
- **kind:39000** carries a bare `["unlisted"]` flag while the setting is
on. It does not add NIP-29 `hidden`, so the metadata stays readable by
id.
- **NIP-11** advertises `buzz-unlisted-channels` in
`supported_extensions`. A relay without this PR would apply
`["visibility", "open"]` and ignore `unlisted`, which would list the
chat publicly. So buzz-app must check for the extension before it sends
that pair.
- **CLI:** `buzz channels update --channel <id> --unlisted true|false`
and `buzz channels create ... --unlisted`.
`buzz_sdk::build_update_channel` takes a new `unlisted: Option<bool>`
argument, and `buzz_sdk::build_create_channel` takes `unlisted: bool`,
which emits the tag only when true.

### Open question: does `unlisted` only mean something when the channel
is open?

**Decision: store it on its own; it only has an effect while the channel
is open.** Private channels are already invisible to non-members, so on
a private channel the flag does nothing. It is still stored and
published, for three reasons:
- a client can mark a Solo chat unlisted before opening it, or in the
same event that opens it, so the chat is never listed even for a moment;
- switching private → open → private → open keeps the setting;
- there is no cross-field rule in kind:9002 to validate or keep in sync.

kind:39000 publishes the flag as stored, whatever the visibility, so
clients can show the setting.

## How

The relay already has a "readable only when named" path for system
channels (NIP-CL). Unlisted open channels reuse it:

- **`get_accessible_channel_ids`**: the open half (the implicit scope of
a REQ that doesn't name a channel) adds `AND NOT unlisted`. The member
half is unchanged, so members still have the channel.
- **`get_readable_named_only_channels`**: also returns unlisted open
channels of the legacy types. Every reader of that set (REQ, COUNT,
NIP-50 search, HTTP `/query` and `/count`, thread windows, workflow run
reads) adds a named-only channel only to a filter that names it, through
`filter_channel_scope`. An unlisted channel's type is `stream`/`forum`,
so the `#t:["system"]`/`#P` system opt-in never adds it.
- **`get_accessible_channels`** (the channel list): open channels are
listed to non-members only when not unlisted.
- **`query_artifacts`**: an artifact in an unlisted open channel reaches
a non-member only when the query's `#h` names that channel.
- **Live fan-out:** no change was needed. A global subscription never
receives channel-scoped events, kind:39000 included (`fan_out_scoped`).
A channel-scoped subscription has to name the channel, and that goes
through the same named-only authorization.

**Left alone on purpose:** access checks that read a named channel or
event keep treating an unlisted channel as open. These are
`handle_join_request` (kind:9021), the ingest write gate,
operator-listener mention delivery (`load_deliverable_event`), personal
read-state writes, and push matching. Push matching still requires
membership, so non-members get no pushes from an unlisted channel.

**Named reads checked:** these callers use the implicit channel set
(`get_accessible_channel_ids`, its cache, `get_accessible_channels`).
Every one that reads a channel the request names also adds the
named-only set, so a non-member can still read an unlisted open chat by
id. That covers WS REQ (history, live registration, NIP-50 search), WS
COUNT, HTTP `/query` and `/count`, thread windows and workflow run
reads. Other named reads don't use the implicit set: they check
visibility or membership, so an unlisted open chat counts as open. Those
are live fan-out, the ingest write gate, typing, kind:9021 join, huddle
audio join, operator-listener delivery, the workflow sink and read-state
writes. Feed queries (mentions, needs-action, activity) and the
`/buzz/v1/me/sidebar` accessory API are lists, so leaving out chats you
only view is intended. Media reads don't check channels. `buzz-auth`'s
`ChannelAccessChecker` has no production implementation.

## Database (docs/database.md checklist)

- **Phase:** expand only. The migration adds a column with a constant
default, and the same release ships the code that reads and writes it.
No backfill or contract step follows.
- No trigger, function, procedure, rule or foreign key is added.
- No migration changes existing rows. `ADD COLUMN ... BOOLEAN NOT NULL
DEFAULT FALSE` stores the constant default in the catalog, so the table
is not rewritten.
- Every location in Rule 4 is updated:
`migrations/0069_channel_unlisted.sql`,
`schema/tables/public/channels.sql`, and the migration count. No seeds,
types or reconcile changes are needed.
`admin_schema_parity_between_desired_state_and_migrations` now compares
`channels` columns between the pgschema desired state and the migrated
database. I failed it once by removing the column from `channels.sql`.
Only columns are compared for `channels`: on current `main`, pgschema
already re-emits `idx_channels_id_live` without its `INCLUDE
(community_id)`, an index drift that predates this PR and is noted in
the test.
- **Currently deployed relay against the new schema:** it never selects
or writes `unlisted`, and every insert gets the default. All
channel-record queries name their columns. Nobody can set the flag until
this relay ships, because the old relay refuses a kind:9002 whose only
tag is `unlisted`.
- The migration starts with `SET LOCAL lock_timeout = '5s'`. No index is
added: the new predicates ride the existing `(community_id, visibility)`
index.

## Rollout

1. Deploy. Every channel stays listed, so nothing changes.
2. **Don't unlist any channel while an old relay is still running.** An
old relay ignores the column and lists the channel to everyone. The
NIP-11 extension lets clients gate on this. Unlisted isn't an access
boundary, so a mixed fleet only exposes the channel in listings for a
while.
3. **Rollback:** an older relay ignores the column, so unlisted channels
show up as ordinary open channels again. Nothing breaks.

## Tests

Postgres (`buzz-db`, discovered by the Postgres lane):
- `unlisted_open_channel_is_named_only_for_non_members`: sets the flag
through `update_channel`. Then checks that a non-member doesn't get it
from `get_accessible_channel_ids` or `get_accessible_channels`, and does
get it from `get_readable_named_only_channels`; that a member still
lists it; that `put_member` (the kind:9021 path) lets the non-member
join, after which they list it; and that listing it again shows it to a
new reader.
- `unlisted_private_channel_stays_private_and_opens_unlisted`: a private
unlisted channel is not readable by name, and opening it keeps the flag.
- `group_metadata_publishes_the_stored_unlisted_flag`, plus the unit
test `publishes_the_unlisted_flag_as_stored`.
- `unlisted_channel_artifacts_are_read_by_name_for_non_members`.
- `channel_created_unlisted_is_never_listed`: creating through
`create_channel_with_event` (the kind:9007 path) and
`create_labeled_channel` stores the flag, and the channel is never
listed to a non-member.
- Mutation-checked: each of `NOT unlisted` in the implicit scope, `NOT
c.unlisted` in the channel list, the unlisted branch of the named-only
query, its `visibility = 'open'` guard, and the artifact condition was
removed one at a time, and each removal failed at least one of these
tests.

Relay E2E (`test_nip29_unlisted_channel_wire`, added to the CI relay E2E
step), against a local relay built from this branch:
- A non-member or an unknown value is refused.
- kind:39000 by `#d` carries `unlisted` and `public`, and not `hidden`.
- kind:39000 discovery without `#d`: non-members don't see the channel,
members do.
- History and NIP-50 search: unnamed, a non-member doesn't see the
channel's messages; with `#h`, they do; a member sees them unnamed.
- A kind:9021 join works, and the joiner then lists the channel.
- Listing it again shows it to everyone.
- A reader whose channel set was cached before each change sees the
change at once. I ran this against a relay with the cache invalidation
removed, and it failed.

Relay E2E (`test_nip29_solo_share_and_convert_wire`, added to the CI
relay E2E step), each flow as a single event:
- kind:9007 with an unknown `unlisted` value is refused. A chat created
with `unlisted true` publishes the flag, and stays unlisted after a
plain `visibility open`.
- Share (open + unlisted + `join_role guest` in one kind:9002): a
non-member doesn't list the chat. They read it by `#h`, both history and
a live message, and can't post. A kind:9021 join lists it for them as a
read-only guest. A kind:9022 leave unlists it again, and it stays
readable by `#h`.
- Unshare (`visibility private`): the viewer's live subscription gets
CLOSED, and a new `#h` read is refused.
- Convert (`["t", ""]` + `unlisted false`): the Solo label is cleared,
and a non-member finds the chat in discovery.
- I ran this against a relay that dropped the kind:9007 flag, and it
failed.

Also run: `cargo test` for buzz-db, buzz-sdk, buzz-cli, buzz-core,
buzz-relay and buzz-test-client; the Postgres lane for buzz-db and
buzz-relay (1045 tests); and the existing unlisted, announce, departure,
guest, role-change and NIP-43 snapshot wire tests. In the lane, two
tests failed on pool timeouts
(`resend_validated_after_the_first_commit_is_a_duplicate`,
`edit_replaces_and_clears_labels`) and passed when rerun alone. An
earlier run had the same kind of flake:
`routed_fallback_spends_one_acquire_budget_when_aurora_cache_is_cold` (a
timing budget) and `resent_edit_is_a_duplicate` (a pool timeout). In a
full local `e2e_relay` run, three tests outside CI's list also failed
(`test_invite_*` twice,
`test_unarchive_emits_member_added_notification`). They fail the same
way on a relay built from `main`.

## Not in this PR

- The buzz-app follow-up: support open Solo chats, the join-from-card
view, the large-audience prompt, and gating on `buzz-unlisted-channels`.
- A NIP-CL update. The behavior is documented in NOSTR.md ("Unlisted
channels").





buzz-review-completed

---------

Signed-off-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: ART <ea39c1c8acf57180161ef4ba4ed68abf8589f5291956b909a5aa991fc66664ca@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Co-authored-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-10-26 14:03:07 -04:00
Bradley Axen
d0dee1cb50 feat(relay): add several people with one kind:9000 (#8575)
## What

A NIP-29 kind:9000 (put-user) can now name up to 100 people, one `p` tag
each. A client sharing a channel with many people can do it in one
signed write instead of one event per person.

- **Role per person:** NIP-29 style `["p", pubkey, role]`. When the
third element is absent or empty, the event's `["role", r]` tag applies.
- **Same checks, per person:** each person gets the checks a single add
does, in the same order: `decide_put_user`, then the agent's
`channel_add_policy`, then the admission rules again under the channel's
membership lock. One refused person no longer refuses the others.
- **Per-person results:** when the event names two or more people, the
OK message reports each result. Only non-empty lists appear, and
refusals are grouped by reason so 100 people stay well under a client's
16 KB receipt limit:
  ```
members:
{"added":[hex…],"updated":[hex…],"already_member":[hex…],"rejected":[{"reason":"…","pubkeys":[hex…]}]}
  ```
- **When the event is refused:** if it is malformed (bad `p` hex,
unknown role, more than 100 people, missing `h`, archived channel), or
if nobody it names may be added. With one person the message keeps
today's `invalid: <reason>`. With several it is `invalid: members: {…}`.
- **One-person events don't change:** a successful single add still
returns an empty OK message, which an existing e2e test pins.
- **Re-adding someone at the role they already have** no longer posts a
second `member_joined` timeline notice. The member-added notification
(kind:44100) still goes out, so an agent that missed its first one is
still re-signaled.
- **Group-state events (39000–39002)** are now published once per event
instead of once per person.

## Cost of a batch

The database work for one event does not grow with the number of people:

- **One membership transaction.** `buzz-db` gains `put_members`: one
transaction, one membership lock, one channel read, one read of every
involved role, the owner count only if a demotion needs it, and one
`UNNEST` upsert with `RETURNING`. `put_member` is now `put_members` with
one person, so there is one implementation of the admission and
last-owner rules, and a single add makes two fewer round trips than
before.
- **Same answers as one write per person.** People are decided in tag
order, and each decision sees the roles earlier people were given,
including the actor's own (an owner who steps down mid-batch can't then
grant admin), and the owners they add or remove. A new buzz-db test pins
this.
- **One policy query.** Validation reads every third-party target's
`channel_add_policy` with one `= ANY($2)` query
(`get_agent_channel_policies`) instead of one per person.
- **One channel-type read** for all the member-added notifications,
instead of one per notification.

What still scales with people is what each person must receive: their
`member_joined` timeline notice and their kind:44100 notification, one
stored event each.

## Why

This is for Solo chat sharing in buzz-app.
[block/buzz-app#1605](https://github.com/block/buzz-app/pull/1605)
routes every share through one helper, `shareChatsWithChannel`, which
today sends one kind:9000 per missing member. A small follow-up there
switches it to one batch, then re-reads the roster and adds anyone still
missing one at a time. That keeps it correct on relays without this
change, which accept the event but ignore every `p` after the first.

The batch stays a signed NIP-29 event rather than a new HTTP endpoint,
per VISION.md and AGENTS.md ("Prefer Nostr events over new HTTP
endpoints").

## Wire changes for existing senders

- Any malformed `p` tag now refuses the event. Before, malformed `p`
tags were skipped and the first valid one was used.
- Extra `p` tags are now targets. Before, they were ignored.
- A third `p` element that isn't a role, such as a NIP-01 relay hint,
refuses the event with `invalid role: …`. No sender in this repo does
that: `build_add_member` sends one bare `p`.

## Tests

**buzz-db (`channel_members` Postgres tests):** new
`put_members_decides_each_person_in_order`: the sole owner can't step
down before an heir is named in the same call, but the heir is still
added; with the heir counted the owner can step down, and the admin
grant after it is then refused; a no-op re-add reports its prior role; a
repeated pubkey refuses the call. The other `channel_members` Postgres
tests pass, including the announce-admission and concurrent `join_role`
tests that exercise `put_member`.

**buzz-relay (`put_user_postgres_tests.rs`), 5 tests:**
- mixed results in one event: added, already a member, an agent with
`nobody` policy rejected, a per-`p` `bot` role, a duplicate `p`, a
single roster publish
- `updated` for a role change, with the event `role` tag as fallback
- a plain member may add someone, but may not grant an elevated role on
a `p`
- a single add keeps its empty OK message
- an all-refused event: single text and grouped JSON
- a malformed `p` tag
- the 100-person cap: 101 refused, 100 accepted

Related relay Postgres groups (`group_state`, `create_with_members`,
`edit_metadata`, `membership`) pass.

**Live relay:** `e2e_relay` against a local relay at 9c27a0f15: 58 of 61
passed, including `test_nip29_batch_put_user_wire` (added to the CI
Relay E2E list) and every NIP-29 put-user, announce, guest and departure
test in that list. The 3 failures don't touch this change: the two
invite tests assume the relay's seeded host, and this relay ran on
`:3030`. `test_unarchive_emits_member_added_notification` timed out once
and passed when re-run, as it did on the first commit.

**Local caveats:** the local `channel_members` roster-fence test and the
deletion tests fail on a shared local database because other tests leave
its fence trigger disabled, so the PostgreSQL Tests job in CI is the
authority for the full suite. The pre-push `rust-tests` hook passed.
`desktop-tauri-checks` failed on `chunks_exact` lints in desktop files
this PR doesn't touch, so the push used `--no-verify`. `cargo clippy
--all-targets` (buzz-db, buzz-relay) and `cargo fmt --check` are clean.

---------

Signed-off-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: ART <ea39c1c8acf57180161ef4ba4ed68abf8589f5291956b909a5aa991fc66664ca@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: claude00 <fb8b76a01257932b8ca35060862c29bd09b2137638573731c2d5074fc02c5385@buzz.block.builderlab.xyz>
Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Co-authored-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-10-26 11:52:32 -04:00
Logan Johnson
d97f6b4c6e feat(relay): add announce channels with a join_role setting (#8143)
🤖
## Summary
Channels are Buzz's main way to control access, but today they cannot
separate reading from writing. In an open channel, anyone who can read
can also write. So there is no way to say "many people read, a few
people write". Announcements, release feeds, plugin release records and
shared configuration all need that.

This PR adds **announce channels**: anyone can follow and read them, but
only members can post. It does this with one new channel setting and
without changing what any role means.

- **Roles mean the same thing in every channel.** A *guest* can read and
leave, but cannot write. A *member* (or *bot*) can write. *Admins* and
*owners* also manage the channel. The relay already had the guest role,
but no client used it until now.
- **A new setting, `join_role` (`member` | `guest`), picks the role a
new person gets** when nobody chooses one: when they join by themselves,
or when someone adds them without a role. The default, `member`, keeps
today's behavior for every existing channel.
- **An open channel with `join_role = guest` is an announce channel.**
Anyone can find, read and join it. People who join are guests, so they
cannot write until someone makes them a member.

The relay enforces these rules, so a client cannot get around them.

What each role can do (the same in every channel):

| Action | Guest | Member or bot | Admin / owner |
| --- | --- | --- | --- |
| Read, leave | ✅ | ✅ | ✅ |
| Post, reply, edit, delete own, react, artifacts, canvas, forum, pins,
topic, typing, workflow messages, save or run workflows, huddles | ❌ | ✅
| ✅ |
| Add people | ❌ | ✅ as member, guest or bot | ✅ any role |
| Make a guest a member (person or agent) | ❌ | ✅ | ✅ |
| Any other role change | ❌ | ❌ | ✅ |
| Change name, about, visibility, TTL, `join_role` | ❌ | ❌ | ✅ |

Other rules:

- **Joining by yourself.** A person who joins (kind:9021), or adds
themselves (kind:9000 with their own key), gets the channel's
`join_role`. They may ask for `join_role` or a lower role, never a
higher one. So in an announce channel nobody can join as a member by
themselves.
- **Non-members.** A non-member may write only where joining would let
them write: an open channel with `join_role = member`. That is today's
behavior for open channels.
- **Agents.** Agents follow the same rules as people. Any member who can
write may make a guest agent a member. Limits that only an agent's owner
may lift belong to the later IFC work (its "restricted" agent level),
not to this PR. That work only refuses more actions, so it can be added
later without breaking anything here.
- **Leaving always works.** A member may leave an archived channel too
(kind:9022, or kind:9001 naming themselves). The last owner still cannot
leave.
- **Agent owners keep edit and delete.** Today the human owner of an
agent can edit (kind:40003) and delete (kind:9005) that agent's messages
without being a member. This PR keeps that existing behavior in announce
channels too, and documents and tests it. It does not let the owner
post.
- **Changing the setting changes no roles.** Existing members can still
write after a change to `guest`. Existing guests stay read-only after a
change back to `member`.

A refused write gets `restricted: only members can post in this
channel`.

## Details
- **One write rule.** The relay's channel write check decides from the
actor's role and the channel's `join_role`. Some event types skip that
check and run their own: message edit, deleting your own message
(kind:9005), topic and purpose (kind:9002), add-member (kind:9000),
workflow messages and joining a huddle. Each of these now uses the same
rule. Membership changes (add, remove, leave) do not need write access,
so a guest can always leave. If the relay finds a stored `join_role`
value it does not know, it treats it as `guest`, so an unknown value can
never give more access.
- **Admission is decided in the database transaction.** The membership
write (`put_member`) makes the whole admission decision inside its
transaction, after it takes the channel's membership lock: `join_role`
for a missing role tag, the self-join ceiling, guests cannot add people,
who may grant admin or owner, and role changes
(`MemberRole::may_change_role`). The relay's request check uses the same
rules, but the database is the authority, so a write that skips the
request check cannot get around them. A change to `visibility` or
`join_role` takes the same lock, so an add and a setting change cannot
interleave.
- **No creator exception.** `put_member` used to give owner to a
channel's creator who added themselves to a private channel without an
active membership. A removed creator could use that: their join
(kind:9021) passes the open-channel check, the channel becomes private
before the join takes the membership lock, and the join restored them as
owner. Channel creation already makes the creator owner in the same
transaction, so the exception is removed. A removed creator is now
admitted like anyone else.
- **Huddles.** Joining a huddle needs write access in the huddle channel
**and** in its parent channel. The relay checks both before the join.
When it commits the join, it applies the same rule again while it holds
both channels' membership locks: a member needs a role that can post,
and a non-member needs an open channel whose `join_role` is `member`. A
guest in either channel cannot join. A join is also refused when the
joiner loses membership or the channel's `join_role` changes before the
commit.
- **One lock order.** Each writer that takes more than one of a
channel's locks takes them in this order: membership lock, TTL lock,
channel row. Settings changes, roster publication, TTL refresh and
huddle joins all use this order. Before this change, a `join_role`
change on an ephemeral channel at the same time as a roster publication
could deadlock.
- **Workflows.** Saving a workflow (kind:30620) skips the generic write
check, so it now checks the sender's role itself and needs a writing
role before anything is stored. Every path that starts a workflow run,
including the manual trigger (kind:46020), needs the workflow's owner to
hold a writing role now (and an elevated role for an elevated workflow).
So a workflow saved while its owner was a member stops running after the
owner is made a guest.
- **Channel names are checked before storage.** kind:9002 and kind:9007
refuse a name longer than the `channels.name` column (255 characters)
before the event is stored. Before this, the relay accepted the event,
then the name update failed, and the rest of the update (for example
`join_role`) was silently skipped. This PR only adds this check. Storing
the event and applying the update in one transaction is a larger change,
and it is not in this PR.
- **Changing the setting.** Owners and admins send a channel-metadata
update (kind:9002) with `["join_role", "guest"]` or `["join_role",
"member"]`. Buzz applies these updates as partial updates; a missing tag
means "no change". The relay posts a `join_role_changed` system message
in the channel.
- **What clients see.** The relay signs a channel description event
(kind:39000) that clients read. One shared builder now writes it for
both the relay and the `buzz-admin` backfill, which had a copy that had
drifted from the relay's. It always publishes an explicit `["join_role",
…]` tag, plus the standard NIP-29 group access flags:

| `visibility` | `join_role` | `public` / `private` | `restricted` |
`closed` |
  | --- | --- | --- | --- | --- |
  | open | `member` | public | | |
  | open | `guest` | public | ✓ | |
  | private | either | private | ✓ | ✓ |

This also fixes an old bug: `closed` ("join requests are ignored") was
published for every channel, even open channels that accept joins.
- **CLI and SDK.** `buzz channels update --join-role guest|member`.
`buzz_sdk::build_update_channel` takes a new `join_role` argument and
always writes it as an explicit tag.
- **Docs.** `NOSTR.md` has an "Announce channels" section with the role
table and the flag table.
- **Tests.** Relay end-to-end tests, run in the CI relay E2E step:
- `test_nip29_announce_channel_wire`: who can set `join_role`, the
kind:39000 flags, joining as a guest, refused guest and non-member
posts, reactions and adds, member posts, a member making a guest a
member who then posts, changing the setting back without changing roles,
and leaving.
- `test_nip29_member_raises_guest_agent`: a member who is not the
agent's owner can make a guest agent a member.
- `test_nip29_announce_channel_wire_workflow_send_needs_write_role`: a
guest's workflow message is refused; a member's is accepted.
- `test_nip29_departure_wire_archived_channel_allows_self_leave`: guests
and members can leave an archived channel.
- `test_private_channel_any_member_can_invite` now expects a
private-channel guest to be refused when it adds someone (it used to
expect success).
- `test_nip29_announce_channel_wire_workflow_commands_need_write_role`:
saving and triggering a workflow, by a member and by a guest, in
announce and normal channels; a guest cannot run a workflow saved while
it was a member.
-
`test_nip29_announce_channel_wire_overlong_name_is_refused_before_storage`:
a kind:9002 with an overlong name and `join_role` is refused, and
kind:39000 still shows the old settings.
- `test_nip29_announce_channel_wire_agent_owner_exception`: an agent's
owner, as a guest and as a non-member, can edit and delete the agent's
messages, while ordinary guests are still refused.
- `test_nip29_guest_is_read_only_in_normal_channel`: a guest in an
ordinary channel cannot write, and joining an ordinary channel still
gives `member`.

Unit tables cover the self-join rule for each `join_role`, and every
actor, current role and requested role for the role-change rule.
Database tests check the admission rules in `put_member`
(`put_member_enforces_announce_admission`), that an add which waits on
an uncommitted change to `guest` is decided on the new setting
(`put_member_waits_for_a_concurrent_join_role_change`), and that a
member can make a guest a member but not an admin. Unit tests cover the
name limit and the workflow owner rule
(`owner_authority_denies_roles_that_cannot_write`). A relay test covers
huddle joins with different roles in the huddle and its parent, and a
commit after a demotion, a `join_role` change or a lost membership
(`huddle_join_needs_a_write_role_in_the_huddle_and_its_parent`).
Lock-order tests:
`join_role_change_waits_for_a_roster_publication_on_an_ephemeral_channel`
reproduced a real deadlock (`40P01`) against the old order.
`f2d_join_lock_order_serializes_membership_and_settings_writers` checks
that membership and settings writers wait for a join and then succeed.
`removed_creator_cannot_rejoin_a_private_channel` checks that
`put_member` refuses a removed creator on a private channel, and
`removed_creator_join_racing_a_private_switch_is_refused` holds the
membership lock so a removed creator's kind:9021 passes the open-channel
check, then makes the channel private before the join continues. Each
new test was run against the old code and failed.

## Rollout
1. **Before the deploy, count guest rows** (`SELECT count(*) FROM
channel_members WHERE role = 'guest' AND removed_at IS NULL`). We expect
none. Any guest that exists loses the ability to post, change the topic
and add people when this ships. If some exist, decide first whether to
make them members.
2. Deploy. Every channel stays `join_role = member`, so nothing else
changes.
3. **Do not set any channel to `join_role = guest` until no old relay is
running.** Old relays ignore the column, so they would let guests and
non-members write in that channel.
4. Rerun the `buzz-admin` channel reconcile so open channels drop the
wrong `closed` flag.

## Migration and rollback
- Migration `0067` adds `channels.join_role TEXT NOT NULL DEFAULT
'member'` with a CHECK constraint. Postgres stores a constant default in
table metadata, so existing rows are not rewritten and there is no
backfill. The relay runs the migration at startup.
- Existing channels keep the old `closed` flag until their next change,
or until the reconcile in Rollout step 4.
- **Before rolling back the relay**, set every announce channel back to
`join_role = member`. An older relay ignores the column and does not
stop guests from writing, so those channels would accept posts from
their guests and give `member` to new joiners again.

## Not in this PR
Client UI: a Follow button, hiding the composer for guests, and an admin
toggle. Clients that ignore the new tag keep working. A guest who tries
to post sees the `restricted` error.

## Stack
1. #8143 (this PR): announce channels
2. #8494: store the bot role only for keys with a recorded agent owner

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-21 16:01:11 -04:00
Logan Johnson
cd8a968179 fix(relay): push system channel events to a lease that names them (#8517)
🤖
## Summary
Mobile clients can now get push notifications for a system channel, but
only when they ask for that channel by its ID. Before this change, the
relay never sent push for system channels, even when a client asked for
one directly.

System channels are channels the app uses for its own purposes (for
example, agent attention). They are hidden from clients that do not know
about them. #8507 made push use the same visibility rule as normal
reads: a push subscription sees a system channel, or any channel type
added later, only if its filter names that channel ID in `#h`. With that
rule, an older client can never get system channel pushes by accident.
The extra "never push system channels" rule in NIP-CL now only blocks
clients that ask on purpose, such as a future Live Activities
subscription. This PR removes that special case.

### Details
- `push_runtime.rs`: system channels now follow the same rule as other
named-only channel types. The relay still checks membership before it
sends a wake.
- `NamedOnlyChannels::is_system` had no other caller, so it is removed.
- NIP-CL and NOSTR.md now describe the shared rule instead of the ban.
- No behavior change for current clients. They do not name system
channels in their push subscriptions.

### Related issue
Follow-up to #8507 (see
https://github.com/block/buzz/pull/8507#issuecomment-6316180106).

### Testing
- `named_only_channel_pushes_only_to_a_filter_that_names_it` now expects
a wake for a system channel named by `#h`. It also checks that a broad
filter still gets no wake, and that a non-member still gets no wake.
- `cargo test -p buzz-relay --lib push_runtime`, `cargo clippy -p
buzz-relay --all-targets -D warnings`, and `cargo fmt --check` pass.

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-21 10:38:57 -04:00
Luke Tornquist
039e1c8d59 feat(relay): write the NIP-43 member list in the membership transaction (#8482)
## Summary

The relay-signed NIP-43 member list (kind:13534) was published after
each membership change had already committed. If that publish failed, or
if a change never republished at all (startup `bootstrap_owner`), the
list stayed wrong until the background reconcile sweep found and fixed
it. #8467 moved that sweep off the startup path. This PR removes the
reason the sweep exists.

Now every `buzz-db` function that changes `relay_members` writes the new
list **in the same transaction**, as its last statement before commit:
- It takes the per-community list lock last, reads the members, signs
the list, retires the old one and inserts the new one.
- If the list write fails, the membership change rolls back and the
caller gets an error.
- A call that changes nothing (already a member, role mismatch, host
exists, limit reached) writes no list.

The functions return `MembershipWrite<T> { outcome, snapshot }`. Callers
push `snapshot` to live subscribers after commit. That push is the only
thing left outside the transaction; if it fails, live clients see the
change on their next fetch, and the stored list is already correct.

Write paths covered (RPR #2, every write path): `add_relay_member`,
`claim_relay_membership`, `claim_relay_invite`, `remove_relay_member`,
`remove_relay_member_if_role`, `update_relay_member_role`,
`bootstrap_owner` / `provision_owner`, `transfer_ownership`,
`backfill_from_allowlist`, and the owner insert in
`create_community_with_owner[_and_nip_fi_issuers]`. Community deletion
removes the members together with the community, so it writes no list.

The relay keypair is passed in explicitly instead of being stored on
`Db`, so the compiler finds every caller.

### Behavior changes

- **Open relays get an accurate list too.** Provisioning and ownership
transfer used to publish only when `require_relay_membership` was on.
The write is now unconditional.
- **Same-second changes can't reuse an id.** The list's `created_at` is
one second past the newest earlier list for that community, including
retired ones, or the current time if that is later. Before, adding and
then removing a member in the same second could produce an event with
the same id as an earlier list. The insert then failed, and the stale
list stayed live. A burst of changes can push `created_at` ahead of the
clock by up to one second per change; it returns to the clock once the
burst ends.
- **`buzz-admin add-member` / `remove-member`** no longer build their
own list (`publish_membership_list_with_bump` is deleted). They push the
list the database wrote. Concurrent CLI runs now serialize on the
database lock rather than depending on `run.sh` running one at a time.
- **Owner mutations lock the community row `FOR NO KEY UPDATE`.**
`transfer_ownership` and `bootstrap_owner` used `FOR UPDATE`. The list
insert takes `FOR KEY SHARE` on that row through `events.community_id`'s
foreign key, so a repair holding the list lock, or a removal or role
update holding a member row, deadlocked with a concurrent owner change.
`FOR NO KEY UPDATE` still conflicts with archive, unarchive and deletion
admission, so the lifecycle ordering is unchanged.
- **Startup delivers its list to other replicas.** When backfill or
`bootstrap_owner` changes members at startup, the relay fans the newest
list out through pub/sub once its state is ready, so clients connected
to other replicas during a rolling deploy see an owner rotation without
a refetch.
- **New `buzz-admin republish-members`.** Rewrites the community's list
from the stored members in one community-write transaction and pushes it
live. This replaces the sweep as the repair for a list that is missing
or stale: a `relay_members` change made directly in SQL, a restore, or a
deployment that turns on `BUZZ_REQUIRE_RELAY_MEMBERSHIP` after running
without it.

## Rollout

Nothing rewrites a list automatically once the sweep is gone. A
community whose list drifted before this deploys stays wrong until its
next membership change or until an operator runs `buzz-admin
republish-members`. Either deploy #8467 and let its sweep complete one
pass first, or run `republish-members` for each community after this
deploys. The command repairs only the community that `RELAY_URL` maps
to, so on a relay hosting many communities, run it once per community
host.

## What got simpler

Deleted:
- `reconcile_nip43_membership_snapshots`,
`spawn_nip43_membership_reconciler`, its leader lock key and #8467's
standby retry
- the three `nip43_membership_snapshot_needs_reconciliation*` drift
reads (one already deprecated)
- `publish_nip43_membership_list` / `publish_nip43_membership_locked`,
the separate after-commit republish at every call site, and
`buzz-admin`'s duplicate publisher
- the reconcile metrics `buzz_nip43_membership_reconciliations_total`
and `buzz_nip43_membership_reconciliation_failures_total`
- the after-commit publish metrics
`buzz_nip43_membership_publications_total` and
`buzz_nip43_membership_publication_seconds`, and the
`publish_nip43_membership_locked` transaction metric label. A failed
list write is now a failed membership write and shows up in the caller's
existing error handling and the transaction metrics of its write path.
- the `BUZZ_NIP43_RECONCILE_INTERVAL_SECS` setting

There is now one way the list gets written, and it can't disagree with
`relay_members` after commit.

**Unused deploy config (outside this repo):** hosted deployments that
set `BUZZ_NIP43_RECONCILE_INTERVAL_SECS` can drop it. It is now ignored.
Dashboards or alerts on the four deleted metrics will stop receiving
data.

## Testing

New Postgres tests (`buzz-db`):
- `relay_members::every_membership_write_commits_its_list`: for each
write path, the returned list is the single live kind:13534 and names
exactly the committed members. No-op calls return no list and leave the
live list alone.
- `relay_members::a_failed_list_write_rolls_back_the_membership_change`:
holds the list lock, waits for the join to block on it, cancels that
statement, and checks that the member was not added and the previous
list is still live.
- `relay_members::concurrent_joins_all_land_in_the_list`: 8 concurrent
joins; the final list names all 8.
-
`relay_members::same_second_return_to_an_earlier_membership_writes_a_new_list`:
add, remove, add, remove within a second; every list is newer than the
last and no id repeats.
- `relay_members::owner_transfer_and_list_repair_both_commit` and
`relay_members::owner_transfer_and_member_removal_both_commit`: hold a
repair (list lock) or removal (member row) open, wait until a transfer
blocks behind it, then finish it. Both transactions commit; with `FOR
UPDATE` back in place, one of them fails.
- `community::create_community_with_owner_is_atomic_and_create_only` and
`relay_invite::bounded_claim_exhausts_and_existing_member_retry_does_not_consume`
now also check the list.

Each new test was checked to fail against a mutation of the new code
that brings back the old behavior (details in the thread).

Stacked on #8467. Merge order: #8467, then #8468, then this.

---------

Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
2026-10-20 11:39:13 -04:00
Logan Johnson
25bcb791b4 fix(relay): show only legacy channel types to reads that name no channel (#8469)
🤖
## Summary
Every Buzz channel has a **type**, such as an ordinary chat channel
(`stream`), a forum, a DM or a workflow. A client app can only show the
types it was built to understand. This PR makes the relay (the Buzz
server) follow one rule everywhere: **a client sees a channel only if it
knows that channel's type.** #8406 applied this rule to membership
notices. This PR applies it to the reads that list channels without
naming one: the channel list, "channels I belong to", the sidebar's
unread list and artifact lists.

Before, these reads left out one type by name: system channels. Now they
return only the four types that every client has always known: `stream`,
`forum`, `dm` and `workflow`. Today the result is the same for every
user, because `system` is the only other type.

### Backward compatibility: old clients stay safe
- A client that was released before a new channel type exists will never
receive channels of that type in its lists. It cannot show such a
channel correctly, so it does not get one.
- Requests that old clients send today return exactly what they returned
before. Nothing changes for them now or when a type is added later.

### Forward compatibility: new types can be added safely
- Before, the rule was a deny list: "everything except `system`". Each
new type would appear in every deployed client by default, unless
someone remembered to update five separate checks in the relay. A missed
check would show the new type to clients that cannot handle it.
- Now the rule is an allow list that is frozen: the four original types.
A new type is hidden by default, with no relay change needed. Clients
that understand it can request it on purpose, the same way current
clients request system channels today. Each new type gets its own
opt-in, so adding a type cannot break clients that are already in use.

## Details
- One frozen list, `buzz_core::channel::LEGACY_CHANNEL_TYPES`.
Membership notices (#8406) now use the same constant, so channel reads
and notices cannot drift apart. The duplicate
`LEGACY_NOTICE_CHANNEL_TYPES` is removed.
- One SQL helper, `legacy_channel_type_sql`, builds `channel_type IN
('stream', 'forum', 'dm', 'workflow')` from that list. It replaces the
five `<> 'system'` checks:
- `get_accessible_channel_ids` (both halves): the implicit channel scope
of any filter without `#h`, for example kind 39000 channel metadata,
REQ, COUNT and search;
  - `get_accessible_channels`: the channel list;
  - the personal-read sidebar roster;
  - artifact queries.
- A filter that names a channel (`#h`, or `#d` on kinds 39000–39003)
reads it under the ordinary rules, whatever its type: the reader is a
member, or the channel is open. The uncached named-channel lookup,
`get_readable_named_only_channels`, now returns every readable
non-legacy channel with its type, not only system channels. Before, a
non-member could not read an open later-type channel, and `#d` read
nothing. Workflow run reads and thread windows use the same lookup.
- The relay keeps each named channel in the scope of the filter that
names it only. This also holds when it re-checks membership for a named
channel that the cache lacks.
- The opt-in to system channels (`#t:["system"]` or `#P` on kinds
39000–39003) adds readable **system** channels only. It never adds a
channel of another type, even one that a sibling filter in the same
request names. The relay scope type `NamedOnlyChannels` keeps the type
with each channel, so the opt-in can tell them apart.
- NIP-CL and NOSTR.md state the rule.

### Related issue
Follows #8406 (the same rule for membership notices).

### Testing
New Postgres tests use a scratch database with a `future_type` value
added to `channel_type`, as a later migration would add one:
- `reads_that_name_no_channel_leave_out_a_type_added_later`: the channel
scope, the channel list and the sidebar roster leave out the later type
and keep the legacy types.
- `artifacts_of_a_later_channel_type_are_read_only_by_name`: artifact
current and history queries and counts without `#h` leave out the later
type. With `#h` naming the channel, they return it.
- `channels_of_a_later_type_are_read_only_by_name`: production read
paths (WS REQ, WS COUNT, HTTP `/query` and `/count`). A member reads a
private later-type channel and anyone reads an open one, by `#h` and by
`#d`; an outsider cannot read the private one; a read that names no
channel returns neither. In one request, a filter that names the channel
plus a `#P` or `#t:["system", <label>]` sibling: the sibling does not
get it.
- `a_named_channel_of_a_later_type_stays_in_its_named_filter`: the
membership re-check for a channel joined mid-request, with an unnamed
sibling and a `#P` sibling. Only the naming filter gets the channel.

Each test fails when its predicate goes back to `<> 'system'`, when the
named lookup loads only `system`, or when the opt-in adds every
named-only channel. The #8406 notice tests and the system channel
Postgres tests pass on this branch.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-20 11:04:29 -04:00
Logan Johnson
cc6336d985 feat(relay): tag every membership notice with its channel type (#8406)
🤖
## Summary
- When someone is added to or removed from a system channel, the relay
now sends them the usual "you were added / removed" notice. Before, it
sent nothing, so a client could learn about a new system channel only by
rereading every channel.
- **A client sees a notice only if it knows the channel's type.** Every
membership notice now carries its channel type. Clients that do not send
`#t` (every client today) see only the types that existed before system
channels, so they see no change. A type that Buzz adds later is hidden
from them by default.
- The notices are stored like every other membership notice, so a client
that was offline can catch up with `since` instead of rereading all its
channels.

## Details
- Every kind:44100 (added) and kind:44101 (removed) notice now carries
`["t", <channel_type>]`, for example `["t","stream"]` or
`["t","system"]`.
- **One place reads the channel type:** `emit_membership_notification`
in `side_effects.rs`, where the relay signs the notice. A channel's type
is set when it is created and no code path changes it, so the tag stays
correct. If the channel row is missing, the relay sends no notice rather
than an untagged one.
- **The rule (NIP-CL):**
- a filter with `#t` matches a notice only when the notice's type is one
of its `#t` values;
- a filter without `#t` matches only the **legacy types**: `stream`,
`forum`, `dm`, `workflow`. This list is frozen
(`LEGACY_NOTICE_CHANNEL_TYPES`), so a new type never joins it;
- a notice without a `t` tag was stored before this change. It is always
a legacy type, so the relay treats it as one, and replay across the
upgrade stays complete.
- **Fails closed.** The rule lives in two shared places, and both
default to the legacy types. A read path that is not updated behaves as
it does today:
- `filters_match` / `notice_visible` (buzz-core). Live fan-out, the REQ
history post-filter, COUNT, the HTTP bridge and push all match through
it.
- `EventQuery::notice_channel_types` (buzz-db). Its SQL keeps only
visible notices before `LIMIT`, so page limits and COUNT stay exact.
`None` means legacy types. Only `filter_to_query_params` sets it, from
the filter's `#t`.
- A client that knows every current type sends one filter:
`{"kinds":[44100,44101],"#p":[<me>],"#t":["stream","forum","dm","workflow","system"]}`.
NIP-CL and NOSTR.md describe this.
- **Only the target receives a notice.** A notice names the target, the
channel and who made the change, and the relay signs it, so knowing its
id is not permission to read it. 44100/44101 are now in
`RESULT_GATED_KINDS`, the one list used by the `ids` exemption check,
the per-result reader check (WS and HTTP history, push), live fan-out
(same pod and Redis) and COUNT. This also closes the same id lookup for
ordinary channels' notices.
- No migration: 44100/44101 are already excluded from search indexing.

## Testing
- `buzz-core`: a table test covers untagged, `stream`, `system` and a
future type against filters without `#t`, with `#t` for one type, and
with `#t` for every type. Other kinds keep the plain `#t` match.
- Postgres tests (`system_channel_postgres_tests.rs`): live fan-out, REQ
history, COUNT and the HTTP bridge, for a filter without `#t`, a
`#t:["system"]` filter and an all-types filter; an untagged legacy
notice; a non-target with an `ids` filter on each path; and a raw
`EventQuery` that does not set the field.
- I broke each part on purpose and the tests failed: no type tag on the
notice, no rule in `filters_match`, no SQL rule, and no untagged clause
in the SQL.
- End to end: I built a relay container from f24f8e7ba and ran
buzz-app's `container-relay.e2e.test.ts` (block/buzz-app#1290) against
it. The live system channel test passed 5 of 5 runs.

Paired client change: block/buzz-app#1290. It works unchanged against
this relay.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-19 16:29:32 -04:00
Kalvin C
cdfc38ecbd feat(search): add newest-first sort to bridge search (#8371)
## Summary

Adds an opt-in newest-first order to bridge search, so clients can offer
"Most recent" alongside relevance.

- `buzz-search`: `SearchQuery` gains `sort: SearchSort`. `Relevance`
keeps the existing `ts_rank_cd` ordering. `Newest` orders hits by
`created_at DESC, id`, and `page` steps through that order. Newest skips
the exact-profile-lexeme prioritization, which only applies to relevance
ranking.
- Bridge `POST /query`: search filters accept `search_sort: "newest"`.
When the field is missing or has another value, results stay in
relevance order. Community scope, channel intersection and per-hit
authorization are unchanged.
- NIP-11 lists `buzz-search-sort` in `supported_extensions`. Clients
only send the field to relays that advertise it, so they never get
relevance results labelled as newest.
- WebSocket NIP-50 search stays relevance-only. `NOSTR.md` documents the
bridge extension.

### Related issue

N/A

### Testing

- New Postgres integration test
`newest_sort_pages_by_time_then_id_within_scope`. It pages a channel in
newest order, including an equal-timestamp tie split across a page
boundary and a newer hit outside the channel scope. It checks that the
pages join up in `(created_at desc, id asc)` order and that relevance
still ranks the strongest match first.
- Unit tests:
  - `bridge_search_sort_extension_selects_newest_only_when_named`
  - `advertises_nip_cl_and_search_sort_extensions`
- Ran the buzz-search Postgres integration suite, the relay `nip11` and
`bridge_search` unit tests, `cargo fmt --check`, and `cargo clippy -p
buzz-search -p buzz-relay --all-targets -D warnings` on Linux.
- Local relay exercised through signed `POST /query`:
  - NIP-11 lists `buzz-search-sort`.
  - Relevance and newest return different orders.
- Newest pages 1–3 return 25 hits with no gaps or duplicates, including
same-second ties across page boundaries.
- A non-member gets no hits from a private channel; a member gets all of
them.
- Client integration is in a companion block/buzz-app change. It was
exercised end to end against this relay through the production broker:
switching sort, paging, scope changes and capability fallback.

Deployment note: clients only offer newest sorting after a relay with
this change advertises `buzz-search-sort`.

---------

Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:27:49 -07:00
Logan Johnson
d28f6b2f70 feat(relay): let channel-state filters opt in to system channels (#8182)
🤖
## Summary
After part 2, a client can read a system channel only if it already
knows the ID. This PR lets a client find system channels on purpose,
with an ordinary Nostr filter and no new filter key.

A filter on the channel-state kinds (39000–39003) now also returns the
system channels the reader can read when it has either:

- `#t:["system"]`, the channel type tag that every channel-state event
already carries; or
- any `#P`, the creator tag from #8174. Asking for the channels of a
named creator returns a small result, so it does not fill generic
channel lists.

The filter then matches as usual. Existing clients send neither on these
kinds, so they still never see a system channel.

| Reader wants | Filter |
|---|---|
| My system channels | `{kinds:[39002], #p:[me], #t:["system"]}`, then
their kind:39000 by `#d` |
| Open system channels | `{kinds:[39000], #t:["system"]}` |
| One creator's labeled channel, system or not | `{kinds:[39000],
#P:[creator], #t:[label]}` |

Private system channels stay hidden from non-members, as for any private
channel. Values in one tag filter combine with OR, so `#t:["system",
label]` means `system` **or** the label; use `#P` plus the label
instead.

Part 3 of 3, stacked on part 2.

### Related issue
None found.

### Details
- The opt-in is decided per filter in `handlers/system_channels.rs` and
applies on REQ, COUNT and the HTTP bridge. It applies only when every
kind in the filter is 39000–39003.
- A label alone is not an opt-in.
- `NOSTR.md` documents the system channel query rules.
- **Spec.** NIP-CL says how a filter asks for system channels.
- The relay information document (NIP-11) now lists `"nip-cl"` in
`supported_extensions`. Clients use this to tell if a relay supports
labels and system channels, and refuse those calls on a relay that does
not. NIP-CL documents this.

### Testing
- End-to-end relay test: `#t:["system"]` finds both system channels for
a member and only the open one for a non-member; 39002 `#p:[me]` with
`#t:["system"]` lists the member's system channels; `#P` includes a
creator's system channels; creator plus label returns exactly one
channel with `limit: 1` and excludes another creator's channel with the
same label; a label alone does not return the system channel; COUNT and
HTTP `/query` and `/count` agree.
- The test fails when the opt-in is disabled.
- Unit tests for which filters opt in.

### Stack
These are two stacks. Merge them in this order. Client support
(creating, labeling and finding these channels) will be in
block/buzz-app, not the buzz SDK and CLI.

**Stack 1: channel labels and creator lookup (#8174–#8176)**

1. #8174 channel type and creator tags, and the channel creation time
2. #8175 channel labels
3. #8176 database-side label and creator match, and the rule for
choosing one channel

**Stack 2: system channels (#8180–#8182)**, stacked on stack 1 because
it uses labels and the creator tag

4. #8180 the system channel type
5. #8181 leave system channels out of reads that do not name them
6. #8182 channel-state filters opt in to system channels ← this PR

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-15 08:40:16 -04:00
Logan Johnson
71d7aa0436 feat(relay): add the system channel type (#8180)
🤖
## Summary
Buzz uses channel membership to decide who can read what, and every
channel a person belongs to shows up in their client as a chat. So there
is no way to make a group that exists only for access control, for
example "only this agent and its owner can read the agent's config".
Such a channel would appear in both people's sidebars, search results
and unread counts.

This stack adds a **system channel**: an ordinary channel that queries
leave out unless they ask for it. This first PR adds the type itself.

- `kind:9007` (create channel) with `["channel_type", "system"]` creates
a system channel.
- It is private unless the creator sends `["visibility", "open"]`.
- While it is private, its channel metadata event (kind:39000) carries
the NIP-29 `hidden` marker, which means "hide this group's metadata from
non-members". Until now the relay set `hidden` only for DMs.

On its own this PR does not yet hide system channels from queries. Part
2 does that.

Part 1 of 3, stacked on #8176 (the channel labels and creator lookup
stack, which this feature uses).

### Related issue
None found.

### Details
- Migration 0062 adds `system` to the `channel_type` Postgres enum. No
new columns.
- `buzz_core::channel::ChannelType::System`, and `system` is now a
reserved label name (labels cannot equal a channel type name).
- `buzz-admin` reconcile writes `hidden` with the same rule as the
relay. Usage metrics count system channels.
- **Rollback.** A relay older than this change cannot read `channel_type
= 'system'`. Do not roll back past it while system channels exist.
- **Spec.** NIP-CL gets a "System channels" section. `ARCHITECTURE.md`
lists the `System` type.

### Testing
- Relay test: a system channel is private and `hidden` by default;
opening it removes `hidden`; its first `t` tag is `system`.
- Migration embed and usage-metrics tests.

### Stack
These are two stacks. Merge them in this order. Client support
(creating, labeling and finding these channels) will be in
block/buzz-app, not the buzz SDK and CLI.

**Stack 1: channel labels and creator lookup (#8174–#8176)**

1. #8174 channel type and creator tags, and the channel creation time
2. #8175 channel labels
3. #8176 database-side label and creator match, and the rule for
choosing one channel

**Stack 2: system channels (#8180–#8182)**, stacked on stack 1 because
it uses labels and the creator tag

4. #8180 the system channel type ← this PR
5. #8181 leave system channels out of reads that do not name them
6. #8182 channel-state filters opt in to system channels

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-14 19:03:34 -04:00
Logan Johnson
2f79374f48 feat(relay): channel labels (#8175)
🤖
## Summary
A plugin or agent often needs to tag its own channels and then find them
again, for example "my attention-config channel" or "all channels of
this workspace". Today a channel has no labels: the relay ignores any
`t` tags in create or edit requests. This PR adds channel labels.

- A create request can carry up to 8 labels as `["t", "<label>"]` tags.
- An edit request with `t` tags replaces the label set. `["t", ""]`
alone clears it, the same convention as `["ttl", ""]`. Only owners and
admins can change labels, as for the name.
- The relay publishes each label as a `t` tag on the channel's state
events, after the channel type tag.

Part 2 of 3, stacked on part 1.

### Related issue
None found.

### Details
- **Rules.** A label uses 1–64 characters from `a-z`, `0-9`, `.`, `:`
and `-`. The relay refuses a label equal to a channel type name
(`stream`, `forum`, `dm`, `workflow`, `system`), so a label cannot make
a channel look like another type.
- **No trust from labels.** Any admin can set a label. Only the
relay-written creator tag (part 1) says who made a channel.
- **Storage.** New migration `0061_channel_labels.sql` adds
`channels.labels TEXT[] NOT NULL DEFAULT '{}'`. Channels without labels
look and behave as before.
- **Readers.** The CLI and `buzz-acp` read the channel type from the
first `t` tag. Before this PR they used the last one, which would now be
a label.
- **Spec.** NIP-CL gets a "Channel labels" section with these rules.

### Testing
- Relay tests: labels at creation appear after the type tag, edit
replaces and clears them, invalid labels are refused, and members who
are not admins cannot change them.
- Unit tests for the label rules: allowed characters, length, reserved
type names, the 8-label cap and the clear marker.
- `buzz-acp` test: the channel type comes from the first `t` tag, not a
label.

### Stack
These are two stacks. Merge them in this order. Client support
(creating, labeling and finding these channels) will be in
block/buzz-app, not the buzz SDK and CLI.

**Stack 1: channel labels and creator lookup (#8174–#8176)**

1. #8174 channel type and creator tags, and the channel creation time
2. #8175 channel labels ← this PR
3. #8176 database-side label and creator match, and the rule for
choosing one channel

**Stack 2: system channels (#8180–#8182)**, stacked on stack 1 because
it uses labels and the creator tag

4. #8180 the system channel type
5. #8181 leave system channels out of reads that do not name them
6. #8182 channel-state filters opt in to system channels

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-14 17:25:46 -04:00
Logan Johnson
8bb49a1b6a feat(relay): publish channel type and creator on every channel-state event (#8174)
🤖
## Summary
Clients cannot tell who created a channel, and only the channel-metadata
event says what type of channel it is. This PR makes every relay-signed
channel-state event carry both facts, so a client can find "the channel
that this key created" with one ordinary Nostr filter.

- Channel metadata, admin list and member list events (kinds 39000,
39001 and 39002) now all carry the channel type as their first `t` tag,
then the creator as a `P` tag.
- The creator comes from the relay's own record of who signed the
channel's create request. No client can set it, and it does not change
when ownership moves.
- Channel metadata (kind:39000) also carries `["created_at", <unix
seconds>]`, the time the channel was created. The event's own
`created_at` changes on every edit, so before this a client could not
tell which of two channels is older.

This is part 1 of a 3-part stack (channel labels and creator lookup).
The system channels stack (#8180–#8182) builds on it.

### Related issue
None found.

### Details
- **Why `P`.** Several Nostr specs (NIP-22, NIP-34, NIP-72) use
uppercase `P` for the author of the root object, and lowercase `p` for
people involved. Lowercase `p` is already used on these events for
members and DM participants, and a filter matches tag values, not their
meaning, so `#p` would mix them.
- **Trust.** A client that trusts a channel because of its creator must
also check that the relay key signed the event.
- **Order.** The type `t` tag comes first, so clients that read the
first `t` tag still get the type after part 2 adds labels as more `t`
tags.
- **Creation time.** The relay reads it from `channels.created_at`, so
an edit does not change it. #8176 uses it to pick one channel when two
creates race.
- `buzz-admin reconcile` writes the same tags when it rebuilds these
events. `NOSTR.md` documents the tags.
- **Spec.** New `docs/nips/NIP-CL.md` defines these tags, the trust rule
and lookup by creator. Later PRs in both stacks add their parts to it.
`NOSTR.md` links to it.

### Testing
- Relay tests in `group_state_postgres_tests.rs`: each of the three
kinds carries the type first, then the creator; the creator stays the
same after an ownership transfer; kind:39000 carries the stored creation
time, and still does after an edit.
- Unit test for the shared tag builder that both the relay and
`buzz-admin reconcile` use.

### Stack
These are two stacks. Merge them in this order. Client support
(creating, labeling and finding these channels) will be in
block/buzz-app, not the buzz SDK and CLI.

**Stack 1: channel labels and creator lookup (#8174–#8176)**

1. #8174 channel type and creator tags, and the channel creation time ←
this PR
2. #8175 channel labels
3. #8176 database-side label and creator match, and the rule for
choosing one channel

**Stack 2: system channels (#8180–#8182)**, stacked on stack 1 because
it uses labels and the creator tag

4. #8180 the system channel type
5. #8181 leave system channels out of reads that do not name them
6. #8182 channel-state filters opt in to system channels

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-14 09:22:42 -04:00
Bradley Axen
1d9f2c81a5 feat(relay): accept an owner's presence for its agents (kind 20003) (#8261)
## Summary

App-hosted agents (buzz-app agents2) run inside the desktop app without
a socket per agent, so they can't send their own kind 20001 presence and
always read as offline — including to their owner on another device.

This adds **kind 20003, an owner's presence for its agents**:

- Owner-signed; `content` is the status (`online` / `away` / `offline`);
each `p` tag names an agent (1–256 distinct).
- WebSocket only (rejected on HTTP ingest, like 20001).
- The relay keeps only the agents the signer owns
(`users.agent_owner_pubkey`) — cached `true` from
`observer_owner_cache`, the rest resolved with one
`list_agents_for_owner` query. Only positive links are cached, so a
just-linked agent isn't hidden for the cache TTL and the observer path
isn't poisoned.
- Sets (180s TTL) or clears each agent's presence in one Redis round
trip, the same keys kind 20001 writes, so the presence snapshot reports
them with no client change.
- Not fanned out: live presence is matched by author, and the author
here is the owner.

A new kind rather than `p` tags on 20001, so a relay without this
ignores the batch instead of storing it as the owner's own presence.

## Testing

- `agent_presence_request_names_distinct_agents_with_a_known_status`
(unit).
-
`presence_storage_postgres_tests::agent_presence_stores_only_owned_agents`
(Postgres + Redis): only the owner's agent goes online, another owner's
agent doesn't, a pre-cached `false` doesn't hide a linked agent, and
`offline` clears it. All 5 presence Postgres tests pass via
`scripts/postgres-test-run.sh -p buzz-relay --lib -E
'test(/presence/)'`.
- `buzz-pubsub` ignored Redis test
`test_presence_many_sets_with_ttl_and_clears` passes.
- clippy clean. Plain `cargo test` failures locally were environmental
only (buzz-media "role buzz does not exist", mesh_demo 504).
- Not yet exercised end to end against a local relay + app.

App side: block/buzz-app#862.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Bradley Axen <baxen@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:43:24 -04:00
Ziga Drev
756dd7f65d docs(nostr): document #h requirement for live reaction subscriptions (#3487)
## What this fixes

`fan_out_scoped` (`crates/buzz-relay/src/subscription.rs:278-394`)
enforces a deliberate, symmetric scoping invariant — documented in the
code itself:

> Global subscriptions (channel_id = None) do NOT receive channel-scoped
events. Channel-scoped subscriptions do NOT receive global events.

The relay derives a reaction's stored channel from its `#e` target at
ingest — client-supplied `#h` is ignored for channel determination
(`NOSTR.md:50` documents this for *writing*). The consequence for
*reading* is that every reaction is a channel-scoped event, so a live
subscription `{"kinds":[7]}` without `#h` is a global subscription and
**silently receives no reactions at all** — no error, no CLOSED, just
nothing. The working form is `{"kinds":[7],"#h":["<channel-uuid>"]}`,
and it works regardless of how the reaction was signed: explicit `h`
tags on the event are matched directly, and tagless reactions match via
the stored channel fallback (`crates/buzz-core/src/filter.rs:78-91` —
fallback applies only when the event has no `h` tags; explicit tags are
authoritative).

`NOSTR.md` already documents this exact pitfall for group-metadata
events:

> **Note:** Channel-scoped storage means live global subscriptions
(`{kinds:[39000]}`) won't receive these via fan-out.
(`NOSTR.md:124-126`)

…but has no equivalent note for reactions, which is the case a
bot/integration author is far more likely to hit: any client that wants
to observe approvals/reactions live (workflow reaction-triggers make
this a first-class pattern in Buzz) will naturally try a kinds-only REQ
first and conclude reactions are broken. We lost real debugging time to
exactly this while building a headless integration
(https://github.com/OriginTrail/buzz-dkg-integration); the behavior is
by design, only the docs are missing.

## What this PR changes

Docs only (`NOSTR.md`): a subscribe-to-reactions example in "Sending
Messages", plus one note mirroring the existing 39000 note. No code
changes.

## How to verify

- Behavior: with the relay running, open a live REQ `{"kinds":[7]}` (no
`#h`) and react to a channel message from another client → nothing is
delivered; re-subscribe with `{"kinds":[7],"#h":["<channel-uuid>"]}` →
the reaction arrives.
- Claims against code (verified at `485d03a`): scoping invariant
`crates/buzz-relay/src/subscription.rs:386-393`; channel derivation
`derive_reaction_channel()` in
`crates/buzz-relay/src/handlers/ingest.rs`; `#h` fallback
`crates/buzz-core/src/filter.rs:78-91` and its test
`h_tag_fallback_uses_stored_channel_id`.

Duplicate search: no existing issue/PR found for `reactions
subscription`, `fan-out kinds` (searched 2026-07-29). DCO signed-off.

---------

Signed-off-by: Žiga Drev <ziga.drev@gmail.com>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Co-authored-by: Žiga Drev <ziga.drev@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
2026-08-01 12:33:43 -04:00
Sean Gearin
909a3b2c31 docs: fix stale kind count, quick-start numbering, and empty Further Reading (#2613)
## Problem

Three small documentation defects, each verified against the code at
06e3d82b:

1. **ARCHITECTURE.md (Event Kinds section)** says `buzz-core` defines
"all 81 kinds". The registry has grown: `ALL_KINDS` in
`crates/buzz-core/src/kind.rs` now has **127** entries (all unique
values). The sentence also says every kind is `pub const KIND_*`, but
registry entries such as `RELAY_ADMIN_ADD_MEMBER` do not use that
prefix.

2. **NOSTR.md Quick Start** numbers its steps 1, 2, 3, 5 — there is no
step 4. PR #797 (2a038515) collapsed the old steps 1-4 (dropping the
separate "Start infrastructure" step) into 1-3, but the final "Connect
any NIP-29 + NIP-42 client" comment kept its old number 5.

3. **NOSTR.md "Further Reading"** is an empty heading — the section's
only content (a link to `crates/buzz-proxy/README.md`) was removed in PR
#1321 (14fba21e) along with the proxy crate itself, leaving a dangling
header as the last line of the file.

## Fix

1. Reworded the ARCHITECTURE.md sentence to defer to
`crates/buzz-core/src/kind.rs` as the source of truth, with the current
count (127) as an explicit "at the time of writing" snapshot, so the
sentence stays truthful as kinds are added. Also removed the incorrect
`KIND_*`-naming claim.
2. Renumbered the final quick-start step 5 → 4.
3. Populated Further Reading with three durable links: the upstream
nostr-protocol/nips repo, this repo's `docs/nips/` extension documents,
and `ARCHITECTURE.md`.

Docs-only; no code changes, no build impact.

## Verification (each claim ~30 seconds)

- Kind count: `python3 -c "import re;
s=open('crates/buzz-core/src/kind.rs').read(); m=re.search(r'ALL_KINDS:
&\[u32\] = &\[(.*?)\];', s, re.S); print(len([e for e in
m.group(1).split(',') if e.strip()]))"` → 127. All 127 values are
distinct. Non-`KIND_*` entry example: `RELAY_ADMIN_ADD_MEMBER` (kind.rs,
in `ALL_KINDS`).
- Missing step: `grep -n '^# [0-9]' NOSTR.md` on main shows `# 1.`, `#
2.`, `# 3.`, `# 5.` in the Quick Start block; `git show 2a038515 --
NOSTR.md` shows the renumbering that orphaned step 5.
- Empty section: `tail -1 NOSTR.md` on main is `## Further Reading` with
nothing after it; `git log -S'buzz-proxy/README' --oneline -- NOSTR.md`
shows the content removal in 14fba21e (#1321).

## Links

- `crates/buzz-core/src/kind.rs` — `ALL_KINDS` registry (source of truth
for the count)
- PR #797 / 2a038515 — introduced the step-numbering gap
- PR #1321 / 14fba21e — emptied the Further Reading section

Signed-off-by: Sean Gearin <sgearin@gmail.com>
Co-authored-by: Sean Gearin <sgearin@gmail.com>
2026-08-01 08:50:25 -04:00
Wes
5bfd5ca270 feat: per-community workspace icon set by admins, served via NIP-11 (#1463)
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
2026-07-02 11:58:42 -07:00
Tyler
14fba21e57 Multi-tenant Buzz relay: community_id as a server-resolved key (comprehensive rewrite) (#1321)
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Quinn <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: Sami <sami@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
2026-06-29 12:39:02 -04:00
Tyler
2ecdcce7bd Multi-tenant relay: spec + mechanized formal proof (S1–S8) (#1285)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
2026-06-26 11:15:59 -04:00
Will Pfleger
0cee0435f7 feat(relay): add buzz-admin member management CLI with NIP-43 roster publish (#1265)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
2026-06-24 23:13:31 -04:00
Wes
79bcee55cb docs: finish Buzz rename cleanup (#974)
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
2026-06-11 09:28:18 -07:00
Will Pfleger
2a03851527 refactor: consolidate infra management into justfile + add mobile-dev (#797) 2026-05-29 18:26:24 -04:00
Wes
0dcfa86f84 chore: remove dead API token references from GUI, docs, and config (#498)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 19:07:44 +00:00
tlongwell-block
3558302373 docs: NOSTR.md accuracy pass — verified against every line of code (#240) 2026-04-05 13:55:29 -04:00
tlongwell-block
39e856fd85 feat: NIP-50 search, NIP-10 threads, NIP-17 DMs, Sprout DM discovery (#74) 2026-03-16 11:15:06 -04:00
tlongwell-block
10d8ce9747 docs: consolidate Nostr client guide into NOSTR.md (#72) 2026-03-15 21:25:10 -04:00
tlongwell-block
124047ef88 feat: NIP-29 native compatibility — standard nostr clients can chat on Sprout (#63) 2026-03-14 10:51:20 -04:00
tlongwell-block
975b7be4a8 feat(proxy): NIP-28 translation proxy for third-party Nostr clients (#43) 2026-03-12 22:16:00 -04:00