Repository navigation
feat(relay): add an unlisted channel setting - #8579
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
🔐 Codex Security Review
Review SummaryOverall Risk: NONE
FindingsNo concrete security, correctness, or reliability findings were identified. Notes
Generated by Codex Security Review | |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 082675dfc3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
RustSec published RUSTSEC-2026-0387 against crypto-bigint: some operations meant to be constant-time could branch on secret values. cargo-deny now fails the Security job on main and every PR. crypto-bigint 0.5.5 came in only through p256 0.13 in buzz-push-gateway. p256 0.14 uses crypto-bigint 0.7.9, which is patched. The only API change the gateway hits is the SEC1 encoder, renamed from ToEncodedPoint::to_encoded_point to ToSec1Point::to_sec1_point. Co-authored-by: ART <ea39c1c8acf57180161ef4ba4ed68abf8589f5291956b909a5aa991fc66664ca@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Bradley Axen <baxen@squareup.com>
An unlisted open channel can be read and joined by anyone who names it by id, as with a link-shared document, but it is left out of every read that lists channels to non-members: kind:39000 discovery without #d, messages, COUNT, NIP-50 search, artifact queries and the channel list. Members still see it everywhere. - Migration 0069 adds channels.unlisted BOOLEAN NOT NULL DEFAULT FALSE. - kind:9002 ["unlisted", "true"|"false"], owner/admin only, posts an unlisted_changed notice and clears the accessible-channels cache. - The open half of get_accessible_channel_ids and get_accessible_channels leaves unlisted channels out; get_readable_named_only_channels adds them, so a filter that names one reads it, as for system channels. - kind:39000 carries a bare ["unlisted"] flag; NIP-11 advertises buzz-unlisted-channels. - buzz channels update --unlisted true|false. Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz> Signed-off-by: Bradley Axen <baxen@squareup.com>
Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz> Signed-off-by: Bradley Axen <baxen@squareup.com>
082675d to
d3e0e44
Compare
A channel can now start unlisted, so a new Solo chat stays unlisted when any client later opens it, and sharing never lists it even for a moment. - kind:9007 takes ["unlisted", "true"|"false"]; a missing tag means listed. Any other value is refused before the event is stored, as on kind:9002. The flag is written with the channel row on both create paths (client UUID via create_channel_with_event, relay-chosen UUID via create_labeled_channel). - buzz_sdk::build_create_channel takes `unlisted: bool`; buzz channels create --unlisted. - test_nip29_solo_share_and_convert_wire exercises the Solo flows as single events: create unlisted, share (open + unlisted + join_role guest), read by #h (history and live) as a non-member, pin (9021), unpin (9022), unshare (private), and convert (["t", ""] + unlisted false). Added to the CI relay E2E step. Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz> Signed-off-by: Bradley Axen <baxen@squareup.com>
CI runs each wire test as its own cargo test against one shared relay, and `since` starts a second early, so the stranger could list open channels the previous test had just created. Assert that the chat created unlisted is not listed instead of asserting an empty list. Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz> Signed-off-by: Bradley Axen <baxen@squareup.com>
A non-member who opens a pinned Solo chat by id names it on every sync, and each such request ran get_readable_named_only_channels, which returned every unlisted open chat in the community, uncached, on the writer pool. Its unlisted branch now takes the channels the request names: the filters' #h/#d values outside the implicit set, a thread window's channels, or a workflow's channel. The system-channel branch is unchanged, since #t:system/#P opt-in still needs the full set. Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz> Signed-off-by: Bradley Axen <baxen@squareup.com>
## Summary RustSec published [RUSTSEC-2026-0387](https://rustsec.org/advisories/RUSTSEC-2026-0387) against `crypto-bigint`. Some of its operations meant to be constant-time could compile to code that branches on secret values. Since the advisory came out, `cargo-deny check` reports advisories as failed. That breaks `Security Domain / Security` on `main` and on every PR that runs the Rust security job (for example #8575 and #8579). `crypto-bigint` 0.5.5 came in only through `p256` 0.13 → `elliptic-curve` 0.13 in `buzz-push-gateway`. The advisory has no 0.5.x fix; the patched line is 0.7.6+. This PR moves `buzz-push-gateway` to `p256` 0.14, which uses `crypto-bigint` 0.7.9. | Advisory | Crate | Change | |---|---|---| | [RUSTSEC-2026-0387](https://rustsec.org/advisories/RUSTSEC-2026-0387) | `crypto-bigint` | 0.5.5 → 0.7.9 (via `p256` 0.13.2 → 0.14.0) | **Code changes:** `p256` 0.14 renamed the SEC1 encoder from `ToEncodedPoint::to_encoded_point` to `ToSec1Point::to_sec1_point`. The gateway calls it in two places, both to re-encode an App Attest public key as uncompressed SEC1 bytes. The tests call it in five more. Nothing else in the gateway's API use changed. **Lockfile:** only the `p256` dependency chain changes: `ecdsa`, `elliptic-curve`, `ff`, `group`, `primeorder`, `rfc6979` and `sec1` move up a minor version. `primefield` and `wnaf` are added. The old `der`/`spki`/`pkcs8`/`pem-rfc7468`/`const-oid` 0.x versions that only this chain used are removed. `desktop/src-tauri/Cargo.lock` and the `buzz-feature-flags` fixture lockfile don't contain `crypto-bigint`, so they're unchanged. **Exposure, for context:** in production the gateway uses `p256` only to verify App Attest signatures against public keys. Signing with a private key happens only in tests. So the timing issue didn't realistically apply here, but the bump clears the advisory instead of ignoring it. ### Related issue N/A. I searched open and closed PRs for `RUSTSEC-2026-0387` and `crypto-bigint` and found none. ### Testing - `cargo-deny check`: `advisories ok, bans ok, licenses ok, sources ok`. On `origin/main` the Security job fails on this advisory. - `cargo test -p buzz-push-gateway --all-features`: 82 passed, 18 ignored (the same tests are `#[ignore]` on `main`). - `cargo clippy -p buzz-push-gateway --all-targets --all-features -- -D warnings` is clean. - `cargo check --workspace --all-targets` passes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Bradley Axen <baxen@squareup.com> Co-authored-by: ART <ea39c1c8acf57180161ef4ba4ed68abf8589f5291956b909a5aa991fc66664ca@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
🤖 Reviewed head
820 CLI/SDK unit tests passed; targeted PostgreSQL and CLI mock probes confirmed these gaps. |
An older relay applies the rest of a kind:9007 or kind:9002 and ignores unlisted, so an open channel created or updated with --unlisted ended up listed. channels create --unlisted (with or without --template) and channels update --unlisted true now read the relay's NIP-11 first and refuse, before anything is written, when supported_extensions lacks buzz-unlisted-channels. --unlisted false needs no check: such a relay lists every open channel already. Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz> Signed-off-by: Bradley Axen <baxen@squareup.com>
The system branch of get_readable_named_only_channels loaded every open system channel whatever its unlisted flag, so #t:["system"] and #P discovery showed a non-member an unlisted open system channel. An open system channel now counts for a non-member only when it is listed or the request names it, and each row says whether the opt-in may add it: not for an unlisted channel the reader is not a member of, so a sibling of a filter that names it does not gain it either (NIP-CL). Members keep it as before. Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz> Signed-off-by: Bradley Axen <baxen@squareup.com>
…s-work * origin/main: fix(deps): bump p256 to 0.14 for RUSTSEC-2026-0387 (#8592) Signed-off-by: Bradley Axen <baxen@squareup.com>
|
🤖 Both fixed in 1. CLI gate (
2. Unlisted system channels (
Validation
|
Renumber the unlisted migration to 0071: main took 0069 and 0070 for entity deletion (#8520). Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Main's #8579 took migration 0071 for the unlisted channel setting, so the invitation migration moves to 0072_invite_revocation.sql. Upgrade fixtures now start from main's schema through 0071, the migrator test expects 72 contiguous migrations with the unlisted assertion at index 70 and the invitation assertions at index 71, and both TESTING.md files name 0072. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com>
🤖
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
openvisibility 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.
visibility private+["unlisted", "true"]visibility open+unlisted true+join_role guest|member. Then post the link.join_roledecides whether someone who joins can.#h, or by#dfor its metadatajoin_role, so withmemberthey can post. A kind:9022 leave makes them a viewer again; the chat stays readable by id.visibility private["t", ""]+unlisted false, plusvisibilitySidebar 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
#d,#Pcreator queries, messages, COUNT, NIP-50 search, artifact queries, channel list)#h, or#don 39000–39003)join_rolestill decides the role["unlisted", "true"]/["unlisted", "false"]. Owner or admin only, likevisibility. 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 anunlisted_changedsystem message and clears the accessible-channels cache across nodes. Nobody loses access when a channel is unlisted, so no subscriptions are evicted.["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.["unlisted"]flag while the setting is on. It does not add NIP-29hidden, so the metadata stays readable by id.buzz-unlisted-channelsinsupported_extensions. A relay without this PR would apply["visibility", "open"]and ignoreunlisted, which would list the chat publicly. So buzz-app must check for the extension before it sends that pair.buzz channels update --channel <id> --unlisted true|falseandbuzz channels create ... --unlisted.buzz_sdk::build_update_channeltakes a newunlisted: Option<bool>argument, andbuzz_sdk::build_create_channeltakesunlisted: bool, which emits the tag only when true.Open question: does
unlistedonly 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:
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) addsAND 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/queryand/count, thread windows, workflow run reads) adds a named-only channel only to a filter that names it, throughfilter_channel_scope. An unlisted channel's type isstream/forum, so the#t:["system"]/#Psystem 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#hnames that channel.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/queryand/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/sidebaraccessory API are lists, so leaving out chats you only view is intended. Media reads don't check channels.buzz-auth'sChannelAccessCheckerhas no production implementation.Database (docs/database.md checklist)
ADD COLUMN ... BOOLEAN NOT NULL DEFAULT FALSEstores the constant default in the catalog, so the table is not rewritten.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_migrationsnow compareschannelscolumns between the pgschema desired state and the migrated database. I failed it once by removing the column fromchannels.sql. Only columns are compared forchannels: on currentmain, pgschema already re-emitsidx_channels_id_livewithout itsINCLUDE (community_id), an index drift that predates this PR and is noted in the test.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 isunlisted.SET LOCAL lock_timeout = '5s'. No index is added: the new predicates ride the existing(community_id, visibility)index.Rollout
Tests
Postgres (
buzz-db, discovered by the Postgres lane):unlisted_open_channel_is_named_only_for_non_members: sets the flag throughupdate_channel. Then checks that a non-member doesn't get it fromget_accessible_channel_idsorget_accessible_channels, and does get it fromget_readable_named_only_channels; that a member still lists it; thatput_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 testpublishes_the_unlisted_flag_as_stored.unlisted_channel_artifacts_are_read_by_name_for_non_members.channel_created_unlisted_is_never_listed: creating throughcreate_channel_with_event(the kind:9007 path) andcreate_labeled_channelstores the flag, and the channel is never listed to a non-member.NOT unlistedin the implicit scope,NOT c.unlistedin the channel list, the unlisted branch of the named-only query, itsvisibility = '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:#dcarriesunlistedandpublic, and nothidden.#d: non-members don't see the channel, members do.#h, they do; a member sees them unnamed.Relay E2E (
test_nip29_solo_share_and_convert_wire, added to the CI relay E2E step), each flow as a single event:unlistedvalue is refused. A chat created withunlisted truepublishes the flag, and stays unlisted after a plainvisibility open.join_role guestin 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.visibility private): the viewer's live subscription gets CLOSED, and a new#hread is refused.["t", ""]+unlisted false): the Solo label is cleared, and a non-member finds the chat in discovery.Also run:
cargo testfor 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) andresent_edit_is_a_duplicate(a pool timeout). In a full locale2e_relayrun, 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 frommain.Not in this PR
buzz-unlisted-channels.buzz-review-completed