feat(relay): leave system channels out of reads that do not name them (#8181)

🤖
## Summary
After part 1, a system channel is still returned by every query that
returns ordinary channels, so existing clients would show it as a chat.
This PR makes the relay leave system channels out of every read that
does not name them. Clients do not need to filter, so clients that know
nothing about system channels stay correct.

| Query | Before | After |
|---|---|---|
| A query that does not name a channel (no `#h`), COUNT, full-text
search, the HTTP `/query` and `/count` routes, artifact queries
(`{"artifact": ...}`), the sidebar | Includes system channels | Leaves
them out |
| "Channels I belong to" (kind:39002 `#p:[me]`) | Includes them | Leaves
them out |
| A filter that names the channel with `#h`, or with `#d` on kinds
39000–39003 | Ordinary read rules | Unchanged: members can read it, and
anyone can read it if it is open |

Push notifications and "you were added to a channel" notifications
(kinds 44100/44101) skip system channels, because a client that received
one would show the channel.

A client that is given a system channel's ID, for example in a pasted
link, can still open it. Naming the ID is the opt-in. This keeps system
channels ordinary NIP-29 groups.

Part 2 of 3, stacked on part 1.

### Related issue
None found.

### Details
- The rule lives in one place: `get_accessible_channel_ids`, the set of
channels a query may read when it does not name one. It is the union of
the reader's memberships and every open channel; both halves now exclude
`channel_type = 'system'`. Because the filter is in SQL before `LIMIT`,
pages stay full.
- A filter that names a system channel adds it to that filter's scope
only (`handlers/system_channels.rs`). A sibling filter in the same
request keeps the implicit set, so naming one system channel cannot
widen another filter.
- Thread windows and the workflows API name their channel, so they
follow the same named-channel rule.
- Artifact queries have their own read check in SQL. It reads a system
channel only when the query's `#h` names it.
- When a request re-checks membership for a named channel that its first
lookup missed, a confirmed system channel joins only the filters that
name it. Before, a reader who joined a system channel during the request
could have it added to a sibling filter's scope.
- The system-channel lookup runs only when a filter names a channel
outside the implicit set, so ordinary queries do not pay for it.
- **Spec.** NIP-CL lists the reads that leave system channels out, and
says when a filter names one. `AGENTS.md` notes this for `get_channels`.

### Testing
- End-to-end relay test (Postgres and Redis) with a private system
channel, an open system channel and an ordinary channel, as a member and
as a non-member: REQ without `#h`, NIP-50 search, COUNT, HTTP `/query`,
HTTP search, HTTP `/count`, the channel list, and 39002 `#p:[me]` return
only the ordinary channel. Named reads by `#h` and `#d` follow
member-or-open on REQ, COUNT, search and HTTP.
- Relay test: adding a member to a system channel sends no kind:44100;
an ordinary channel still does.
- Both tests fail when the SQL exclusion or the named-channel scope is
removed.
- Database test: an artifact in an open system channel is missing from
`{"artifact":"current"}` and present with `#h:[<system channel>]`, for a
member and a non-member. It fails without the channel-type condition.
- Relay test: the membership re-check confirms a system channel that the
system-channel lookup missed. The channel goes to the filter that names
it, and a sibling filter's scope does not include it. It fails without
the fix.
- Unit tests for the per-filter scope. They now run in `just test-unit`
and `scripts/run-tests.sh`.

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

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

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

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

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

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
Logan Johnson
2026-10-15 08:40:15 -04:00
committed by GitHub
parent b77ba86262
commit cdf03f618a
19 changed files with 1220 additions and 84 deletions

View File

@@ -255,6 +255,48 @@ async fn lifecycle_cas_queries_move_redaction_and_retention() {
f.db.validate_deletion_serving_catalog().await.unwrap();
}
/// An artifact in a system channel is read only by a query whose `#h` names
/// that channel (NIP-CL), even when the channel is open.
/// Mutation: drop the `channel_type` condition from `query_artifacts` → RED.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn system_channel_artifacts_are_read_only_by_name() {
let f = Fixture::new().await;
let system = Uuid::new_v4();
sqlx::query("INSERT INTO channels(community_id,id,name,visibility,channel_type,created_by) VALUES($1,$2,$3,'open','system',$4)")
.bind(f.community.as_uuid())
.bind(system)
.bind(system.to_string())
.bind(f.owner.public_key().to_bytes().as_slice())
.execute(&f.db.pool)
.await
.unwrap();
for home in [f.a, system] {
let create = f.revision(Uuid::new_v4(), "create", home, None, &f.owner, vec![]);
assert!(matches!(
f.accept(&create, None).await,
ArtifactOutcome::Accepted(_)
));
}
for reader in [&f.owner, &f.peer] {
let (events, _) = f
.query(reader, serde_json::json!({"artifact":"current"}), false)
.await;
let homes: Vec<_> = events.iter().map(|e| e.channel_id).collect();
assert_eq!(homes, [Some(f.a)], "no #h");
assert_eq!(
f.count(reader, serde_json::json!({"artifact":"current"}))
.await,
1
);
let named = serde_json::json!({"artifact":"current","#h":[system.to_string()]});
let (events, _) = f.query(reader, named.clone(), false).await;
let homes: Vec<_> = events.iter().map(|e| e.channel_id).collect();
assert_eq!(homes, [Some(system)], "#h names the system channel");
assert_eq!(f.count(reader, named).await, 1);
}
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn repeated_moves_get_distinct_source_safe_markers() {

View File

@@ -36,9 +36,19 @@ 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.
let named_channels: Vec<uuid::Uuid> = query
.tags
.iter()
.filter(|(name, _)| name == "h")
.flat_map(|(_, values)| values.iter().filter_map(|value| value.parse().ok()))
.collect();
// The explicit kind lets the community/kind index exclude chat before
// the join, even for an unfiltered current-state page.
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 (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=")
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 (c.channel_type<>'system' 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_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

@@ -1117,6 +1117,10 @@ pub async fn get_members_bulk(
///
/// Includes channels where the pubkey is an active member AND all open channels.
/// Open channels must be included in REQ filter resolution.
///
/// System channels are left out of both halves: this is the implicit scope of
/// queries that do not name a channel. A query that names a system channel,
/// or opts in to them, adds them from [`get_readable_system_channel_ids`].
pub async fn get_accessible_channel_ids(
pool: &PgPool,
community_id: CommunityId,
@@ -1133,10 +1137,12 @@ pub async fn get_accessible_channel_ids(
FROM channel_members cm
JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL
WHERE cm.community_id = $1 AND cm.pubkey = $2 AND cm.removed_at IS NULL
AND c.channel_type <> 'system'
UNION
SELECT id AS channel_id
FROM channels
WHERE community_id = $1 AND visibility = 'open' AND deleted_at IS NULL
AND channel_type <> 'system'
"#,
)
.bind(community_id.as_uuid())
@@ -1152,6 +1158,72 @@ pub async fn get_accessible_channel_ids(
.collect()
}
/// Get the system channels a pubkey can read: those where it is an active
/// member, and every open system channel. Uncached, so a caller can trust it
/// for a channel the request names.
pub async fn get_readable_system_channel_ids(
pool: &PgPool,
community_id: CommunityId,
pubkey: &[u8],
) -> Result<Vec<Uuid>> {
let mut connection = crate::observability::acquire_writer(
pool,
crate::observability::WriterOperation::Authorization,
)
.await?;
let rows = sqlx::query(
r#"
SELECT c.id AS channel_id
FROM channels c
WHERE c.community_id = $1 AND c.deleted_at IS NULL AND c.channel_type = 'system'
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
)
)
"#,
)
.bind(community_id.as_uuid())
.bind(pubkey)
.fetch_all(&mut *connection)
.await?;
rows.into_iter()
.map(|r| {
let id: Uuid = r.try_get("channel_id")?;
Ok(id)
})
.collect()
}
/// The IDs in `channel_ids` that name system channels, deleted or not.
/// Push matching and membership notifications use it to skip them.
pub async fn system_channel_ids_among(
pool: &PgPool,
community_id: CommunityId,
channel_ids: &[Uuid],
) -> Result<Vec<Uuid>> {
if channel_ids.is_empty() {
return Ok(Vec::new());
}
let mut connection = crate::observability::acquire_writer(
pool,
crate::observability::WriterOperation::Authorization,
)
.await?;
Ok(sqlx::query_scalar(
"SELECT id FROM channels \
WHERE community_id = $1 AND id = ANY($2) AND channel_type = 'system'",
)
.bind(community_id.as_uuid())
.bind(channel_ids)
.fetch_all(&mut *connection)
.await?)
}
/// A large channel whose canonical active-member count may need its legacy
/// discovery snapshot repaired.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -1377,6 +1449,7 @@ pub async fn get_accessible_channels(
WHERE c.community_id = $1 AND c.deleted_at IS NULL
{membership_clause}
AND (c.channel_type != 'dm' OR cm.hidden_at IS NULL)
AND c.channel_type <> 'system'
"#
);
@@ -1782,6 +1855,26 @@ impl Db {
get_accessible_channel_ids(&self.pool, community_id, pubkey).await
}
/// Get the system channels a pubkey can read (member, or open).
#[datastore_span(name = "get_readable_system_channel_ids", system = "postgresql")]
pub async fn get_readable_system_channel_ids(
&self,
community_id: CommunityId,
pubkey: &[u8],
) -> Result<Vec<Uuid>> {
get_readable_system_channel_ids(&self.pool, community_id, pubkey).await
}
/// The IDs in `channel_ids` that name system channels, deleted or not.
#[datastore_span(name = "system_channel_ids_among", system = "postgresql")]
pub async fn system_channel_ids_among(
&self,
community_id: CommunityId,
channel_ids: &[Uuid],
) -> Result<Vec<Uuid>> {
system_channel_ids_among(&self.pool, community_id, channel_ids).await
}
/// Returns large active-channel rosters whose relay-authored snapshots differ.
#[datastore_span(
name = "list_large_channel_rosters_needing_reconciliation",

View File

@@ -99,6 +99,8 @@ impl Db {
WHERE cm.community_id=$1 AND cm.pubkey=$2 AND cm.removed_at IS NULL
AND c.deleted_at IS NULL AND ($3::uuid IS NULL OR c.id>$3)
AND ($9::uuid[] IS NULL OR c.id=ANY($9))
-- System channels only when the request names them.
AND ($9::uuid[] IS NOT NULL OR c.channel_type<>'system')
ORDER BY c.id LIMIT $4
)
SELECT r.*, COALESCE(e.latest_id, latest.latest_id) AS latest_id,