Skip to content

feat(relay): add an unlisted channel setting - #8579

Merged
TheSentinel454 merged 10 commits into
mainfrom
mbot/unlisted-channels
Oct 26, 2026
Merged

TheSentinel454 merged 10 commits into
mainfrom
mbot/unlisted-channels

Conversation

@baxen

@baxen baxen commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

🤖

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

@baxen
baxen marked this pull request as ready for review October 25, 2026 17:05
@baxen
baxen requested a review from a team as a code owner October 25, 2026 17:05
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-25T17:13:07.185580Z 082675d Draft marked ready
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@baxen
baxen deployed to codex-review October 25, 2026 17:05 — with GitHub Actions Active
@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Oct 25, 2026
@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

🔐 Codex Security Review

Note: This is an automated, security-focused review generated by Codex.
Use it as a supplement to human review; false positives are possible.

Scope

  • Exact PR diff: d0dee1cb50736c5dff4b4b2ad3dad0acf2be7794...9fdb9e8def6deed2d1f2f8c8cd3f49a102c9676d
  • Model: gpt-6.1-sol

💡 Click "edited" above to see earlier reviews for this PR.


Review Summary

Overall Risk: NONE

No concrete security, correctness, or reliability findings identified in the authorized PR range.

Findings

No concrete security, correctness, or reliability findings were identified.

Notes

  • Review used read-only source and history inspection. Builds and tests were not executed, as instructed.

Generated by Codex Security Review |
Requested by: @TheSentinel454 |
Workflow run

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread crates/buzz-relay/src/handlers/side_effects.rs
baxen and others added 3 commits October 25, 2026 15:53
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>
@baxen
baxen force-pushed the mbot/unlisted-channels branch from 082675d to d3e0e44 Compare October 25, 2026 23:09
@baxen
baxen changed the base branch from main to art/crypto-bigint-0387 October 25, 2026 23:09
@github-actions github-actions Bot removed the codex-security-review-current The posted Codex security review matches its recorded range. label Oct 25, 2026
baxen and others added 3 commits October 25, 2026 17:45
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>
matt2e pushed a commit that referenced this pull request Oct 26, 2026
## 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>
Base automatically changed from art/crypto-bigint-0387 to main October 26, 2026 06:43
@salman1993

Copy link
Copy Markdown
Contributor

🤖 Reviewed head 61bb10ab0. Two P2 findings:

  • Gate CLI unlisted operations on relay support. Create/update submit unlisted without checking the NIP-11 buzz-unlisted-channels extension. Older relays apply visibility=open and ignore unlisted, silently making the channel discoverable. Check support before submitting. (code)
  • Exclude unlisted system channels from non-member discovery. The named-only query returns open system channels regardless of unlisted; #t:["system"] and #P discovery then include them, contrary to NIP-CL. Preserve the flag/membership distinction while allowing reads that name the channel. (code)

820 CLI/SDK unit tests passed; targeted PostgreSQL and CLI mock probes confirmed these gaps.

baxen and others added 3 commits October 26, 2026 06:25
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>
@baxen

baxen commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Both fixed in c520ec9fe. The fixes are 9760e6c4f and a5a65264b, followed by a merge of main that leaves the tree unchanged (#8592 squashed the stacked p256 commit).

1. CLI gate (9760e6c4f)

  • channels create --unlisted, with or without --template, and channels update --unlisted true now read NIP-11 /info first. They refuse if supported_extensions lacks buzz-unlisted-channels, and nothing is written in that case. With --template, the check runs before roster resolution.
  • --unlisted false skips the check. A relay without the extension lists every open channel anyway, so that request can't produce the wrong result.
  • Mock tests in channels.rs (unlisted_support_tests):
    • Without the extension, create and update both refuse and no event is sent.
    • With the extension, both send ["unlisted","true"].
    • Listed writes never check.

2. Unlisted system channels (a5a65264b)

  • In get_readable_named_only_channels, an open system channel now counts for a non-member only when NOT c.unlisted OR c.id = ANY($3). Members keep it as before.
  • Each row also returns opt_in = NOT c.unlisted OR <member>, and NamedOnlyChannels::from_readable adds a system channel to the #t:["system"]/#P set only when opt_in is true. Without that column, a sibling filter in the same REQ that names the channel would load it, and the opt-in filter would pick it up, which NIP-CL forbids ("A filter that names a channel does not add it to another filter in the same request").
  • Tests:
    • buzz-db an_unlisted_system_channel_is_read_only_by_name:
      • Naming nothing loads only the listed system channel.
      • Naming the unlisted one loads it with opt_in=false.
      • The member gets both with opt_in=true.
      • Listing it again restores it.
    • buzz-relay unlisted_system_channels_are_found_only_by_name checks REQ, COUNT and bridge /query:
      • The outsider's #t:system and #P return only the listed channel.
      • #h and #d read the unlisted one.
      • An opt-in sibling of an #h filter doesn't gain it.
      • The member finds both.
    • Mutation-checked: dropping the condition fails the db test, ignoring opt_in fails the relay test, and forcing opt_in to true fails both.
  • NOSTR.md now says an unlisted open channel of any type is left out of the #P/#t:["system"] opt-in. NIP-CL already said so.

Validation

  • fmt and clippy -D warnings (buzz-db, buzz-relay, buzz-cli) are clean, and buzz-cli unit tests pass 501/501.
  • Postgres lane: 1044 of 1048 pass. The 4 failures are the PoolTimedOut flakes in edit_metadata/group_state that also fail on the base commit locally.
  • Against a local relay built from a5a65264b, e2e_relay passes 62/62, including nip29_unlisted_channel_wire and nip29_solo_share_and_convert_wire.
  • CLI smoke against that relay: channels create --unlisted was accepted.

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>
@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Oct 26, 2026
@TheSentinel454
TheSentinel454 merged commit a3a47e6 into main Oct 26, 2026
169 of 172 checks passed
@TheSentinel454
TheSentinel454 deleted the mbot/unlisted-channels branch October 26, 2026 18:03
TheSentinel454 added a commit that referenced this pull request Oct 26, 2026
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>

This branch was successfully deployed

1 active deployment
codex-review — 9fdb9e8d Deployed Oct 26, 2026 by TheSentinel454 via Run Codex Security Review #9842
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

codex-security-review-current The posted Codex security review matches its recorded range.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants