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>
This commit is contained in:
Bradley Axen
2026-10-26 11:03:07 -07:00
committed by GitHub
parent ab73cd9fb5
commit a3a47e6294
25 changed files with 1730 additions and 101 deletions

View File

@@ -655,6 +655,30 @@ fn extract_head_expected_revision(head: &serde_json::Value) -> Option<String> {
.map(str::to_string)
}
/// The NIP-11 extension a relay advertises when it honors the `unlisted` tag.
const UNLISTED_EXTENSION: &str = "buzz-unlisted-channels";
/// Refuse to unlist a channel on a relay that does not advertise
/// [`UNLISTED_EXTENSION`]. Such a relay applies the rest of the event and
/// ignores `unlisted`, so an open channel would end up listed. Call it before
/// anything is written.
async fn require_unlisted_support(client: &BuzzClient) -> Result<(), CliError> {
let raw = client.get_public("/info").await?;
let info: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("invalid NIP-11 response: {e}")))?;
let supported = info
.get("supported_extensions")
.and_then(|v| v.as_array())
.is_some_and(|exts| exts.iter().any(|e| e.as_str() == Some(UNLISTED_EXTENSION)));
if supported {
Ok(())
} else {
Err(CliError::Other(format!(
"this relay does not support unlisted channels (missing \"{UNLISTED_EXTENSION}\" in supported_extensions); it would ignore --unlisted and list the channel"
)))
}
}
pub async fn cmd_create_channel(
client: &BuzzClient,
name: &str,
@@ -662,6 +686,7 @@ pub async fn cmd_create_channel(
visibility: &str,
description: Option<&str>,
ttl: Option<i64>,
unlisted: bool,
) -> Result<(), CliError> {
match channel_type {
"stream" | "forum" => {}
@@ -681,6 +706,9 @@ pub async fn cmd_create_channel(
}
let ttl = ttl.map(validate_ttl_seconds).transpose()?;
if unlisted {
require_unlisted_support(client).await?;
}
let channel_uuid = Uuid::new_v4();
@@ -694,9 +722,16 @@ pub async fn cmd_create_channel(
"forum" => buzz_sdk::ChannelKind::Forum,
_ => unreachable!(),
};
let builder =
buzz_sdk::build_create_channel(channel_uuid, name, Some(vis), Some(ct), description, ttl)
.map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?;
let builder = buzz_sdk::build_create_channel(
channel_uuid,
name,
Some(vis),
Some(ct),
description,
ttl,
unlisted,
)
.map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
@@ -1423,6 +1458,7 @@ pub async fn cmd_create_channel_from_template(
visibility_override: Option<&str>,
description: Option<&str>,
ttl: Option<i64>,
unlisted: bool,
) -> Result<(), CliError> {
let templates_path = channel_templates::resolve_templates_path(templates_file)?;
let template: ChannelTemplateRecord =
@@ -1447,6 +1483,9 @@ pub async fn cmd_create_channel_from_template(
}
}
let ttl = ttl.map(validate_ttl_seconds).transpose()?;
if unlisted {
require_unlisted_support(client).await?;
}
// Owner invariant (F1): the auth-tag owner (already verified against the
// signer at startup) if present, else the signing pubkey. No sole-author
@@ -1477,6 +1516,7 @@ pub async fn cmd_create_channel_from_template(
Some(ct),
effective_description,
ttl,
unlisted,
)
.map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?;
let event = client.sign_event(builder)?;
@@ -1599,15 +1639,17 @@ fn validate_update_channel_fields(
visibility: Option<&str>,
ttl_change: Option<Option<i32>>,
join_role: Option<buzz_sdk::ChannelJoinRole>,
unlisted: Option<bool>,
) -> Result<(), CliError> {
if name.is_none()
&& description.is_none()
&& visibility.is_none()
&& ttl_change.is_none()
&& join_role.is_none()
&& unlisted.is_none()
{
return Err(CliError::Usage(
"at least one field required (--name, --description, --visibility, --ttl, --no-ttl, --join-role)"
"at least one field required (--name, --description, --visibility, --ttl, --no-ttl, --join-role, --unlisted)"
.into(),
));
}
@@ -1624,6 +1666,7 @@ pub async fn cmd_update_channel(
ttl: Option<i64>,
no_ttl: bool,
join_role: Option<buzz_sdk::ChannelJoinRole>,
unlisted: Option<bool>,
) -> Result<(), CliError> {
// Outer Option: None leaves TTL unchanged. Inner: Some(secs) sets it,
// None (from --no-ttl) clears it, making the channel permanent.
@@ -1633,8 +1676,20 @@ pub async fn cmd_update_channel(
(None, false) => None,
};
validate_update_channel_fields(name, description, visibility, ttl_change, join_role)?;
validate_update_channel_fields(
name,
description,
visibility,
ttl_change,
join_role,
unlisted,
)?;
let channel_uuid = parse_uuid(channel_id)?;
// `--unlisted false` needs no check: a relay without the extension lists
// every open channel already.
if unlisted == Some(true) {
require_unlisted_support(client).await?;
}
let builder = buzz_sdk::build_update_channel(
channel_uuid,
@@ -1643,6 +1698,7 @@ pub async fn cmd_update_channel(
visibility,
ttl_change,
join_role,
unlisted,
)
.map_err(|e| CliError::Other(format!("build_update_channel failed: {e}")))?;
@@ -1905,6 +1961,7 @@ pub async fn dispatch(
visibility,
description,
ttl,
unlisted,
template,
templates_file,
} => {
@@ -1918,6 +1975,7 @@ pub async fn dispatch(
visibility.as_ref().map(|v| v.to_string()).as_deref(),
description.as_deref(),
ttl,
unlisted,
)
.await
} else {
@@ -1934,6 +1992,7 @@ pub async fn dispatch(
&visibility.to_string(),
description.as_deref(),
ttl,
unlisted,
)
.await
}
@@ -1946,6 +2005,7 @@ pub async fn dispatch(
ttl,
no_ttl,
join_role,
unlisted,
} => {
let visibility = visibility.as_ref().map(|v| v.to_string());
cmd_update_channel(
@@ -1957,6 +2017,7 @@ pub async fn dispatch(
ttl,
no_ttl,
join_role.map(Into::into),
unlisted,
)
.await
}
@@ -2146,7 +2207,7 @@ mod tests {
#[test]
fn update_channel_fields_rejects_empty_update() {
let result = validate_update_channel_fields(None, None, None, None, None);
let result = validate_update_channel_fields(None, None, None, None, None, None);
assert!(matches!(result, Err(CliError::Usage(_))));
let msg = result.unwrap_err().to_string();
assert!(msg.contains("at least one field required"));
@@ -2155,7 +2216,7 @@ mod tests {
#[test]
fn update_channel_fields_accepts_visibility_only_update() {
let result = validate_update_channel_fields(None, None, Some("open"), None, None);
let result = validate_update_channel_fields(None, None, Some("open"), None, None, None);
assert!(result.is_ok(), "visibility-only update should be accepted");
}
@@ -4681,3 +4742,145 @@ mod restore_canvas_already_current_tests {
);
}
}
#[cfg(test)]
mod unlisted_support_tests {
use std::sync::{Arc, Mutex};
use axum::body::Bytes;
use axum::extract::State;
use axum::routing::{get, post};
use axum::Router;
use nostr::Keys;
use serde_json::Value;
use tokio::net::TcpListener;
use super::{cmd_create_channel, cmd_update_channel};
use crate::client::BuzzClient;
const CHANNEL: &str = "326d56bc-c96c-4af0-86a1-5e804cd1b467";
/// A relay whose NIP-11 advertises `extensions`, recording every event
/// posted to `/events`.
async fn relay(extensions: &'static str) -> (String, Arc<Mutex<Vec<Value>>>) {
let submitted: Arc<Mutex<Vec<Value>>> = Arc::new(Mutex::new(Vec::new()));
let app = Router::new()
.route(
"/info",
get(move || async move {
(
[("content-type", "application/nostr+json")],
format!(r#"{{"supported_extensions":{extensions}}}"#),
)
}),
)
.route(
"/events",
post(
|State(s): State<Arc<Mutex<Vec<Value>>>>, body: Bytes| async move {
let event: Value = serde_json::from_slice(&body).expect("event json");
s.lock().unwrap().push(event);
r#"{"event_id":"deadbeef","accepted":true,"message":""}"#.to_string()
},
),
)
.with_state(submitted.clone());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
(format!("http://{addr}"), submitted)
}
fn client(base_url: &str) -> BuzzClient {
BuzzClient::new(base_url.to_string(), Keys::generate(), None, None).unwrap()
}
fn has_unlisted_true(event: &Value) -> bool {
event["tags"]
.as_array()
.unwrap()
.iter()
.any(|t| t[0] == "unlisted" && t[1] == "true")
}
#[tokio::test]
async fn unlisting_is_refused_without_the_extension() {
let (url, submitted) = relay(r#"["buzz-gif"]"#).await;
let client = client(&url);
let err = cmd_create_channel(&client, "solo", "stream", "open", None, None, true)
.await
.unwrap_err();
assert!(err.to_string().contains("buzz-unlisted-channels"), "{err}");
let err = cmd_update_channel(
&client,
CHANNEL,
None,
None,
Some("open"),
None,
false,
None,
Some(true),
)
.await
.unwrap_err();
assert!(err.to_string().contains("buzz-unlisted-channels"), "{err}");
assert!(submitted.lock().unwrap().is_empty(), "nothing is sent");
}
#[tokio::test]
async fn unlisting_is_sent_with_the_extension() {
let (url, submitted) = relay(r#"["buzz-unlisted-channels"]"#).await;
let client = client(&url);
cmd_create_channel(&client, "solo", "stream", "open", None, None, true)
.await
.unwrap();
cmd_update_channel(
&client,
CHANNEL,
None,
None,
None,
None,
false,
None,
Some(true),
)
.await
.unwrap();
let submitted = submitted.lock().unwrap();
assert_eq!(submitted.len(), 2);
assert!(submitted.iter().all(has_unlisted_true));
}
#[tokio::test]
async fn listed_writes_skip_the_check() {
// The relay advertises no extensions, so a check would fail the command.
let (url, submitted) = relay(r#"[]"#).await;
let client = client(&url);
cmd_create_channel(&client, "solo", "stream", "open", None, None, false)
.await
.unwrap();
cmd_update_channel(
&client,
CHANNEL,
None,
None,
None,
None,
false,
None,
Some(false),
)
.await
.unwrap();
assert_eq!(submitted.lock().unwrap().len(), 2);
}
}

View File

@@ -662,7 +662,7 @@ pub enum ChannelsCmd {
},
/// Create a new channel
#[command(
after_help = "Examples:\n buzz channels create --name general --type stream --visibility open\n buzz channels create --name design --type forum --visibility open --description \"Design discussions\"\n buzz channels create --name standup --type stream --visibility open --ttl 3600 # ephemeral, archived after 1h idle\n buzz channels create --name project-x --template \"Buzz Team\" # type/visibility/canvas/roster from the template; explicit flags override"
after_help = "Examples:\n buzz channels create --name general --type stream --visibility open\n buzz channels create --name design --type forum --visibility open --description \"Design discussions\"\n buzz channels create --name standup --type stream --visibility open --ttl 3600 # ephemeral, archived after 1h idle\n buzz channels create --name project-x --template \"Buzz Team\" # type/visibility/canvas/roster from the template; explicit flags override\n buzz channels create --name notes --type stream --visibility private --unlisted # never listed, even after it is made open"
)]
Create {
/// Channel name
@@ -682,6 +682,13 @@ pub enum ChannelsCmd {
/// many seconds pass without a new message.
#[arg(long, value_name = "SECONDS")]
ttl: Option<i64>,
/// Create the channel unlisted: left out of channel lists for people
/// who are not members. Anyone can still read and join an unlisted
/// open channel by its id. Change it later with `channels update
/// --unlisted`. Refused if the relay does not advertise
/// `buzz-unlisted-channels`.
#[arg(long)]
unlisted: bool,
/// Apply a desktop-local channel template by name (case-insensitive):
/// supplies default type/visibility/description/canvas, and resolves
/// its agent roster against the relay to add as members.
@@ -692,9 +699,9 @@ pub enum ChannelsCmd {
#[arg(long, value_name = "PATH")]
templates_file: Option<String>,
},
/// Update channel name, description, visibility, join role, or ephemeral TTL
/// Update channel name, description, visibility, join role, unlisted, or ephemeral TTL
#[command(
after_help = "Examples:\n buzz channels update --channel <uuid> --name general\n buzz channels update --channel <uuid> --visibility open\n buzz channels update --channel <uuid> --visibility private\n buzz channels update --channel <uuid> --join-role guest"
after_help = "Examples:\n buzz channels update --channel <uuid> --name general\n buzz channels update --channel <uuid> --visibility open\n buzz channels update --channel <uuid> --visibility private\n buzz channels update --channel <uuid> --join-role guest\n buzz channels update --channel <uuid> --visibility open --unlisted true"
)]
Update {
/// Channel UUID
@@ -721,6 +728,13 @@ pub enum ChannelsCmd {
/// but not write. Does not change existing members. Owner/admin only.
#[arg(long, value_enum)]
join_role: Option<ChannelJoinRoleArg>,
/// Leave the channel out of channel lists for people who are not
/// members (`true`), or list it again (`false`). Anyone can still read
/// and join an unlisted open channel by its id. Matters only while the
/// channel is open. Owner/admin only. `true` is refused if the relay
/// does not advertise `buzz-unlisted-channels`.
#[arg(long, value_name = "BOOL", action = clap::ArgAction::Set)]
unlisted: Option<bool>,
},
/// Set the channel topic
Topic {

View File

@@ -772,7 +772,12 @@ mod postgres_tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);
assert_eq!(migrations.len(), 70);
assert_eq!(migrations.len(), 71);
assert_eq!(migrations[70].version, 71);
assert!(migrations[70]
.sql
.as_str()
.contains("ADD COLUMN unlisted BOOLEAN NOT NULL DEFAULT FALSE"));
assert_eq!(migrations[69].version, 70);
assert_eq!(migrations[68].version, 69);
assert_eq!(migrations[67].version, 68);
@@ -2743,7 +2748,8 @@ mod postgres_tests {
/// test-relay launchers take — and migrates another through the latest
/// migration, then asserts the admin and entity-intake tables have identical
/// column definitions (name, type, nullability, default) and index shapes, including each
/// key's catalog sort/null options (`pg_index.indoption`). Columns are keyed
/// key's catalog sort/null options (`pg_index.indoption`), and that the
/// `channels` columns match. Columns are keyed
/// by name, not ordinal, because migrations append via `ALTER TABLE` while
/// `schema.sql` declares them inline — positions legitimately differ, shapes
/// must not. Entity-intake constraints are also compared in the live catalog.
@@ -2910,6 +2916,15 @@ mod postgres_tests {
migration and schema.sql must both use a representable shape."
);
}
// `channels` columns only: pgschema re-emits `idx_channels_id_live`
// without its `INCLUDE (community_id)`, an index drift that predates
// this check.
assert_eq!(
columns(&desired, "channels").await,
columns(&migrated, "channels").await,
"column parity mismatch for channels: schema.sql desired state has drifted \
from the migrations; update schema/tables/public/channels.sql to match"
);
assert_eq!(
events_key_defs(&desired).await,
events_key_defs(&migrated).await,

View File

@@ -435,6 +435,7 @@ async fn channel_and_membership_writers_require_application_admission() {
&creator,
None,
&[],
false,
)
.await
.expect_err("channel create must reject"),
@@ -467,6 +468,7 @@ async fn channel_and_membership_writers_require_application_admission() {
&creator,
None,
&[],
false,
)
.await
.expect("active channel create succeeds");
@@ -538,6 +540,7 @@ async fn thread_metadata_writer_requires_application_admission() {
&[1; 32],
None,
&[],
false,
)
.await
.expect("active channel");
@@ -585,6 +588,7 @@ async fn relay_admin_domain_writer_requires_application_admission() {
&[1; 32],
None,
&[],
false,
)
.await
.expect("active channel");
@@ -633,6 +637,7 @@ async fn personal_read_writer_requires_application_admission() {
&actor.public_key().to_bytes(),
None,
&[],
false,
)
.await
.expect("active channel");

View File

@@ -300,6 +300,61 @@ async fn system_channel_artifacts_are_read_only_by_name() {
}
}
/// An artifact in an unlisted open channel is read by a non-member only
/// through a query whose `#h` names that channel; a member reads it as usual.
/// Mutation: drop the unlisted condition from `query_artifacts` → RED.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn unlisted_channel_artifacts_are_read_by_name_for_non_members() {
let f = Fixture::new().await;
let unlisted = Uuid::new_v4();
sqlx::query("INSERT INTO channels(community_id,id,name,visibility,channel_type,created_by,unlisted) VALUES($1,$2,$3,'open','stream',$4,TRUE)")
.bind(f.community.as_uuid())
.bind(unlisted)
.bind(unlisted.to_string())
.bind(f.owner.public_key().to_bytes().as_slice())
.execute(&f.db.pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO channel_members(community_id,channel_id,pubkey,role) VALUES($1,$2,$3,'owner')",
)
.bind(f.community.as_uuid())
.bind(unlisted)
.bind(f.owner.public_key().to_bytes().as_slice())
.execute(&f.db.pool)
.await
.unwrap();
for home in [f.a, unlisted] {
let create = f.revision(Uuid::new_v4(), "create", home, None, &f.owner, vec![]);
assert!(matches!(
f.accept(&create, None).await,
ArtifactOutcome::Accepted(_)
));
}
let unnamed = serde_json::json!({"artifact":"current"});
let named = serde_json::json!({"artifact":"current","#h":[unlisted.to_string()]});
let homes = |events: Vec<buzz_core::StoredEvent>| {
let mut homes: Vec<_> = events.iter().map(|e| e.channel_id).collect();
homes.sort();
homes
};
// The member reads both without naming either.
let (events, _) = f.query(&f.owner, unnamed.clone(), false).await;
let mut both = vec![Some(f.a), Some(unlisted)];
both.sort();
assert_eq!(homes(events), both, "member without #h");
// A non-member reads the unlisted one only by naming it.
let (events, _) = f.query(&f.peer, unnamed.clone(), false).await;
assert_eq!(homes(events), [Some(f.a)], "non-member without #h");
assert_eq!(f.count(&f.peer, unnamed).await, 1);
let (events, _) = f.query(&f.peer, named.clone(), false).await;
assert_eq!(homes(events), [Some(unlisted)], "non-member #h names it");
assert_eq!(f.count(&f.peer, named).await, 1);
}
/// An artifact in a channel of a type added later is read only by a query
/// whose `#h` names that channel, like a system channel (NIP-CL).
/// Mutation: put back `c.channel_type <> 'system'` in `query_artifacts` → RED.

View File

@@ -36,8 +36,9 @@ impl Db {
if query.view == ArtifactView::Current {
qb.push("JOIN artifact_heads a ON a.community_id=e.community_id AND a.event_id=e.id AND NOT a.deleted ");
}
// A system channel is read only by a query whose `#h` names it
// (NIP-CL); malformed `#h` values name no channel.
// A system channel, or an unlisted open channel the reader is not a
// member of, is read only by a query whose `#h` names it (NIP-CL);
// malformed `#h` values name no channel.
let named_channels: Vec<uuid::Uuid> = query
.tags
.iter()
@@ -49,8 +50,10 @@ impl Db {
qb.push("WHERE e.community_id=").push_bind(community.as_uuid()).push(" AND e.kind=45010 AND e.deleted_at IS NULL AND EXISTS (SELECT 1 FROM channels c WHERE c.community_id=e.community_id AND c.id=e.channel_id AND c.deleted_at IS NULL AND (")
.push(crate::store::channel::legacy_channel_type_sql("c.channel_type"))
.push(" OR c.id=ANY(")
.push_bind(named_channels.clone())
.push(")) AND ((c.visibility='open' AND (NOT c.unlisted OR c.id=ANY(")
.push_bind(named_channels)
.push(")) AND (c.visibility='open' OR EXISTS (SELECT 1 FROM channel_members m WHERE m.community_id=c.community_id AND m.channel_id=c.id AND m.pubkey=")
.push("))) OR EXISTS (SELECT 1 FROM channel_members m WHERE m.community_id=c.community_id AND m.channel_id=c.id AND m.pubkey=")
.push_bind(reader.to_vec()).push(" AND m.removed_at IS NULL)))");
for (name, values) in &query.tags {
// GIN-usable necessary prefilter; retain the positional recheck

View File

@@ -84,6 +84,11 @@ pub struct ChannelRecord {
/// The role a new person gets (`"member"` or `"guest"`). Parse with
/// [`buzz_core::channel::ChannelJoinRole::parse_fail_closed`].
pub join_role: String,
/// Whether the channel is left out of channel listings for non-members.
/// It matters only while the channel is open: anyone can still read and
/// join an unlisted open channel by naming it, and private channels are
/// never listed to non-members.
pub unlisted: bool,
}
impl ChannelRecord {
@@ -109,6 +114,7 @@ pub async fn create_channel(
created_by: &[u8],
ttl_seconds: Option<i32>,
labels: &[String],
unlisted: bool,
) -> Result<ChannelRecord> {
if created_by.len() != 32 {
return Err(DbError::InvalidData(format!(
@@ -133,9 +139,9 @@ pub async fn create_channel(
sqlx::query(
r#"
INSERT INTO channels (id, community_id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline, labels)
INSERT INTO channels (id, community_id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline, labels, unlisted)
VALUES ($1, $2, $3, $4::channel_type, $5::channel_visibility, $6, $7, $8,
CASE WHEN $8 IS NOT NULL THEN NOW() + ($8 || ' seconds')::interval ELSE NULL END, $9)
CASE WHEN $8 IS NOT NULL THEN NOW() + ($8 || ' seconds')::interval ELSE NULL END, $9, $10)
"#,
)
.bind(id)
@@ -147,6 +153,7 @@ pub async fn create_channel(
.bind(created_by)
.bind(ttl_seconds)
.bind(labels)
.bind(unlisted)
.execute(tx.conn())
.await?;
@@ -175,7 +182,7 @@ pub async fn create_channel(
nip29_group_id, topic_required, max_members,
topic, topic_set_by, topic_set_at,
purpose, purpose_set_by, purpose_set_at,
ttl_seconds, ttl_deadline, labels, join_role
ttl_seconds, ttl_deadline, labels, join_role, unlisted
FROM channels WHERE community_id = $1 AND id = $2
"#,
)
@@ -224,6 +231,7 @@ pub async fn create_channel_with_id(
created_by,
ttl_seconds,
labels,
unlisted: false,
members: &[],
},
)
@@ -237,7 +245,7 @@ pub async fn create_channel_with_id(
nip29_group_id, topic_required, max_members,
topic, topic_set_by, topic_set_at,
purpose, purpose_set_by, purpose_set_at,
ttl_seconds, ttl_deadline, labels, join_role
ttl_seconds, ttl_deadline, labels, join_role, unlisted
FROM channels WHERE community_id = $1 AND id = $2
"#,
)
@@ -267,6 +275,8 @@ pub struct NewChannel<'a> {
pub ttl_seconds: Option<i32>,
/// Labels validated by `buzz_core::channel::parse_channel_labels`.
pub labels: &'a [String],
/// Leave the channel out of listings for non-members from the start.
pub unlisted: bool,
/// 32-byte pubkeys added as ordinary members invited by the creator. The
/// caller has authorized each addition.
pub members: &'a [Vec<u8>],
@@ -340,6 +350,7 @@ async fn insert_channel_rows(
created_by,
ttl_seconds,
labels,
unlisted,
members,
} = channel;
let community_id = tx.community();
@@ -363,9 +374,9 @@ async fn insert_channel_rows(
let rows_affected = sqlx::query(
r#"
INSERT INTO channels (id, community_id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline, labels)
INSERT INTO channels (id, community_id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline, labels, unlisted)
VALUES ($1, $2, $3, $4::channel_type, $5::channel_visibility, $6, $7, $8,
CASE WHEN $8 IS NOT NULL THEN NOW() + ($8 || ' seconds')::interval ELSE NULL END, $9)
CASE WHEN $8 IS NOT NULL THEN NOW() + ($8 || ' seconds')::interval ELSE NULL END, $9, $10)
ON CONFLICT (community_id, id) DO NOTHING
"#,
)
@@ -378,6 +389,7 @@ async fn insert_channel_rows(
.bind(created_by)
.bind(ttl_seconds)
.bind(labels)
.bind(unlisted)
.execute(tx.conn())
.await?
.rows_affected();
@@ -476,7 +488,7 @@ async fn get_channel_with_operation(
nip29_group_id, topic_required, max_members,
topic, topic_set_by, topic_set_at,
purpose, purpose_set_by, purpose_set_at,
ttl_seconds, ttl_deadline, labels, join_role
ttl_seconds, ttl_deadline, labels, join_role, unlisted
FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL
"#,
)
@@ -565,7 +577,7 @@ async fn list_channels_with_operation(
nip29_group_id, topic_required, max_members,
topic, topic_set_by, topic_set_at,
purpose, purpose_set_by, purpose_set_at,
ttl_seconds, ttl_deadline, labels, join_role
ttl_seconds, ttl_deadline, labels, join_role, unlisted
FROM channels
WHERE community_id = $1 AND deleted_at IS NULL AND visibility::text = $2
ORDER BY created_at DESC
@@ -585,7 +597,7 @@ async fn list_channels_with_operation(
nip29_group_id, topic_required, max_members,
topic, topic_set_by, topic_set_at,
purpose, purpose_set_by, purpose_set_at,
ttl_seconds, ttl_deadline, labels, join_role
ttl_seconds, ttl_deadline, labels, join_role, unlisted
FROM channels
WHERE community_id = $1 AND deleted_at IS NULL
ORDER BY created_at DESC
@@ -653,6 +665,8 @@ pub(crate) fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result<Channe
// Selected by every channel-record query. A missing column is an
// error, never a silent `member`: the write gate depends on it.
join_role: row.try_get("join_role")?,
// Selected by every channel-record query, like `join_role`.
unlisted: row.try_get("unlisted")?,
})
}
@@ -672,7 +686,10 @@ pub(crate) fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result<Channe
///
/// Buzz also publishes the explicit `["join_role", ...]` value, so clients
/// can tell what joining gives (show Join or Follow) without inferring it
/// from the flags.
/// from the flags, and a bare `["unlisted"]` flag when the channel is
/// unlisted. The flag is published as stored, whatever the visibility, so
/// clients see the setting; it only changes listings while the channel is
/// open.
pub fn group_metadata_tags(channel: &ChannelRecord, members: &[MemberRecord]) -> Vec<Vec<String>> {
let tag = |parts: &[&str]| parts.iter().map(|p| (*p).to_owned()).collect::<Vec<_>>();
let mut tags = vec![
@@ -690,6 +707,9 @@ pub fn group_metadata_tags(channel: &ChannelRecord, members: &[MemberRecord]) ->
} else {
tags.push(tag(&["private"]));
}
if channel.unlisted {
tags.push(tag(&["unlisted"]));
}
let join_role = ChannelJoinRole::parse_fail_closed(&channel.join_role);
if !open || join_role == ChannelJoinRole::Guest {
tags.push(tag(&["restricted"]));
@@ -766,6 +786,9 @@ pub struct ChannelUpdate {
pub labels: Option<Vec<String>>,
/// New join role (`"member"`/`"guest"`), or `None` to leave unchanged.
pub join_role: Option<String>,
/// New unlisted flag, or `None` to leave unchanged. It decides listing,
/// not admission, so it does not take the membership lock.
pub unlisted: Option<bool>,
}
impl ChannelUpdate {
@@ -777,6 +800,7 @@ impl ChannelUpdate {
&& self.ttl_seconds.is_none()
&& self.labels.is_none()
&& self.join_role.is_none()
&& self.unlisted.is_none()
}
/// True when the update changes a setting that decides admission:
@@ -1013,6 +1037,10 @@ async fn update_channel_on(
set_parts.push(format!("join_role = ${param_idx}"));
param_idx += 1;
}
if updates.unlisted.is_some() {
set_parts.push(format!("unlisted = ${param_idx}"));
param_idx += 1;
}
if let Some(ref ttl) = updates.ttl_seconds {
// Set ttl_seconds, then reset the deadline from now (or clear both).
set_parts.push(format!("ttl_seconds = ${param_idx}"));
@@ -1048,6 +1076,9 @@ async fn update_channel_on(
if let Some(ref join_role) = updates.join_role {
q = q.bind(join_role);
}
if let Some(unlisted) = updates.unlisted {
q = q.bind(unlisted);
}
if let Some(ref ttl) = updates.ttl_seconds {
q = q.bind(*ttl);
}
@@ -1488,6 +1519,7 @@ impl Db {
created_by,
ttl_seconds,
&[],
false,
)
.await
}
@@ -1575,7 +1607,8 @@ impl Db {
.await
}
/// Creates a channel with a relay-chosen UUID and an initial label set.
/// Creates a channel with a relay-chosen UUID, an initial label set and
/// an initial unlisted flag.
#[allow(clippy::too_many_arguments)]
#[datastore_span(name = "create_labeled_channel", system = "postgresql")]
pub async fn create_labeled_channel(
@@ -1588,6 +1621,7 @@ impl Db {
created_by: &[u8],
ttl_seconds: Option<i32>,
labels: &[String],
unlisted: bool,
) -> Result<ChannelRecord> {
create_channel(
&self.pool,
@@ -1599,6 +1633,7 @@ impl Db {
created_by,
ttl_seconds,
labels,
unlisted,
)
.await
}
@@ -1784,6 +1819,7 @@ mod group_metadata_tag_tests {
ttl_deadline: None,
labels: Vec::new(),
join_role: join_role.into(),
unlisted: false,
}
}
@@ -1867,6 +1903,23 @@ mod group_metadata_tag_tests {
assert_eq!(identity, expected.iter().collect::<Vec<_>>());
}
/// `unlisted` is a bare flag, published as stored for either visibility,
/// and absent for a listed channel. It never adds NIP-29 `hidden`: an
/// unlisted channel's metadata stays readable by id.
#[test]
fn publishes_the_unlisted_flag_as_stored() {
let unlisted = vec!["unlisted".to_string()];
let hidden = vec!["hidden".to_string()];
for visibility in ["open", "private"] {
let mut channel = channel(visibility, "member");
assert!(!group_metadata_tags(&channel, &[]).contains(&unlisted));
channel.unlisted = true;
let tags = group_metadata_tags(&channel, &[]);
assert!(tags.contains(&unlisted), "{visibility}");
assert!(!tags.contains(&hidden), "{visibility}");
}
}
/// Private system channels are hidden, like DMs; open ones are not.
#[test]
fn hides_private_system_channels() {
@@ -2218,6 +2271,7 @@ mod postgres_tests {
created_by,
ttl_seconds: None,
labels: &[],
unlisted: false,
members,
}
}

View File

@@ -1396,14 +1396,16 @@ pub async fn get_members_bulk(
/// Get all channel IDs accessible to a pubkey.
///
/// Includes channels where the pubkey is an active member AND all open channels.
/// Open channels must be included in REQ filter resolution.
/// Includes channels where the pubkey is an active member AND all listed open
/// channels. Open channels must be included in REQ filter resolution.
///
/// Only legacy channel types are in either half
/// ([`buzz_core::channel::LEGACY_CHANNEL_TYPES`]): this is the implicit scope
/// of queries that do not name a channel, so system channels and any type
/// added later stay out. A query that names such a channel, or opts in to
/// system channels, adds them from [`get_readable_named_only_channels`].
/// added later stay out. Unlisted open channels stay out of the open half for
/// the same reason; a member still has them through the member half. A query
/// that names such a channel, or opts in to system channels, adds them from
/// [`get_readable_named_only_channels`].
pub async fn get_accessible_channel_ids(
pool: &PgPool,
community_id: CommunityId,
@@ -1424,7 +1426,7 @@ pub async fn get_accessible_channel_ids(
UNION
SELECT id AS channel_id
FROM channels
WHERE community_id = $1 AND visibility = 'open' AND deleted_at IS NULL
WHERE community_id = $1 AND visibility = 'open' AND NOT unlisted AND deleted_at IS NULL
AND {open_types}
"#,
member_types = crate::store::channel::legacy_channel_type_sql("c.channel_type"),
@@ -1445,19 +1447,40 @@ pub async fn get_accessible_channel_ids(
.collect()
}
/// The channels of non-legacy types (system channels, and any type added
/// later) that a pubkey can read: those where it is an active member, and
/// every open one. Each comes with its type. Uncached, so a caller can trust
/// it for a channel the request names.
/// A channel that [`get_readable_named_only_channels`] found readable.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadableNamedOnlyChannel {
/// The channel id.
pub id: Uuid,
/// Its channel type, as text.
pub channel_type: String,
/// Whether the system-channel opt-in (`#t:["system"]` or `#P`) may add
/// it. False for an unlisted channel the reader is not a member of: only
/// a filter that names it reads it.
pub opt_in: bool,
}
/// The channels that a pubkey can read but that [`get_accessible_channel_ids`]
/// leaves out:
/// - channels of non-legacy types (system channels, and any type added later)
/// where it is an active member, and every open one that is listed or among
/// `named`, the channels the request names;
/// - each unlisted open channel of a legacy type among `named`. A member of
/// one also has it through [`get_accessible_channel_ids`]; listing it here
/// as well is harmless, since a filter's scope is deduplicated.
///
/// These are the channels that [`get_accessible_channel_ids`] leaves out. A
/// Uncached, so a caller can trust it for a channel the request names. A
/// read reaches one only by naming it, or, for system channels, by opting in
/// to them.
/// to them. The opt-in needs every listed system channel, so those are all
/// loaded; an unlisted channel is loaded only when named, since a community
/// can have many, and it is marked so the opt-in never adds it for a
/// non-member ([`ReadableNamedOnlyChannel::opt_in`]).
pub async fn get_readable_named_only_channels(
pool: &PgPool,
community_id: CommunityId,
pubkey: &[u8],
) -> Result<Vec<(Uuid, String)>> {
named: &[Uuid],
) -> Result<Vec<ReadableNamedOnlyChannel>> {
let mut connection = crate::observability::acquire_writer(
pool,
crate::observability::WriterOperation::Authorization,
@@ -1465,30 +1488,44 @@ pub async fn get_readable_named_only_channels(
.await?;
let sql = format!(
r#"
SELECT c.id AS channel_id, c.channel_type::text AS channel_type
SELECT c.id AS channel_id, c.channel_type::text AS channel_type,
(NOT c.unlisted OR {is_member}) AS opt_in
FROM channels c
WHERE c.community_id = $1 AND c.deleted_at IS NULL
AND NOT {legacy_types}
AND (
c.visibility = 'open'
OR EXISTS (
SELECT 1 FROM channel_members cm
WHERE cm.community_id = c.community_id AND cm.channel_id = c.id
AND cm.pubkey = $2 AND cm.removed_at IS NULL
(
NOT {legacy_types}
AND (
(c.visibility = 'open' AND (NOT c.unlisted OR c.id = ANY($3)))
OR {is_member}
)
)
OR ({legacy_types} AND c.visibility = 'open' AND c.unlisted AND c.id = ANY($3))
)
"#,
legacy_types = crate::store::channel::legacy_channel_type_sql("c.channel_type"),
is_member = "EXISTS (
SELECT 1 FROM channel_members cm
WHERE cm.community_id = c.community_id AND cm.channel_id = c.id
AND cm.pubkey = $2 AND cm.removed_at IS NULL
)",
);
// Built only from the constant type list.
// Built only from constants.
let rows = sqlx::query(sqlx::AssertSqlSafe(sql))
.bind(community_id.as_uuid())
.bind(pubkey)
.bind(named)
.fetch_all(&mut *connection)
.await?;
rows.into_iter()
.map(|r| Ok((r.try_get("channel_id")?, r.try_get("channel_type")?)))
.map(|r| {
Ok(ReadableNamedOnlyChannel {
id: r.try_get("channel_id")?,
channel_type: r.try_get("channel_type")?,
opt_in: r.try_get("opt_in")?,
})
})
.collect()
}
@@ -1663,7 +1700,7 @@ async fn get_channel_tx(
nip29_group_id, topic_required, max_members,
topic, topic_set_by, topic_set_at,
purpose, purpose_set_by, purpose_set_at,
ttl_seconds, ttl_deadline, labels, join_role
ttl_seconds, ttl_deadline, labels, join_role, unlisted
FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL
"#,
)
@@ -1722,7 +1759,8 @@ pub struct AccessibleChannel {
}
/// Returns full channel records for all channels a user can access:
/// open channels (visible to everyone) plus channels where the user is an active member.
/// listed open channels (visible to everyone) plus channels where the user is
/// an active member. An unlisted open channel is returned only to its members.
///
/// Uses a LEFT JOIN on channel_members (PK: channel_id + pubkey) which produces at
/// most one row per channel. Results are ordered stream -> forum -> dm, then by name.
@@ -1748,7 +1786,8 @@ pub async fn get_accessible_channels(
let membership_clause = if member_only == Some(true) {
"AND cm.channel_id IS NOT NULL"
} else {
"AND (c.visibility = 'open' OR cm.channel_id IS NOT NULL)"
// Unlisted open channels are listed to their members only.
"AND ((c.visibility = 'open' AND NOT c.unlisted) OR cm.channel_id IS NOT NULL)"
};
let base = format!(
@@ -1759,7 +1798,7 @@ pub async fn get_accessible_channels(
c.nip29_group_id, c.topic_required, c.max_members,
c.topic, c.topic_set_by, c.topic_set_at,
c.purpose, c.purpose_set_by, c.purpose_set_at,
c.ttl_seconds, c.ttl_deadline, c.labels, c.join_role,
c.ttl_seconds, c.ttl_deadline, c.labels, c.join_role, c.unlisted,
(cm.channel_id IS NOT NULL) AS is_member
FROM channels c
LEFT JOIN channel_members cm
@@ -2209,15 +2248,17 @@ impl Db {
get_accessible_channel_ids(&self.pool, community_id, pubkey).await
}
/// The channels of non-legacy types a pubkey can read (member, or open),
/// each with its type.
/// The channels of non-legacy types a pubkey can read (member, or open
/// and listed or among `named`), and the unlisted open channels among
/// `named`. See [`get_readable_named_only_channels`].
#[datastore_span(name = "get_readable_named_only_channels", system = "postgresql")]
pub async fn get_readable_named_only_channels(
&self,
community_id: CommunityId,
pubkey: &[u8],
) -> Result<Vec<(Uuid, String)>> {
get_readable_named_only_channels(&self.pool, community_id, pubkey).await
named: &[Uuid],
) -> Result<Vec<ReadableNamedOnlyChannel>> {
get_readable_named_only_channels(&self.pool, community_id, pubkey, named).await
}
/// The type of channel `channel_id`, deleted or not, or `None` when no

View File

@@ -76,7 +76,7 @@ pub async fn find_dm_by_participants(
created_by, created_at, updated_at, archived_at, deleted_at,
nip29_group_id, topic_required, max_members,
topic, topic_set_by, topic_set_at,
purpose, purpose_set_by, purpose_set_at, labels, join_role
purpose, purpose_set_by, purpose_set_at, labels, join_role, unlisted
FROM channels
WHERE community_id = $1
AND participant_hash = $2
@@ -142,7 +142,7 @@ pub async fn create_dm(
created_by, created_at, updated_at, archived_at, deleted_at,
nip29_group_id, topic_required, max_members,
topic, topic_set_by, topic_set_at,
purpose, purpose_set_by, purpose_set_at, labels, join_role
purpose, purpose_set_by, purpose_set_at, labels, join_role, unlisted
FROM channels
WHERE community_id = $1
AND participant_hash = $2
@@ -212,7 +212,7 @@ pub async fn create_dm(
created_by, created_at, updated_at, archived_at, deleted_at,
nip29_group_id, topic_required, max_members,
topic, topic_set_by, topic_set_at,
purpose, purpose_set_by, purpose_set_at, labels, join_role
purpose, purpose_set_by, purpose_set_at, labels, join_role, unlisted
FROM channels WHERE community_id = $1 AND id = $2
"#,
)
@@ -534,6 +534,7 @@ fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result<ChannelRecord> {
ttl_deadline: row.try_get("ttl_deadline").unwrap_or(None),
labels: row.try_get("labels")?,
join_role: row.try_get("join_role")?,
unlisted: row.try_get("unlisted")?,
})
}

View File

@@ -88,5 +88,8 @@ mod artifact_postgres_tests;
#[cfg(test)]
mod legacy_channel_types_postgres_tests;
#[cfg(test)]
mod unlisted_channels_postgres_tests;
#[cfg(test)]
mod admission_postgres_tests;

View File

@@ -0,0 +1,422 @@
//! An unlisted open channel is left out of the reads that list channels to
//! non-members, but stays readable and joinable by id. Members still list it.
//! The flag is set through the real settings write (`update_channel`).
use crate::channel::ChannelUpdate;
use crate::Db;
use buzz_core::channel::{ChannelType, ChannelVisibility};
use buzz_core::CommunityId;
use nostr::Keys;
use sqlx::PgPool;
use uuid::Uuid;
struct Fixture {
db: Db,
community: CommunityId,
owner: Keys,
outsider: Keys,
}
impl Fixture {
async fn new() -> Self {
let pool = PgPool::connect(&crate::test_support::database_url())
.await
.expect("connect to test DB");
if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") {
crate::migration::run_migrations(&pool)
.await
.expect("migrate test DB");
}
let community = CommunityId::from_uuid(Uuid::new_v4());
sqlx::query("INSERT INTO communities(id,host) VALUES($1,$2)")
.bind(community.as_uuid())
.bind(format!("unlisted-{}.test", community.as_uuid()))
.execute(&pool)
.await
.expect("insert community");
Self {
db: Db::from_pool(pool),
community,
owner: Keys::generate(),
outsider: Keys::generate(),
}
}
/// A channel created by `owner`, who becomes its owner member.
async fn channel(&self, name: &str, visibility: ChannelVisibility) -> Uuid {
self.typed_channel(name, ChannelType::Stream, visibility)
.await
}
/// [`Self::channel`] of `channel_type`.
async fn typed_channel(
&self,
name: &str,
channel_type: ChannelType,
visibility: ChannelVisibility,
) -> Uuid {
self.db
.create_channel(
self.community,
name,
channel_type,
visibility,
None,
&self.owner.public_key().to_bytes(),
None,
)
.await
.expect("create channel")
.id
}
async fn set_unlisted(&self, channel: Uuid, unlisted: bool) -> crate::channel::ChannelRecord {
self.db
.update_channel(
self.community,
channel,
ChannelUpdate {
unlisted: Some(unlisted),
..Default::default()
},
)
.await
.expect("set unlisted")
}
async fn implicit(&self, reader: &Keys) -> Vec<Uuid> {
self.db
.get_accessible_channel_ids(self.community, &reader.public_key().to_bytes())
.await
.expect("accessible channel ids")
}
async fn listed(&self, reader: &Keys) -> Vec<Uuid> {
self.db
.get_accessible_channels(self.community, &reader.public_key().to_bytes(), None, None)
.await
.expect("accessible channels")
.into_iter()
.map(|channel| channel.channel.id)
.collect()
}
/// The named-only channels `reader` can read when a request names
/// `named`, each with its type.
async fn named_only(&self, reader: &Keys, named: &[Uuid]) -> Vec<(Uuid, String)> {
self.readable(reader, named)
.await
.into_iter()
.map(|channel| (channel.id, channel.channel_type))
.collect()
}
/// The named-only channels `reader` can read when a request names
/// `named`, each with whether the system-channel opt-in may add it.
async fn opt_in(&self, reader: &Keys, named: &[Uuid]) -> Vec<(Uuid, bool)> {
self.readable(reader, named)
.await
.into_iter()
.map(|channel| (channel.id, channel.opt_in))
.collect()
}
async fn readable(
&self,
reader: &Keys,
named: &[Uuid],
) -> Vec<crate::channel_members::ReadableNamedOnlyChannel> {
self.db
.get_readable_named_only_channels(
self.community,
&reader.public_key().to_bytes(),
named,
)
.await
.expect("readable named-only channels")
}
}
/// Mutation: drop `NOT unlisted` from `get_accessible_channel_ids` or
/// `get_accessible_channels`, or the unlisted branch from
/// `get_readable_named_only_channels` → RED.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn unlisted_open_channel_is_named_only_for_non_members() {
let f = Fixture::new().await;
let listed = f.channel("listed", ChannelVisibility::Open).await;
let unlisted = f.channel("unlisted", ChannelVisibility::Open).await;
let record = f.set_unlisted(unlisted, true).await;
assert!(record.unlisted, "update_channel returns the new setting");
assert!(
f.db.get_channel(f.community, unlisted)
.await
.expect("get channel")
.unlisted,
"the setting is stored"
);
// A non-member does not list it, but can name it.
let implicit = f.implicit(&f.outsider).await;
assert!(implicit.contains(&listed));
assert!(!implicit.contains(&unlisted), "implicit scope");
let shown = f.listed(&f.outsider).await;
assert!(shown.contains(&listed));
assert!(!shown.contains(&unlisted), "channel list");
let named = f.named_only(&f.outsider, &[listed, unlisted]).await;
assert!(
named.contains(&(unlisted, "stream".to_owned())),
"readable by name: {named:?}"
);
assert!(
!named.iter().any(|(id, _)| *id == listed),
"a listed channel is not named-only"
);
// A member still lists it.
assert!(f.implicit(&f.owner).await.contains(&unlisted));
assert!(f.listed(&f.owner).await.contains(&unlisted));
// Joining by id works as on any open channel (kind:9021 → put_member),
// and the joiner then lists it like any member.
let joined =
f.db.put_member(
f.community,
unlisted,
&f.outsider.public_key().to_bytes(),
None,
None,
)
.await
.expect("self-join an unlisted open channel");
assert_eq!(joined.role, "member");
assert!(f.implicit(&f.outsider).await.contains(&unlisted));
assert!(f.listed(&f.outsider).await.contains(&unlisted));
// Listing it again puts it back for everyone.
let stranger = Keys::generate();
assert!(!f.implicit(&stranger).await.contains(&unlisted));
assert!(!f.set_unlisted(unlisted, false).await.unlisted);
assert!(f.implicit(&stranger).await.contains(&unlisted));
assert!(f.listed(&stranger).await.contains(&unlisted));
assert!(!f
.named_only(&stranger, &[unlisted])
.await
.iter()
.any(|(id, _)| *id == unlisted));
}
/// Only the unlisted channels a request names are loaded, so the lookup stays
/// small however many unlisted chats a community has.
/// Mutation: drop `c.id = ANY($3)` from the unlisted branch of
/// `get_readable_named_only_channels` → RED.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn only_the_named_unlisted_channels_are_loaded() {
let f = Fixture::new().await;
let a = f.channel("a", ChannelVisibility::Open).await;
let b = f.channel("b", ChannelVisibility::Open).await;
f.set_unlisted(a, true).await;
f.set_unlisted(b, true).await;
assert_eq!(
f.named_only(&f.outsider, &[a]).await,
[(a, "stream".to_owned())],
"naming A loads A and not B"
);
assert!(
f.named_only(&f.outsider, &[]).await.is_empty(),
"naming nothing loads no unlisted channel"
);
let both = f.named_only(&f.outsider, &[a, b]).await;
assert!(both.contains(&(a, "stream".to_owned())));
assert!(both.contains(&(b, "stream".to_owned())));
}
/// An unlisted open system channel is read only by naming it, for a
/// non-member: the system-channel opt-in (`#t:["system"]`, `#P`) needs every
/// readable system channel, but never gets this one. Its members keep it as
/// before, and a listed open system channel is loaded and opted in to as
/// before.
/// Mutation: drop `NOT c.unlisted OR c.id = ANY($3)` from the system branch
/// of `get_readable_named_only_channels` → RED (naming nothing loads it); make
/// its `opt_in` always true → RED (the outsider may opt in to it).
#[tokio::test]
#[ignore = "requires Postgres"]
async fn an_unlisted_system_channel_is_read_only_by_name() {
let f = Fixture::new().await;
let listed = f
.typed_channel("listed", ChannelType::System, ChannelVisibility::Open)
.await;
let unlisted = f
.typed_channel("unlisted", ChannelType::System, ChannelVisibility::Open)
.await;
f.set_unlisted(unlisted, true).await;
// A non-member that names nothing (an opt-in alone) gets only the listed one.
assert_eq!(
f.opt_in(&f.outsider, &[]).await,
[(listed, true)],
"opt-in discovery leaves the unlisted system channel out"
);
// Naming it reads it, but the opt-in still may not add it.
let named = f.opt_in(&f.outsider, &[unlisted]).await;
assert_eq!(named.len(), 2, "{named:?}");
assert!(named.contains(&(listed, true)));
assert!(named.contains(&(unlisted, false)), "readable by name only");
// Its member keeps it, for the opt-in too, whether or not it is named.
for names in [&[][..], &[unlisted][..]] {
let member = f.opt_in(&f.owner, names).await;
assert_eq!(member.len(), 2, "{member:?}");
assert!(member.contains(&(listed, true)));
assert!(member.contains(&(unlisted, true)), "a member opts in to it");
}
// Listing it again puts it back in discovery.
f.set_unlisted(unlisted, false).await;
let relisted = f.opt_in(&f.outsider, &[]).await;
assert_eq!(relisted.len(), 2, "{relisted:?}");
assert!(relisted.contains(&(unlisted, true)));
}
/// The flag is stored on its own and matters only while the channel is open:
/// a private unlisted channel stays invisible and unreadable to non-members,
/// and opening it keeps it unlisted.
/// Mutation: drop `c.visibility = 'open'` from the unlisted branch of
/// `get_readable_named_only_channels` → RED.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn unlisted_private_channel_stays_private_and_opens_unlisted() {
let f = Fixture::new().await;
let chat = f.channel("chat", ChannelVisibility::Private).await;
assert!(f.set_unlisted(chat, true).await.unlisted);
assert!(!f.implicit(&f.outsider).await.contains(&chat));
assert!(!f.listed(&f.outsider).await.contains(&chat));
assert!(
!f.named_only(&f.outsider, &[chat])
.await
.iter()
.any(|(id, _)| *id == chat),
"a private channel is never readable by name"
);
assert!(f.implicit(&f.owner).await.contains(&chat));
let opened =
f.db.update_channel(
f.community,
chat,
ChannelUpdate {
visibility: Some("open".into()),
..Default::default()
},
)
.await
.expect("open the channel");
assert!(opened.unlisted, "opening keeps the stored flag");
assert!(!f.implicit(&f.outsider).await.contains(&chat));
assert!(f
.named_only(&f.outsider, &[chat])
.await
.contains(&(chat, "stream".to_owned())));
}
/// kind:39000 publishes the flag from the stored row.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn group_metadata_publishes_the_stored_unlisted_flag() {
let f = Fixture::new().await;
let chat = f.channel("chat", ChannelVisibility::Open).await;
let flag = vec!["unlisted".to_string()];
let before = f.db.get_channel(f.community, chat).await.expect("channel");
assert!(!crate::channel::group_metadata_tags(&before, &[]).contains(&flag));
let after = f.set_unlisted(chat, true).await;
assert!(crate::channel::group_metadata_tags(&after, &[]).contains(&flag));
}
/// A channel can start unlisted (kind:9007 `["unlisted", "true"]`), through
/// both create paths: the event-backed create a client UUID takes, and the
/// relay-chosen-UUID create. It is never listed to non-members, even for a
/// moment, and stays named-only once it is opened.
/// Mutation: drop `unlisted` from either `INSERT INTO channels` → RED.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn channel_created_unlisted_is_never_listed() {
let f = Fixture::new().await;
let owner = f.owner.public_key().to_bytes();
let with_event = Uuid::new_v4();
let event = nostr::EventBuilder::new(nostr::Kind::Custom(9007), "")
.tags([
nostr::Tag::parse(["h", &with_event.to_string()]).expect("h tag"),
nostr::Tag::parse(["name", "solo"]).expect("name tag"),
nostr::Tag::parse(["visibility", "open"]).expect("visibility tag"),
nostr::Tag::parse(["unlisted", "true"]).expect("unlisted tag"),
])
.sign_with_keys(&f.owner)
.expect("sign 9007");
let created =
f.db.create_channel_with_event(
f.community,
with_event,
crate::channel::NewChannel {
name: "solo",
channel_type: ChannelType::Stream,
visibility: ChannelVisibility::Open,
description: None,
created_by: &owner,
ttl_seconds: None,
labels: &[],
unlisted: true,
members: &[],
},
&event,
)
.await
.expect("create with event");
assert!(matches!(created, crate::channel::ChannelCreate::Created(_)));
let relay_chosen =
f.db.create_labeled_channel(
f.community,
"solo-2",
ChannelType::Stream,
ChannelVisibility::Open,
None,
&owner,
None,
&[],
true,
)
.await
.expect("create labeled channel")
.id;
let listed = f.channel("listed", ChannelVisibility::Open).await;
assert!(
!f.db
.get_channel(f.community, listed)
.await
.expect("channel")
.unlisted,
"channels start listed by default"
);
for chat in [with_event, relay_chosen] {
assert!(
f.db.get_channel(f.community, chat)
.await
.expect("channel")
.unlisted,
"the setting is stored at creation"
);
assert!(!f.implicit(&f.outsider).await.contains(&chat));
assert!(!f.listed(&f.outsider).await.contains(&chat));
assert!(f
.named_only(&f.outsider, &[chat])
.await
.contains(&(chat, "stream".to_owned())));
assert!(f.implicit(&f.owner).await.contains(&chat));
}
}

View File

@@ -100,17 +100,21 @@ async fn access_sets(
.get_accessible_channel_ids(tenant.community(), &reader.to_bytes())
.await
.map_err(|e| database_error(context, e))?;
let system = if requests
let mut named: Vec<uuid::Uuid> = requests
.iter()
.any(|request| !implicit.contains(&request.channel))
{
.map(|request| request.channel)
.filter(|channel| !implicit.contains(channel))
.collect();
named.sort_unstable();
named.dedup();
let system = if !named.is_empty() {
state
.db
.get_readable_named_only_channels(tenant.community(), &reader.to_bytes())
.get_readable_named_only_channels(tenant.community(), &reader.to_bytes(), &named)
.await
.map_err(|e| database_error(context, e))?
.into_iter()
.map(|(id, _)| id)
.map(|channel| channel.id)
.collect()
} else {
Vec::new()

View File

@@ -129,7 +129,7 @@ async fn authorize_workflow_read(
let readable = accessible.contains(&channel_id)
|| state
.db
.get_readable_named_only_channels(tenant.community(), &pubkey_bytes)
.get_readable_named_only_channels(tenant.community(), &pubkey_bytes, &[channel_id])
.await
.map_err(|error| {
internal_error(&format!(
@@ -138,7 +138,7 @@ async fn authorize_workflow_read(
.into_response()
})?
.iter()
.any(|(id, _)| *id == channel_id);
.any(|channel| channel.id == channel_id);
if !readable {
return Err(not_found());
}

View File

@@ -2526,6 +2526,7 @@ struct ChannelCreateRequest {
description: Option<String>,
ttl_seconds: Option<i32>,
labels: Vec<String>,
unlisted: bool,
members: Vec<Vec<u8>>,
}
@@ -3266,6 +3267,8 @@ async fn ingest_event_inner(
.transpose()
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?
.unwrap_or_default();
let unlisted = super::side_effects::create_unlisted_from_tags(&event)
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
if let Some(client_uuid) = channel_id {
let members = super::side_effects::initial_members(&event)
@@ -3284,6 +3287,7 @@ async fn ingest_event_inner(
}),
ttl_seconds: super::resolve_ttl(&event, state.config.ephemeral_ttl_override),
labels,
unlisted,
members,
});
}
@@ -3546,6 +3550,7 @@ async fn ingest_event_inner(
created_by: &creator,
ttl_seconds: create.ttl_seconds,
labels: &create.labels,
unlisted: create.unlisted,
members: &create.members,
};
match state

View File

@@ -641,6 +641,7 @@ pub async fn validate_admin_event(
"ttl",
"t",
"join_role",
"unlisted",
];
let has_recognized = event
.tags
@@ -648,22 +649,24 @@ pub async fn validate_admin_event(
.any(|t| RECOGNIZED_TAGS.contains(&t.kind().to_string().as_str()));
if !has_recognized {
return Err(anyhow::anyhow!(
"kind:9002 must include at least one metadata tag (name, about, archived, topic, purpose, visibility, ttl, t, join_role)"
"kind:9002 must include at least one metadata tag (name, about, archived, topic, purpose, visibility, ttl, t, join_role, unlisted)"
));
}
// Validate archived values before storage.
// Validate archived and unlisted values before storage. Both are
// partial updates, so turning one off must be explicit.
for t in event.tags.iter() {
if t.kind().to_string() == "archived" {
let name = t.kind().to_string();
if name == "archived" || name == "unlisted" {
match t.content() {
Some("true") | Some("false") => {}
Some(v) => {
return Err(anyhow::anyhow!(
"invalid archived value: {v} (must be \"true\" or \"false\")"
"invalid {name} value: {v} (must be \"true\" or \"false\")"
));
}
None => {
return Err(anyhow::anyhow!("archived tag must have a value"));
return Err(anyhow::anyhow!("{name} tag must have a value"));
}
}
}
@@ -751,7 +754,8 @@ pub async fn validate_admin_event(
labels.map_err(|e| anyhow::anyhow!("{e}"))?;
}
// name/about/archived/visibility/ttl/labels/join_role require owner/admin;
// name/about/archived/visibility/ttl/labels/join_role/unlisted
// require owner/admin;
// topic/purpose allow any member who may post.
let has_privileged_tag = event.tags.iter().any(|t| {
let k = t.kind().to_string();
@@ -762,6 +766,7 @@ pub async fn validate_admin_event(
|| k == "ttl"
|| k == "t"
|| k == "join_role"
|| k == "unlisted"
});
if has_privileged_tag {
let members = state.db.get_members(tenant.community(), channel_id).await?;
@@ -783,7 +788,7 @@ pub async fn validate_admin_event(
return Ok(());
}
Err(anyhow::anyhow!(
"actor not authorized for name/about/archived/visibility/ttl/label/join_role changes"
"actor not authorized for name/about/archived/visibility/ttl/label/join_role/unlisted changes"
))
}
}
@@ -2025,6 +2030,15 @@ pub(crate) async fn persist_edit_metadata(
"false" => edit.archived = Some(false),
_ => {} // refused during authorization
},
"unlisted" => {
// Validated during authorization; fail closed so a bad value
// can never reach the column.
edit.columns.unlisted = Some(match val {
"true" => true,
"false" => false,
_ => return Err(anyhow::anyhow!("invalid unlisted value: {val}")),
});
}
_ => {}
}
}
@@ -2055,6 +2069,11 @@ pub(crate) async fn persist_edit_metadata(
"type": "join_role_changed", "actor": actor_hex, "join_role": join_role
}));
}
if let Some(unlisted) = edit.columns.unlisted {
notices.push(serde_json::json!({
"type": "unlisted_changed", "actor": actor_hex, "unlisted": unlisted
}));
}
if let Some(ttl_seconds) = edit.columns.ttl_seconds {
notices.push(serde_json::json!({
"type": "ttl_changed", "actor": actor_hex, "ttl_seconds": ttl_seconds
@@ -2075,6 +2094,7 @@ pub(crate) async fn persist_edit_metadata(
.collect::<anyhow::Result<Vec<_>>>()?;
let new_visibility = edit.columns.visibility.clone();
let unlisted_changed = edit.columns.unlisted.is_some();
let unarchived = edit.archived == Some(false);
let write = state
.db
@@ -2092,6 +2112,11 @@ pub(crate) async fn persist_edit_metadata(
// The changes are committed. What follows only updates live state, so a
// failure is logged; the next read sees the committed channel.
if unlisted_changed && new_visibility.is_none() {
// Unlisting changes which channels a read that names none can see;
// nobody loses access, so no subscription is evicted.
state.invalidate_all_accessible_channels(tenant);
}
if let Some(visibility) = new_visibility.as_deref() {
// A visibility flip changes who can see the channel, so the
// accessible-channels and visibility caches must be cleared
@@ -2200,6 +2225,27 @@ pub(crate) fn channel_labels_from_tags(
}
}
/// The kind:9007 `["unlisted", "true"|"false"]` setting: whether a new
/// channel starts out of channel listings for non-members. A missing tag means
/// listed. Any other value is refused, as kind:9002 refuses it; the last tag
/// wins, as in kind:9002.
pub(crate) fn create_unlisted_from_tags(event: &Event) -> anyhow::Result<bool> {
let mut unlisted = false;
for tag in event
.tags
.iter()
.filter(|tag| tag.kind().to_string() == "unlisted")
{
unlisted = match tag.content() {
Some("true") => true,
Some("false") => false,
Some(v) => anyhow::bail!("invalid unlisted value: {v} (must be \"true\" or \"false\")"),
None => anyhow::bail!("unlisted tag must have a value"),
};
}
Ok(unlisted)
}
async fn handle_delete_event_side_effect(
tenant: &TenantContext,
event: &Event,
@@ -2327,6 +2373,7 @@ async fn handle_create_group(
.transpose()
.map_err(|e| anyhow::anyhow!("{e}"))?
.unwrap_or_default();
let unlisted = create_unlisted_from_tags(event)?;
// If the event has an h-tag UUID, ingest_event() already created the channel
// via create_channel_with_id(). Fetch it rather than creating a duplicate.
@@ -2361,6 +2408,7 @@ async fn handle_create_group(
&actor_bytes,
ttl_seconds,
&labels,
unlisted,
)
.await?;
metrics::counter!(
@@ -2384,6 +2432,7 @@ async fn handle_create_group(
&actor_bytes,
ttl_seconds,
&labels,
unlisted,
)
.await?;
metrics::counter!(

View File

@@ -1217,6 +1217,110 @@ async fn system_channels_are_found_by_type_or_creator() {
);
}
/// An unlisted open system channel is read only by naming it, for a
/// non-member: `#t:["system"]` and `#P` discovery leave it out, also when a
/// sibling filter in the same request names it, and naming it by `#h` or
/// `#d` reads it. Its members still find it by the opt-in (NIP-CL).
/// Mutation: make `opt_in` always true in `get_readable_named_only_channels`,
/// or ignore it in `NamedOnlyChannels::from_readable` → RED (the sibling of a
/// filter that names it finds it). The branch that loads it only when named
/// is covered in buzz-db (`an_unlisted_system_channel_is_read_only_by_name`).
#[tokio::test]
#[ignore = "requires Postgres and Redis"]
async fn unlisted_system_channels_are_found_only_by_name() {
let f = fixture().await;
let owner = Keys::generate();
let outsider = Keys::generate();
let owner_hex = owner.public_key().to_hex();
let listed = create_channel(&f.state, &f.tenant, &owner, &[&["channel_type", "system"]]).await;
let unlisted =
create_channel(&f.state, &f.tenant, &owner, &[&["channel_type", "system"]]).await;
edit_channel(
&f.state,
&f.tenant,
&owner,
listed,
&[&["visibility", "open"]],
)
.await
.expect("open the listed system channel");
edit_channel(
&f.state,
&f.tenant,
&owner,
unlisted,
&[&["visibility", "open"], &["unlisted", "true"]],
)
.await
.expect("open the system channel unlisted");
let h = unlisted.to_string();
let message = publish(&f, &owner, 9, "hello", &[&["h", &h]]).await;
let listed_meta = group_state_id(&f, listed, 39000).await;
let unlisted_meta = group_state_id(&f, unlisted, 39000).await;
// Opt-in discovery: members find both, outsiders only the listed one.
for opt_in in [
json!({"kinds": [39000], "#t": ["system"]}),
json!({"kinds": [39000], "#P": [owner_hex]}),
] {
assert_eq!(
sorted(req(&f, &owner, opt_in.clone()).await),
sorted(vec![listed_meta.clone(), unlisted_meta.clone()]),
"member {opt_in}"
);
assert_eq!(
req(&f, &outsider, opt_in.clone()).await,
vec![listed_meta.clone()],
"outsider {opt_in}"
);
assert_eq!(
count(&f, &outsider, opt_in.clone()).await,
1,
"outsider COUNT {opt_in}"
);
assert_eq!(
bridge_ids(bridge(&f, &outsider, "/query", json!([opt_in])).await),
vec![listed_meta.clone()],
"outsider bridge /query {opt_in}"
);
}
// Naming it reads it.
assert_eq!(
req(&f, &outsider, json!({"kinds": [9], "#h": [h]})).await,
vec![message.clone()],
"outsider #h"
);
assert_eq!(
req(&f, &outsider, json!({"kinds": [39000], "#d": [h]})).await,
vec![unlisted_meta.clone()],
"outsider #d"
);
// A sibling that opts in does not gain the channel the first filter names.
let filters = json!([
{"kinds": [9], "#h": [h]},
{"kinds": [39000], "#t": ["system"]},
]);
let expected = sorted(vec![message, listed_meta]);
assert_eq!(
sorted(req_all(&f, &outsider, filters.clone()).await),
expected,
"outsider REQ with a naming sibling"
);
assert_eq!(
count_all(&f, &outsider, filters.clone()).await,
2,
"outsider COUNT with a naming sibling"
);
assert_eq!(
sorted(bridge_ids(bridge(&f, &outsider, "/query", filters).await)),
expected,
"outsider bridge /query with a naming sibling"
);
}
/// GET a workflow's runs as `reader`, returning the response status.
async fn workflow_runs_status(f: &Fixture, reader: &Keys, workflow: Uuid) -> u16 {
use axum::body::Body;

View File

@@ -34,8 +34,10 @@ use crate::state::AppState;
/// The named-only channels a request may add to a filter's scope: the
/// readable channels of non-legacy types, and which of them are system
/// channels. A filter reads one only by naming it; a filter that opts in also
/// reads every system one (see the module docs).
/// channels the opt-in may add. A filter reads one only by naming it; a
/// filter that opts in also reads every such system one (see the module
/// docs). An unlisted system channel the reader is not a member of is never
/// one of them, so only a filter that names it reads it.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct NamedOnlyChannels {
ids: Vec<Uuid>,
@@ -52,12 +54,28 @@ impl NamedOnlyChannels {
channels
}
/// From [`buzz_db::channel_members::get_readable_named_only_channels`],
/// which says which channels the opt-in may add.
pub(crate) fn from_readable(
rows: impl IntoIterator<Item = buzz_db::channel_members::ReadableNamedOnlyChannel>,
) -> Self {
let mut channels = Self::default();
for row in rows {
channels.insert_with_opt_in(row.id, &row.channel_type, row.opt_in);
}
channels
}
fn insert(&mut self, id: Uuid, channel_type: &str) {
self.insert_with_opt_in(id, channel_type, true);
}
fn insert_with_opt_in(&mut self, id: Uuid, channel_type: &str, opt_in: bool) {
if self.ids.contains(&id) {
return;
}
self.ids.push(id);
if channel_type == buzz_core::channel::ChannelType::System.as_str() {
if opt_in && channel_type == buzz_core::channel::ChannelType::System.as_str() {
self.system.push(id);
}
}
@@ -139,6 +157,17 @@ fn filter_needs_named_only_channels(filter: &Filter, implicit: &[Uuid]) -> bool
named_channels(filter).any(|id| !implicit.contains(&id))
}
/// The channels that `filters` name outside the implicit set, deduplicated.
/// Only these can be unlisted channels the request reaches.
fn named_outside(filters: &[Filter], implicit: &[Uuid]) -> Vec<Uuid> {
let mut seen: HashSet<Uuid> = implicit.iter().copied().collect();
filters
.iter()
.flat_map(named_channels)
.filter(|id| seen.insert(*id))
.collect()
}
/// The channels `filter` may read: the implicit set, plus the readable
/// named-only channels the filter names, plus every readable system channel
/// when it opts in.
@@ -186,10 +215,11 @@ pub(crate) async fn readable_named_only_channels_for_filters(
{
return Ok(NamedOnlyChannels::default());
}
let mut readable = NamedOnlyChannels::from_typed(
let named = named_outside(filters, implicit);
let mut readable = NamedOnlyChannels::from_readable(
state
.db
.get_readable_named_only_channels(community, pubkey)
.get_readable_named_only_channels(community, pubkey, &named)
.await?,
);
if let Some(allowed) = token_channel_ids {

View File

@@ -249,6 +249,10 @@ impl RelayInfo {
"nip-cl".to_string(),
// Bridge `/query` search accepts `search_sort: "newest"`.
"buzz-search-sort".to_string(),
// kind:9002 `unlisted` (NOSTR.md, Unlisted channels). A relay
// without it ignores the tag beside `visibility`, so a client
// checks this before opening a channel as unlisted.
"buzz-unlisted-channels".to_string(),
];
let gif = gif_provider.map(|provider| {
supported_extensions.push("buzz-gif".to_string());
@@ -668,11 +672,11 @@ mod tests {
.contains(&"buzz-gif".to_string()));
}
/// Clients refuse NIP-CL labels and system channels, and newest-first
/// search, unless the relay says it supports them, so every document must
/// advertise both.
/// Clients refuse NIP-CL labels and system channels, newest-first
/// search, and unlisted channels unless the relay says it supports them,
/// so every document must advertise all three.
#[test]
fn advertises_nip_cl_and_search_sort_extensions() {
fn advertises_nip_cl_search_sort_and_unlisted_extensions() {
let info = RelayInfo::build(
None,
None,
@@ -685,6 +689,7 @@ mod tests {
let extensions = info.supported_extensions.expect("extensions");
assert!(extensions.contains(&"nip-cl".to_string()));
assert!(extensions.contains(&"buzz-search-sort".to_string()));
assert!(extensions.contains(&"buzz-unlisted-channels".to_string()));
}
/// NIP-WP → NIP-11 mirror: a set workspace icon is served in the standard

View File

@@ -823,10 +823,13 @@ pub fn build_leave(channel_id: Uuid) -> Result<EventBuilder, SdkError> {
Ok(EventBuilder::new(Kind::Custom(9022), "").tags(tags))
}
/// Build a NIP-29 edit-metadata event for name/about/visibility/ttl/join_role (kind 9002).
/// Build a NIP-29 edit-metadata event for
/// name/about/visibility/ttl/join_role/unlisted (kind 9002).
///
/// `ttl`: outer `None` leaves it unchanged; `Some(Some(secs))` sets the
/// ephemeral timeout; `Some(None)` clears it (emits `["ttl", ""]`).
/// `unlisted`: `None` leaves it unchanged; `Some(b)` emits
/// `["unlisted", "true"|"false"]`.
pub fn build_update_channel(
channel_id: Uuid,
name: Option<&str>,
@@ -834,15 +837,18 @@ pub fn build_update_channel(
visibility: Option<&str>,
ttl: Option<Option<i32>>,
join_role: Option<buzz_core::channel::ChannelJoinRole>,
unlisted: Option<bool>,
) -> Result<EventBuilder, SdkError> {
if name.is_none()
&& about.is_none()
&& visibility.is_none()
&& ttl.is_none()
&& join_role.is_none()
&& unlisted.is_none()
{
return Err(SdkError::InvalidTag(
"at least one of name, about, visibility, ttl, or join_role must be provided".into(),
"at least one of name, about, visibility, ttl, join_role, or unlisted must be provided"
.into(),
));
}
if let Some(v) = visibility {
@@ -882,6 +888,9 @@ pub fn build_update_channel(
if let Some(join_role) = join_role {
tags.push(tag(&["join_role", join_role.as_str()])?);
}
if let Some(unlisted) = unlisted {
tags.push(tag(&["unlisted", if unlisted { "true" } else { "false" }])?);
}
Ok(EventBuilder::new(Kind::Custom(9002), "").tags(tags))
}
@@ -908,6 +917,11 @@ pub fn build_set_purpose(channel_id: Uuid, purpose: &str) -> Result<EventBuilder
/// `ttl`: `Some(secs)` makes the channel ephemeral with that lifetime in
/// seconds (the relay archives it once the deadline passes without activity);
/// `None` leaves it permanent.
///
/// `unlisted`: `true` emits `["unlisted", "true"]`, so the channel starts out
/// of channel listings for non-members; `false` emits nothing, and the
/// channel is listed. Check the relay's NIP-11 `buzz-unlisted-channels`
/// extension first: a relay without it ignores the tag.
pub fn build_create_channel(
channel_id: Uuid,
name: &str,
@@ -915,6 +929,7 @@ pub fn build_create_channel(
channel_type: Option<ChannelKind>,
about: Option<&str>,
ttl: Option<i32>,
unlisted: bool,
) -> Result<EventBuilder, SdkError> {
let name = buzz_core::channel::canonical_channel_name(name);
if name.trim().is_empty() {
@@ -933,6 +948,9 @@ pub fn build_create_channel(
if let Some(secs) = ttl {
tags.push(tag(&["ttl", &secs.to_string()])?);
}
if unlisted {
tags.push(tag(&["unlisted", "true"])?);
}
Ok(EventBuilder::new(Kind::Custom(9007), "").tags(tags))
}
@@ -3642,8 +3660,16 @@ mod tests {
fn update_channel_name_and_about() {
let cid = uuid();
let ev = sign(
build_update_channel(cid, Some("new-name"), Some("new about"), None, None, None)
.unwrap(),
build_update_channel(
cid,
Some("new-name"),
Some("new about"),
None,
None,
None,
None,
)
.unwrap(),
);
assert_eq!(ev.kind.as_u16(), 9002);
assert!(has_tag(&ev, "name", "new-name"));
@@ -3653,7 +3679,16 @@ mod tests {
#[test]
fn update_channel_strips_all_leading_hashes_from_name() {
let ev = sign(
build_update_channel(uuid(), Some(" ###new-name "), None, None, None, None).unwrap(),
build_update_channel(
uuid(),
Some(" ###new-name "),
None,
None,
None,
None,
None,
)
.unwrap(),
);
assert!(has_tag(&ev, "name", "new-name"));
}
@@ -3661,7 +3696,7 @@ mod tests {
#[test]
fn update_channel_rejects_hash_only_name() {
assert!(matches!(
build_update_channel(uuid(), Some(" ### "), None, None, None, None),
build_update_channel(uuid(), Some(" ### "), None, None, None, None, None),
Err(SdkError::InvalidTag(_))
));
}
@@ -3670,7 +3705,16 @@ mod tests {
fn update_channel_visibility_and_ttl() {
let cid = uuid();
let ev = sign(
build_update_channel(cid, None, None, Some("private"), Some(Some(3600)), None).unwrap(),
build_update_channel(
cid,
None,
None,
Some("private"),
Some(Some(3600)),
None,
None,
)
.unwrap(),
);
assert_eq!(ev.kind.as_u16(), 9002);
assert!(has_tag(&ev, "visibility", "private"));
@@ -3680,7 +3724,7 @@ mod tests {
#[test]
fn update_channel_clears_ttl() {
let cid = uuid();
let ev = sign(build_update_channel(cid, None, None, None, Some(None), None).unwrap());
let ev = sign(build_update_channel(cid, None, None, None, Some(None), None, None).unwrap());
assert!(has_tag(&ev, "ttl", ""));
}
@@ -3688,7 +3732,7 @@ mod tests {
fn update_channel_invalid_visibility_rejected() {
let cid = uuid();
assert!(matches!(
build_update_channel(cid, None, None, Some("secret"), None, None),
build_update_channel(cid, None, None, Some("secret"), None, None, None),
Err(SdkError::InvalidTag(_))
));
}
@@ -3698,27 +3742,63 @@ mod tests {
use buzz_core::channel::ChannelJoinRole;
let cid = uuid();
let on = sign(
build_update_channel(cid, None, None, None, None, Some(ChannelJoinRole::Guest))
.unwrap(),
build_update_channel(
cid,
None,
None,
None,
None,
Some(ChannelJoinRole::Guest),
None,
)
.unwrap(),
);
assert!(has_tag(&on, "join_role", "guest"));
let off = sign(
build_update_channel(cid, None, None, None, None, Some(ChannelJoinRole::Member))
.unwrap(),
build_update_channel(
cid,
None,
None,
None,
None,
Some(ChannelJoinRole::Member),
None,
)
.unwrap(),
);
assert!(has_tag(&off, "join_role", "member"));
let other = sign(build_update_channel(cid, Some("n"), None, None, None, None).unwrap());
let other =
sign(build_update_channel(cid, Some("n"), None, None, None, None, None).unwrap());
assert!(!other
.tags
.iter()
.any(|t| t.kind().to_string() == "join_role"));
}
#[test]
fn update_channel_unlisted_is_always_explicit() {
let cid = uuid();
let on = sign(
build_update_channel(cid, None, None, Some("open"), None, None, Some(true)).unwrap(),
);
assert!(has_tag(&on, "visibility", "open"));
assert!(has_tag(&on, "unlisted", "true"));
let off =
sign(build_update_channel(cid, None, None, None, None, None, Some(false)).unwrap());
assert!(has_tag(&off, "unlisted", "false"));
let other =
sign(build_update_channel(cid, Some("n"), None, None, None, None, None).unwrap());
assert!(!other
.tags
.iter()
.any(|t| t.kind().to_string() == "unlisted"));
}
#[test]
fn update_channel_no_fields_rejected() {
let cid = uuid();
assert!(matches!(
build_update_channel(cid, None, None, None, None, None),
build_update_channel(cid, None, None, None, None, None, None),
Err(SdkError::InvalidTag(_))
));
}
@@ -3750,6 +3830,7 @@ mod tests {
Some(ChannelKind::Stream),
Some("General chat"),
None,
false,
)
.unwrap(),
);
@@ -3771,6 +3852,7 @@ mod tests {
None::<ChannelKind>,
None,
None,
false,
)
.unwrap(),
);
@@ -3788,6 +3870,7 @@ mod tests {
None::<ChannelKind>,
None,
None,
false,
)
.unwrap(),
);
@@ -3804,11 +3887,35 @@ mod tests {
None::<ChannelKind>,
None,
None,
false,
),
Err(SdkError::InvalidTag(_))
));
}
#[test]
fn create_channel_unlisted_emits_tag_only_when_set() {
let build = |unlisted| {
sign(
build_create_channel(
uuid(),
"solo",
Some(Visibility::Private),
Some(ChannelKind::Stream),
None,
None,
unlisted,
)
.unwrap(),
)
};
assert!(has_tag(&build(true), "unlisted", "true"));
assert!(!build(false)
.tags
.iter()
.any(|t| t.kind().to_string() == "unlisted"));
}
#[test]
fn create_channel_ephemeral_emits_ttl() {
let cid = uuid();
@@ -3820,6 +3927,7 @@ mod tests {
Some(ChannelKind::Stream),
None,
Some(3600),
false,
)
.unwrap(),
);

View File

@@ -3200,6 +3200,454 @@ async fn post_ws(url: &str, channel_id: &str, signer: &Keys, text: &str) -> (boo
(ok.accepted, ok.message)
}
/// Submit a kind:9002 `unlisted` change and return the OK frame.
async fn set_unlisted_ws(
url: &str,
channel_id: &str,
signer: &Keys,
value: &str,
) -> (bool, String) {
let event = EventBuilder::new(Kind::Custom(9002), "")
.tags([
Tag::parse(["h", channel_id]).unwrap(),
Tag::parse(["unlisted", value]).unwrap(),
Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()]).unwrap(),
])
.sign_with_keys(signer)
.unwrap();
let mut client = BuzzTestClient::connect(url, signer)
.await
.expect("connect unlisted actor");
let ok = client.send_event(event).await.expect("send kind:9002");
client.disconnect().await.ok();
(ok.accepted, ok.message)
}
/// The events one REQ returns for `keys`.
async fn req_events(url: &str, keys: &Keys, filter: Filter) -> Vec<nostr::Event> {
let mut ws = BuzzTestClient::connect(url, keys).await.expect("connect");
let sid = sub_id("unlisted");
ws.subscribe(&sid, vec![filter]).await.expect("subscribe");
let events = ws
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("EOSE");
ws.disconnect().await.ok();
events
}
/// The channel ids of the kind:39000 events `keys` sees without naming any
/// channel, since `since`.
async fn listed_channel_ids(url: &str, keys: &Keys, since: nostr::Timestamp) -> Vec<String> {
req_events(
url,
keys,
Filter::new().kind(Kind::Custom(39000)).since(since),
)
.await
.iter()
.filter_map(|e| {
e.tags
.iter()
.find(|t| t.kind().to_string() == "d")
.and_then(|t| t.content().map(str::to_owned))
})
.collect()
}
/// An unlisted open channel through the real relay: only owners/admins set
/// it; non-members do not see it in kind:39000 discovery, history or search
/// unless they name it, and then read it; anyone can join it by id and then
/// lists it; listing it again shows it to everyone. A reader whose channel set
/// was cached before a change sees the change at once.
#[tokio::test]
#[ignore]
async fn test_nip29_unlisted_channel_wire() {
let url = relay_url();
let owner = Keys::generate();
let outsider = Keys::generate();
// 39000 timestamps are whole seconds; start one second early.
let since = nostr::Timestamp::from(nostr::Timestamp::now().as_secs() - 1);
let listed = create_test_channel(&owner).await;
let channel_id = create_test_channel(&owner).await;
// A non-member whose accessible channels are cached while it is listed.
let stranger = Keys::generate();
assert!(listed_channel_ids(&url, &stranger, since)
.await
.contains(&channel_id));
// Only owners/admins may change the setting; only true/false are valid.
let (accepted, msg) = set_unlisted_ws(&url, &channel_id, &outsider, "true").await;
assert!(!accepted, "a non-member must not change unlisted");
assert!(msg.contains("not authorized"), "{msg}");
let (accepted, msg) = set_unlisted_ws(&url, &channel_id, &owner, "yes").await;
assert!(!accepted, "an unknown unlisted value must be refused");
assert!(msg.contains("invalid unlisted value"), "{msg}");
assert_eq!(
set_unlisted_ws(&url, &channel_id, &owner, "true").await,
(true, String::new())
);
// Named by `#d`, the metadata is readable and carries the flag.
let tags = group_metadata_tags(&url, &outsider, &channel_id).await;
assert!(tags.contains(&vec!["unlisted".to_string()]), "{tags:?}");
assert!(tags.contains(&vec!["public".to_string()]), "{tags:?}");
assert!(!tags.contains(&vec!["hidden".to_string()]), "{tags:?}");
// Discovery without `#d`: members list it, non-members do not, including
// one whose channel set was cached before the change.
assert!(!listed_channel_ids(&url, &stranger, since)
.await
.contains(&channel_id));
let seen = listed_channel_ids(&url, &outsider, since).await;
assert!(
seen.contains(&listed),
"a listed channel is listed: {seen:?}"
);
assert!(!seen.contains(&channel_id), "unlisted to a non-member");
assert!(listed_channel_ids(&url, &owner, since)
.await
.contains(&channel_id));
// History and search: a non-member reads it only by naming it.
let token = format!("unlisted{}", uuid::Uuid::new_v4().simple());
let (accepted, msg) = post_ws(&url, &channel_id, &owner, &format!("hello {token}")).await;
assert!(accepted, "owner post: {msg}");
let h = SingleLetterTag::lowercase(Alphabet::H);
let messages = Filter::new().kind(Kind::Custom(9)).since(since);
let search = Filter::new().kind(Kind::Custom(9)).search(&token);
for (filter, what) in [(&messages, "history"), (&search, "search")] {
let in_channel = |events: &[nostr::Event]| {
events.iter().any(|e| {
e.tags.iter().any(|t| {
t.kind().to_string() == "h" && t.content() == Some(channel_id.as_str())
})
})
};
let unnamed = req_events(&url, &outsider, filter.clone()).await;
assert!(!in_channel(&unnamed), "{what}: unnamed, non-member");
let named = req_events(
&url,
&outsider,
filter.clone().custom_tags(h, [channel_id.as_str()]),
)
.await;
assert!(in_channel(&named), "{what}: named, non-member");
let member = req_events(&url, &owner, filter.clone()).await;
assert!(in_channel(&member), "{what}: unnamed, member");
}
// Anyone can join by id, and then lists it like any member.
let (accepted, msg) = join_ws(&url, &channel_id, &outsider).await;
assert!(accepted, "join an unlisted channel: {msg}");
assert!(listed_channel_ids(&url, &outsider, since)
.await
.contains(&channel_id));
// Listing it again shows it to everyone, at once.
assert!(!listed_channel_ids(&url, &stranger, since)
.await
.contains(&channel_id));
assert_eq!(
set_unlisted_ws(&url, &channel_id, &owner, "false").await,
(true, String::new())
);
let tags = group_metadata_tags(&url, &stranger, &channel_id).await;
assert!(!tags.contains(&vec!["unlisted".to_string()]), "{tags:?}");
assert!(listed_channel_ids(&url, &stranger, since)
.await
.contains(&channel_id));
}
/// Submit one signed event over WebSocket and return the OK frame.
async fn submit_ws(url: &str, signer: &Keys, kind: u16, tags: &[&[&str]]) -> (bool, String) {
let mut tags: Vec<Tag> = tags
.iter()
.map(|t| Tag::parse(t.iter().copied()).unwrap())
.collect();
tags.push(Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()]).unwrap());
let event = EventBuilder::new(Kind::Custom(kind), "")
.tags(tags)
.sign_with_keys(signer)
.unwrap();
let mut client = BuzzTestClient::connect(url, signer)
.await
.expect("connect submitter");
let ok = client.send_event(event).await.expect("send event");
client.disconnect().await.ok();
(ok.accepted, ok.message)
}
/// Create a private Solo-style chat (a private stream labelled `buzz.solo`)
/// with kind:9007, adding `extra` tags, and return its id.
async fn create_solo_chat_ws(url: &str, owner: &Keys, extra: &[&[&str]]) -> String {
let channel_id = uuid::Uuid::new_v4().to_string();
let name = format!("solo-e2e-{channel_id}");
let base: [&[&str]; 5] = [
&["h", &channel_id],
&["name", &name],
&["channel_type", "stream"],
&["visibility", "private"],
&["t", "buzz.solo"],
];
let tags: Vec<&[&str]> = base.into_iter().chain(extra.iter().copied()).collect();
let (accepted, msg) = submit_ws(url, owner, 9007, &tags).await;
assert!(accepted, "create Solo chat: {msg}");
channel_id
}
/// Whether any event in `events` belongs to `channel_id`.
fn any_in_channel(events: &[nostr::Event], channel_id: &str) -> bool {
events.iter().any(|e| {
e.tags
.iter()
.any(|t| t.kind().to_string() == "h" && t.content() == Some(channel_id))
})
}
/// The Solo sharing model through the real relay, one event per user action:
///
/// - **Create unlisted:** kind:9007 `["unlisted", "true"]` starts a private
/// chat unlisted, and a later plain `["visibility", "open"]` keeps it
/// unlisted.
/// - **Share:** one kind:9002 with `visibility open` + `unlisted true` +
/// `join_role guest`. A non-member does not list it, reads it by `#h`
/// (history and live), and cannot post. A kind:9021 join (pin) lists it for
/// them, still as a read-only guest; a kind:9022 leave (unpin) unlists it
/// again and it stays readable by `#h`.
/// - **Unshare:** kind:9002 `visibility private` closes the non-member's live
/// subscription and refuses a new `#h` read.
/// - **Convert to a channel:** one kind:9002 with `["t", ""]` +
/// `unlisted false`. A non-member then finds it in discovery.
#[tokio::test]
#[ignore]
async fn test_nip29_solo_share_and_convert_wire() {
let url = relay_url();
let owner = Keys::generate();
let viewer = Keys::generate();
// A non-member whose accessible channels are cached before each change.
let stranger = Keys::generate();
let since = nostr::Timestamp::from(nostr::Timestamp::now().as_secs() - 1);
let h = SingleLetterTag::lowercase(Alphabet::H);
let messages = Filter::new().kind(Kind::Custom(9)).since(since);
// ── Create unlisted (kind:9007) ─────────────────────────────────────────
let (accepted, msg) = submit_ws(
&url,
&owner,
9007,
&[
&["h", &uuid::Uuid::new_v4().to_string()],
&["name", "solo-bad-unlisted"],
&["visibility", "private"],
&["unlisted", "yes"],
],
)
.await;
assert!(!accepted, "an unknown unlisted value must be refused");
assert!(msg.contains("invalid unlisted value"), "{msg}");
let born_unlisted = create_solo_chat_ws(&url, &owner, &[&["unlisted", "true"]]).await;
let tags = group_metadata_tags(&url, &owner, &born_unlisted).await;
assert!(tags.contains(&vec!["unlisted".to_string()]), "{tags:?}");
assert!(tags.contains(&vec!["private".to_string()]), "{tags:?}");
assert!(!listed_channel_ids(&url, &stranger, since)
.await
.contains(&born_unlisted));
let (accepted, msg) = submit_ws(
&url,
&owner,
9002,
&[&["h", &born_unlisted], &["visibility", "open"]],
)
.await;
assert!(accepted, "open a chat created unlisted: {msg}");
assert!(
!listed_channel_ids(&url, &stranger, since)
.await
.contains(&born_unlisted),
"opening a chat created unlisted keeps it unlisted"
);
let tags = group_metadata_tags(&url, &stranger, &born_unlisted).await;
assert!(tags.contains(&vec!["unlisted".to_string()]), "{tags:?}");
assert!(tags.contains(&vec!["public".to_string()]), "{tags:?}");
// ── Share: one kind:9002 on an existing (listed) private chat ───────────
let chat = create_solo_chat_ws(&url, &owner, &[]).await;
let (accepted, msg) = post_ws(&url, &chat, &owner, "before sharing").await;
assert!(accepted, "owner post: {msg}");
assert!(!listed_channel_ids(&url, &stranger, since)
.await
.contains(&chat));
let (accepted, msg) = submit_ws(
&url,
&owner,
9002,
&[
&["h", &chat],
&["visibility", "open"],
&["unlisted", "true"],
&["join_role", "guest"],
],
)
.await;
assert!(accepted, "share in one event: {msg}");
let tags = group_metadata_tags(&url, &viewer, &chat).await;
for flag in ["public", "unlisted", "restricted"] {
assert!(tags.contains(&vec![flag.to_string()]), "{flag}: {tags:?}");
}
assert!(
tags.contains(&vec!["join_role".to_string(), "guest".to_string()]),
"{tags:?}"
);
assert!(
!listed_channel_ids(&url, &stranger, since)
.await
.contains(&chat),
"a shared chat is not listed to a non-member"
);
// A non-member viewer reads it by `#h`, history and live, but can't post.
let named = messages.clone().custom_tags(h, [chat.as_str()]);
assert!(any_in_channel(
&req_events(&url, &viewer, named.clone()).await,
&chat
));
assert!(!any_in_channel(
&req_events(&url, &viewer, messages.clone()).await,
&chat
));
let mut live = BuzzTestClient::connect(&url, &viewer)
.await
.expect("connect viewer");
let live_sub = sub_id("solo-live");
live.subscribe(&live_sub, vec![named.clone()])
.await
.expect("subscribe by #h");
live.collect_until_eose(&live_sub, Duration::from_secs(5))
.await
.expect("EOSE");
let token = format!("live-{}", uuid::Uuid::new_v4().simple());
let (accepted, msg) = post_ws(&url, &chat, &owner, &token).await;
assert!(accepted, "owner post: {msg}");
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
match live.recv_event(remaining).await.expect("live event") {
RelayMessage::Event {
subscription_id,
event,
} if subscription_id == live_sub && event.content == token => break,
RelayMessage::Closed { message, .. } => panic!("live sub closed: {message}"),
_ => {}
}
}
let (accepted, msg) = post_ws(&url, &chat, &viewer, "viewer post").await;
assert!(!accepted, "a guest-join chat is read-only to a non-member");
assert!(msg.contains("only members can post"), "{msg}");
// Pin (kind:9021): listed for the viewer, still a read-only guest.
let (accepted, msg) = join_ws(&url, &chat, &viewer).await;
assert!(accepted, "pin: {msg}");
assert!(listed_channel_ids(&url, &viewer, since)
.await
.contains(&chat));
assert!(any_in_channel(
&req_events(&url, &viewer, messages.clone()).await,
&chat
));
let (accepted, msg) = post_ws(&url, &chat, &viewer, "guest post").await;
assert!(!accepted, "a pinned guest still cannot post");
assert!(msg.contains("only members can post"), "{msg}");
assert!(!listed_channel_ids(&url, &stranger, since)
.await
.contains(&chat));
// Unpin (kind:9022): unlisted for the viewer again, still readable by id.
let (accepted, msg) = self_departure_ws(&url, &chat, &viewer, 9022).await;
assert!(accepted, "unpin: {msg}");
assert!(
!listed_channel_ids(&url, &viewer, since)
.await
.contains(&chat),
"an unpinned chat leaves the viewer's list"
);
assert!(any_in_channel(
&req_events(&url, &viewer, named.clone()).await,
&chat
));
// Unshare: the viewer's live subscription closes and a new read is refused.
let (accepted, msg) = submit_ws(
&url,
&owner,
9002,
&[&["h", &chat], &["visibility", "private"]],
)
.await;
assert!(accepted, "unshare: {msg}");
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
match live.recv_event(remaining).await.expect("CLOSED on unshare") {
RelayMessage::Closed {
subscription_id, ..
} if subscription_id == live_sub => break,
RelayMessage::Event {
subscription_id,
event,
} if subscription_id == live_sub && event.kind == Kind::Custom(9) => {
panic!("a message reached the viewer after unsharing: {event:?}")
}
_ => {}
}
}
live.disconnect().await.ok();
let mut ws = BuzzTestClient::connect(&url, &viewer)
.await
.expect("connect");
let sid = sub_id("solo-unshared");
ws.subscribe(&sid, vec![named]).await.expect("subscribe");
match ws
.recv_event(Duration::from_secs(5))
.await
.expect("reply to #h read")
{
RelayMessage::Closed { message, .. } => {
assert!(message.contains("restricted"), "{message}")
}
other => panic!("an unshared chat must refuse a non-member: {other:?}"),
}
ws.disconnect().await.ok();
// ── Convert to a channel: one kind:9002 ─────────────────────────────────
let (accepted, msg) = submit_ws(
&url,
&owner,
9002,
&[
&["h", &born_unlisted],
&["t", ""],
&["unlisted", "false"],
&["visibility", "open"],
],
)
.await;
assert!(accepted, "convert in one event: {msg}");
let tags = group_metadata_tags(&url, &stranger, &born_unlisted).await;
assert!(!tags.contains(&vec!["unlisted".to_string()]), "{tags:?}");
assert!(
!tags.contains(&vec!["t".to_string(), "buzz.solo".to_string()]),
"converting clears the Solo label: {tags:?}"
);
assert!(
listed_channel_ids(&url, &stranger, since)
.await
.contains(&born_unlisted),
"a converted chat is listed to non-members"
);
}
/// Record `owner` as the NIP-OA owner of `agent`, by authenticating once.
async fn record_agent_owner(url: &str, agent: &Keys, owner: &Keys) {
let auth_tag = buzz_sdk::nip_oa::parse_auth_tag(