mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(relay): add the system channel type (#8180)
🤖 ## Summary Buzz uses channel membership to decide who can read what, and every channel a person belongs to shows up in their client as a chat. So there is no way to make a group that exists only for access control, for example "only this agent and its owner can read the agent's config". Such a channel would appear in both people's sidebars, search results and unread counts. This stack adds a **system channel**: an ordinary channel that queries leave out unless they ask for it. This first PR adds the type itself. - `kind:9007` (create channel) with `["channel_type", "system"]` creates a system channel. - It is private unless the creator sends `["visibility", "open"]`. - While it is private, its channel metadata event (kind:39000) carries the NIP-29 `hidden` marker, which means "hide this group's metadata from non-members". Until now the relay set `hidden` only for DMs. On its own this PR does not yet hide system channels from queries. Part 2 does that. Part 1 of 3, stacked on #8176 (the channel labels and creator lookup stack, which this feature uses). ### Related issue None found. ### Details - Migration 0062 adds `system` to the `channel_type` Postgres enum. No new columns. - `buzz_core::channel::ChannelType::System`, and `system` is now a reserved label name (labels cannot equal a channel type name). - `buzz-admin` reconcile writes `hidden` with the same rule as the relay. Usage metrics count system channels. - **Rollback.** A relay older than this change cannot read `channel_type = 'system'`. Do not roll back past it while system channels exist. - **Spec.** NIP-CL gets a "System channels" section. `ARCHITECTURE.md` lists the `System` type. ### Testing - Relay test: a system channel is private and `hidden` by default; opening it removes `hidden`; its first `t` tag is `system`. - Migration embed and usage-metrics tests. ### Stack These are two stacks. Merge them in this order. Client support (creating, labeling and finding these channels) will be in block/buzz-app, not the buzz SDK and CLI. **Stack 1: channel labels and creator lookup (#8174–#8176)** 1. #8174 channel type and creator tags, and the channel creation time 2. #8175 channel labels 3. #8176 database-side label and creator match, and the rule for choosing one channel **Stack 2: system channels (#8180–#8182)**, stacked on stack 1 because it uses labels and the creator tag 4. #8180 the system channel type ← this PR 5. #8181 leave system channels out of reads that do not name them 6. #8182 channel-state filters opt in to system channels --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -474,7 +474,7 @@ All database access. Uses `sqlx::query()` (runtime, not compile-time macros) —
|
||||
| `user.rs` | User profile storage |
|
||||
| `error.rs` | Database error types |
|
||||
|
||||
**Channel types:** `Stream`, `Forum`, `Dm`, `Workflow`
|
||||
**Channel types:** `Stream`, `Forum`, `Dm`, `Workflow`, `System`
|
||||
**Member roles:** `Owner`, `Admin`, `Member`, `Guest`, `Bot`
|
||||
**Workflow statuses:** `Active`, `Disabled`, `Archived`
|
||||
**Run statuses:** `Pending`, `Running`, `WaitingApproval`, `Completed`, `Failed`, `Cancelled`
|
||||
|
||||
4
NOSTR.md
4
NOSTR.md
@@ -57,7 +57,7 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \
|
||||
| **Admin delete event (kind:9005)** | ✅ | Event author can always delete own. Otherwise owner/admin required. Target must be in same channel. |
|
||||
| **Group deletion (kind:9008)** | ✅ | Owner only. |
|
||||
| **Leave group (kind:9022)** | ✅ | Any member. Last-owner guard prevents orphaned groups. |
|
||||
| **Group metadata (kind:39000)** | ✅ | Relay-signed; always `d`, `name`, `closed`, `["t", <channel_type>]`, `["P", <creator>]` and `["created_at", <unix seconds>]` tags; `about` only if description non-empty; `private` if applicable; `hidden` for DM channels |
|
||||
| **Group metadata (kind:39000)** | ✅ | Relay-signed; always `d`, `name`, `closed`, `["t", <channel_type>]`, `["P", <creator>]` and `["created_at", <unix seconds>]` tags; `about` only if description non-empty; `private` if applicable; `hidden` for DM channels and private system channels |
|
||||
| **Group admins (kind:39001)** | ✅ | Relay-signed; `d`, channel type `t` and creator `P` tags + `p` tags with roles (`owner`, `admin`) |
|
||||
| **Group members (kind:39002)** | ✅ | Relay-signed; `d`, channel type `t` and creator `P` tags + `p` tags for all members |
|
||||
| **Membership notifications** | ✅ | kind:44100 (added) / kind:44101 (removed); relay-signed, community-global scope (`channel_id=None` inside the connected community) |
|
||||
@@ -109,7 +109,7 @@ All discovery events include a `d` tag set to the channel UUID (NIP-29 addressab
|
||||
|
||||
| Kind | Tags | Content |
|
||||
|------|------|---------|
|
||||
| **39000** | `d=<uuid>`, `name`, `closed`, `t=<channel_type>`, `P=<creator>`, `created_at=<unix seconds>` (always); `about` (if description non-empty); `private` (if applicable); `hidden` (DM channels only) | Group metadata. **Note:** `closed` is always emitted per NIP-29 convention (Buzz channels require explicit membership), but open channels are still readable/writable by non-members at runtime. The tag reflects the membership model, not access enforcement. |
|
||||
| **39000** | `d=<uuid>`, `name`, `closed`, `t=<channel_type>`, `P=<creator>`, `created_at=<unix seconds>` (always); `about` (if description non-empty); `private` (if applicable); `hidden` (DM channels and private system channels) | Group metadata. **Note:** `closed` is always emitted per NIP-29 convention (Buzz channels require explicit membership), but open channels are still readable/writable by non-members at runtime. The tag reflects the membership model, not access enforcement. |
|
||||
| **39001** | `d=<uuid>`, `t=<channel_type>`, `P=<creator>`, `p` tags with role label (`owner`, `admin`) | Admin list |
|
||||
| **39002** | `d=<uuid>`, `t=<channel_type>`, `P=<creator>`, `p` tags for all members | Member list |
|
||||
|
||||
|
||||
@@ -157,7 +157,9 @@ pub(crate) fn channel_type_from_tags(tags: &[serde_json::Value]) -> String {
|
||||
}
|
||||
}
|
||||
}
|
||||
if declared_type == Some("dm") || is_hidden {
|
||||
// `hidden` marks a DM only on metadata without a declared type; a private
|
||||
// system channel is hidden too.
|
||||
if declared_type == Some("dm") || (declared_type.is_none() && is_hidden) {
|
||||
"dm".to_string()
|
||||
} else if declared_type == Some("private") || is_private {
|
||||
"private".to_string()
|
||||
@@ -4202,6 +4204,21 @@ mod tests {
|
||||
assert_eq!(channel_type_from_tags(&tags), "dm");
|
||||
}
|
||||
|
||||
/// Mutation: treat any `hidden` metadata as a DM → RED.
|
||||
#[test]
|
||||
fn hidden_means_dm_only_without_a_declared_type() {
|
||||
let system = vec![
|
||||
serde_json::json!(["d", "c"]),
|
||||
serde_json::json!(["t", "system"]),
|
||||
serde_json::json!(["private"]),
|
||||
serde_json::json!(["hidden"]),
|
||||
];
|
||||
assert_eq!(channel_type_from_tags(&system), "private");
|
||||
|
||||
let legacy_dm = vec![serde_json::json!(["d", "c"]), serde_json::json!(["hidden"])];
|
||||
assert_eq!(channel_type_from_tags(&legacy_dm), "dm");
|
||||
}
|
||||
|
||||
async fn nip11_test_client(
|
||||
responses: HashMap<String, (u16, String)>,
|
||||
) -> (
|
||||
|
||||
@@ -836,7 +836,9 @@ async fn reconcile_channels(
|
||||
} else {
|
||||
tags.push(Tag::parse(["public"])?);
|
||||
}
|
||||
if channel.channel_type == "dm" {
|
||||
if channel.channel_type == "dm"
|
||||
|| (channel.channel_type == "system" && channel.visibility == "private")
|
||||
{
|
||||
tags.push(Tag::parse(["hidden"])?);
|
||||
}
|
||||
tags.push(Tag::parse(["closed"])?);
|
||||
|
||||
@@ -182,6 +182,10 @@ pub enum ChannelType {
|
||||
Dm,
|
||||
/// Internal workflow execution channel.
|
||||
Workflow,
|
||||
/// Access-control channel. The relay leaves it out of queries unless a
|
||||
/// filter names it, or asks for system channels with `#t:["system"]` on
|
||||
/// kinds 39000-39003.
|
||||
System,
|
||||
}
|
||||
|
||||
impl ChannelType {
|
||||
@@ -192,6 +196,17 @@ impl ChannelType {
|
||||
Self::Forum => "forum",
|
||||
Self::Dm => "dm",
|
||||
Self::Workflow => "workflow",
|
||||
Self::System => "system",
|
||||
}
|
||||
}
|
||||
|
||||
/// Visibility of a new channel of this type when its create event has no
|
||||
/// `visibility` tag. A system channel holds access-control data, so it is
|
||||
/// private unless its creator asks for open.
|
||||
pub fn default_visibility(&self) -> ChannelVisibility {
|
||||
match self {
|
||||
Self::System => ChannelVisibility::Private,
|
||||
Self::Stream | Self::Forum | Self::Dm | Self::Workflow => ChannelVisibility::Open,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,6 +226,7 @@ impl FromStr for ChannelType {
|
||||
"forum" => Ok(Self::Forum),
|
||||
"dm" => Ok(Self::Dm),
|
||||
"workflow" => Ok(Self::Workflow),
|
||||
"system" => Ok(Self::System),
|
||||
other => Err(format!("unknown channel type: {other:?}")),
|
||||
}
|
||||
}
|
||||
@@ -309,18 +325,20 @@ mod tests {
|
||||
/// until its name is in `RESERVED_CHANNEL_LABELS`.
|
||||
#[test]
|
||||
fn every_channel_type_name_is_a_reserved_label() {
|
||||
const ALL: [ChannelType; 4] = [
|
||||
const ALL: [ChannelType; 5] = [
|
||||
ChannelType::Stream,
|
||||
ChannelType::Forum,
|
||||
ChannelType::Dm,
|
||||
ChannelType::Workflow,
|
||||
ChannelType::System,
|
||||
];
|
||||
for channel_type in ALL {
|
||||
match channel_type {
|
||||
ChannelType::Stream
|
||||
| ChannelType::Forum
|
||||
| ChannelType::Dm
|
||||
| ChannelType::Workflow => {}
|
||||
| ChannelType::Workflow
|
||||
| ChannelType::System => {}
|
||||
}
|
||||
assert!(
|
||||
RESERVED_CHANNEL_LABELS.contains(&channel_type.as_str()),
|
||||
|
||||
@@ -705,7 +705,7 @@ mod postgres_tests {
|
||||
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
|
||||
migrations.sort_by_key(|migration| migration.version);
|
||||
|
||||
assert_eq!(migrations.len(), 61);
|
||||
assert_eq!(migrations.len(), 62);
|
||||
assert_eq!(migrations[58].version, 59);
|
||||
assert!(migrations[58].sql.as_str().contains("buzz.push_enabled"));
|
||||
assert_eq!(migrations[59].version, 60);
|
||||
@@ -715,6 +715,11 @@ mod postgres_tests {
|
||||
.contains("ALTER TABLE push_leases DROP COLUMN app_profile"));
|
||||
assert_eq!(migrations[60].version, 61);
|
||||
assert!(migrations[60].sql.as_str().contains("ADD COLUMN labels"));
|
||||
assert_eq!(migrations[61].version, 62);
|
||||
assert!(migrations[61]
|
||||
.sql
|
||||
.as_str()
|
||||
.contains("ALTER TYPE channel_type ADD VALUE IF NOT EXISTS 'system'"));
|
||||
assert_eq!(migrations[55].version, 56);
|
||||
assert!(migrations[55]
|
||||
.sql
|
||||
|
||||
@@ -38,6 +38,8 @@ pub struct FleetStockSnapshot {
|
||||
pub channels_dm: i64,
|
||||
/// Non-deleted workflow channels.
|
||||
pub channels_workflow: i64,
|
||||
/// Non-deleted system channels.
|
||||
pub channels_system: i64,
|
||||
/// Relay owners.
|
||||
pub members_owner: i64,
|
||||
/// Relay administrators.
|
||||
@@ -96,7 +98,8 @@ where
|
||||
COUNT(*) FILTER (WHERE channel_type = 'stream') AS stream,
|
||||
COUNT(*) FILTER (WHERE channel_type = 'forum') AS forum,
|
||||
COUNT(*) FILTER (WHERE channel_type = 'dm') AS dm,
|
||||
COUNT(*) FILTER (WHERE channel_type = 'workflow') AS workflow
|
||||
COUNT(*) FILTER (WHERE channel_type = 'workflow') AS workflow,
|
||||
COUNT(*) FILTER (WHERE channel_type = 'system') AS system
|
||||
FROM channels
|
||||
WHERE deleted_at IS NULL
|
||||
),
|
||||
@@ -122,6 +125,7 @@ where
|
||||
channels.forum AS channels_forum,
|
||||
channels.dm AS channels_dm,
|
||||
channels.workflow AS channels_workflow,
|
||||
channels.system AS channels_system,
|
||||
members.owner AS members_owner,
|
||||
members.admin AS members_admin,
|
||||
members.member AS members_member,
|
||||
@@ -1455,7 +1459,7 @@ mod postgres_tests {
|
||||
.await
|
||||
.expect("deactivate user");
|
||||
|
||||
for channel_type in ["stream", "forum", "dm", "workflow"] {
|
||||
for channel_type in ["stream", "forum", "dm", "workflow", "system"] {
|
||||
sqlx::query(
|
||||
"INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) \
|
||||
VALUES ($1, $2, $3, $4::channel_type, 'open', $5)",
|
||||
@@ -1589,8 +1593,9 @@ mod postgres_tests {
|
||||
stock.channels_forum,
|
||||
stock.channels_dm,
|
||||
stock.channels_workflow,
|
||||
stock.channels_system,
|
||||
),
|
||||
(1, 1, 1, 1)
|
||||
(1, 1, 1, 1, 1)
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
|
||||
@@ -797,3 +797,139 @@ async fn label_reqs_are_not_starved_by_containment_false_matches() {
|
||||
"label name"
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether a group-state tag list has a bare `[name]` marker tag.
|
||||
fn has_marker(tags: &[Vec<String>], name: &str) -> bool {
|
||||
tags.iter().any(|tag| tag.len() == 1 && tag[0] == name)
|
||||
}
|
||||
|
||||
/// A system channel is private unless its creator asks for open, and its
|
||||
/// kind:39000 carries NIP-29 `hidden` exactly while it is private.
|
||||
/// Mutation: default system channels to open, or drop the `hidden` rule →
|
||||
/// RED.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn system_channel_defaults_private_and_hidden() {
|
||||
let state = state().await;
|
||||
let tenant = community(&state, "group-state-system").await;
|
||||
let creator = Keys::generate();
|
||||
|
||||
let private = create_channel(&state, &tenant, &creator, &[&["channel_type", "system"]]).await;
|
||||
let tags = group_state_tags(&state, &tenant, private, 39000).await;
|
||||
assert!(has_marker(&tags, "private"), "default private: {tags:?}");
|
||||
assert!(
|
||||
has_marker(&tags, "hidden"),
|
||||
"private system is hidden: {tags:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
t_values(&state, &tenant, private, 39000).await.first(),
|
||||
Some(&"system".to_string())
|
||||
);
|
||||
|
||||
edit_channel(
|
||||
&state,
|
||||
&tenant,
|
||||
&creator,
|
||||
private,
|
||||
&[&["visibility", "open"]],
|
||||
)
|
||||
.await
|
||||
.expect("open the channel");
|
||||
let tags = group_state_tags(&state, &tenant, private, 39000).await;
|
||||
assert!(has_marker(&tags, "public"), "opened: {tags:?}");
|
||||
assert!(
|
||||
!has_marker(&tags, "hidden"),
|
||||
"open system is not hidden: {tags:?}"
|
||||
);
|
||||
|
||||
let open = create_channel(
|
||||
&state,
|
||||
&tenant,
|
||||
&creator,
|
||||
&[&["channel_type", "system"], &["visibility", "open"]],
|
||||
)
|
||||
.await;
|
||||
let tags = group_state_tags(&state, &tenant, open, 39000).await;
|
||||
assert!(has_marker(&tags, "public"), "explicit open: {tags:?}");
|
||||
assert!(!has_marker(&tags, "hidden"), "{tags:?}");
|
||||
|
||||
// Other types keep the open default and are not hidden.
|
||||
let stream = create_channel(&state, &tenant, &creator, &[]).await;
|
||||
let tags = group_state_tags(&state, &tenant, stream, 39000).await;
|
||||
assert!(has_marker(&tags, "public"), "{tags:?}");
|
||||
assert!(!has_marker(&tags, "hidden"), "{tags:?}");
|
||||
}
|
||||
|
||||
/// A kind:9007 without an `h` tag is created by the side-effect handler, not
|
||||
/// by the ingest pre-create. That path must give a system channel the same
|
||||
/// private default, so strangers can neither read nor write it.
|
||||
/// Mutation: default visibility to open in `handle_create_group` → RED.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn system_channel_without_h_tag_defaults_private() {
|
||||
let state = state().await;
|
||||
let tenant = community(&state, "group-state-system-no-h").await;
|
||||
let creator = Keys::generate();
|
||||
let stranger = Keys::generate();
|
||||
|
||||
let name = format!("system-no-h-{}", Uuid::new_v4().simple());
|
||||
let create = EventBuilder::new(Kind::Custom(9007), "")
|
||||
.tags([
|
||||
Tag::parse(["name", &name]).unwrap(),
|
||||
Tag::parse(["channel_type", "system"]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&creator)
|
||||
.expect("sign 9007");
|
||||
let result = ingest_event(&state, &tenant, create, http_auth(&creator))
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("create channel: {error:?}"));
|
||||
assert!(result.accepted, "{}", result.message);
|
||||
|
||||
let (channel_id, visibility): (Uuid, String) = sqlx::query_as(
|
||||
"SELECT id, visibility::text FROM channels WHERE community_id = $1 AND name = $2",
|
||||
)
|
||||
.bind(*tenant.community().as_uuid())
|
||||
.bind(&name)
|
||||
.fetch_one(state.db.pool())
|
||||
.await
|
||||
.expect("created channel");
|
||||
assert_eq!(visibility, "private");
|
||||
|
||||
let tags = group_state_tags(&state, &tenant, channel_id, 39000).await;
|
||||
assert!(has_marker(&tags, "private"), "{tags:?}");
|
||||
assert!(has_marker(&tags, "hidden"), "{tags:?}");
|
||||
assert!(!has_marker(&tags, "public"), "{tags:?}");
|
||||
|
||||
let stranger_bytes = stranger.public_key().to_bytes().to_vec();
|
||||
assert!(
|
||||
crate::handlers::ingest::check_channel_membership(
|
||||
&tenant,
|
||||
&state,
|
||||
channel_id,
|
||||
&stranger_bytes,
|
||||
None
|
||||
)
|
||||
.await
|
||||
.is_err(),
|
||||
"a stranger must not pass the channel access check"
|
||||
);
|
||||
let readable = state
|
||||
.db
|
||||
.get_accessible_channel_ids(tenant.community(), &stranger_bytes)
|
||||
.await
|
||||
.expect("accessible channels");
|
||||
assert!(!readable.contains(&channel_id), "stranger can read it");
|
||||
|
||||
let message = EventBuilder::new(Kind::Custom(9), "hello")
|
||||
.tags([Tag::parse(["h", &channel_id.to_string()]).unwrap()])
|
||||
.sign_with_keys(&stranger)
|
||||
.unwrap();
|
||||
match ingest_event(&state, &tenant, message, http_auth(&stranger)).await {
|
||||
Ok(result) => assert!(
|
||||
!result.accepted,
|
||||
"stranger wrote to a private system channel: {}",
|
||||
result.message
|
||||
),
|
||||
Err(error) => assert!(format!("{error:?}").contains("restricted"), "{error:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3078,17 +3078,6 @@ async fn ingest_event_inner(
|
||||
|
||||
// Validate visibility/channel_type for ALL kind:9007 events (with or without h-tag).
|
||||
// This runs pre-storage so invalid enums are rejected before the event is persisted.
|
||||
let visibility_str = event
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|t| {
|
||||
if t.kind().to_string() == "visibility" {
|
||||
t.content().map(|s| s.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "open".to_string());
|
||||
let channel_type_str = event
|
||||
.tags
|
||||
.iter()
|
||||
@@ -3100,14 +3089,23 @@ async fn ingest_event_inner(
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "stream".to_string());
|
||||
|
||||
let visibility: buzz_db::channel::ChannelVisibility = visibility_str
|
||||
.parse()
|
||||
.map_err(|_| IngestError::Rejected(format!("invalid visibility: {visibility_str}")))?;
|
||||
let channel_type: buzz_db::channel::ChannelType =
|
||||
channel_type_str.parse().map_err(|_| {
|
||||
IngestError::Rejected(format!("invalid channel_type: {channel_type_str}"))
|
||||
})?;
|
||||
let visibility: buzz_db::channel::ChannelVisibility =
|
||||
match event.tags.iter().find_map(|t| {
|
||||
if t.kind().to_string() == "visibility" {
|
||||
t.content().map(|s| s.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
Some(visibility_str) => visibility_str.parse().map_err(|_| {
|
||||
IngestError::Rejected(format!("invalid visibility: {visibility_str}"))
|
||||
})?,
|
||||
None => channel_type.default_visibility(),
|
||||
};
|
||||
let labels = super::side_effects::channel_labels_from_tags(&event)
|
||||
.transpose()
|
||||
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?
|
||||
|
||||
@@ -1303,8 +1303,12 @@ pub async fn emit_group_discovery_events(
|
||||
// making channel visibility self-describing for clients.
|
||||
tags.push(Tag::parse(["public"])?);
|
||||
}
|
||||
// NIP-29 hidden tag: hint to clients not to show DMs in public group lists.
|
||||
// Not a security boundary — access control is handled by channel-scoped storage.
|
||||
// NIP-29 hidden tag: hide metadata from non-members. Set for DMs and
|
||||
// for private system channels. Not a security boundary — access
|
||||
// control is handled by channel-scoped storage.
|
||||
if channel.channel_type == "system" && channel.visibility == "private" {
|
||||
tags.push(Tag::parse(["hidden"])?);
|
||||
}
|
||||
if channel.channel_type == "dm" {
|
||||
tags.push(Tag::parse(["hidden"])?);
|
||||
// Include participant pubkeys in kind:39000 for DMs so clients can
|
||||
@@ -2046,17 +2050,18 @@ async fn handle_create_group(
|
||||
) -> anyhow::Result<()> {
|
||||
let name =
|
||||
extract_tag_value(event, "name").ok_or_else(|| anyhow::anyhow!("missing name tag"))?;
|
||||
let visibility_str =
|
||||
extract_tag_value(event, "visibility").unwrap_or_else(|| "open".to_string());
|
||||
let channel_type_str =
|
||||
extract_tag_value(event, "channel_type").unwrap_or_else(|| "stream".to_string());
|
||||
|
||||
let visibility: buzz_db::channel::ChannelVisibility = visibility_str
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("invalid visibility: {visibility_str}"))?;
|
||||
let channel_type: buzz_db::channel::ChannelType = channel_type_str
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("invalid channel_type: {channel_type_str}"))?;
|
||||
let visibility: buzz_db::channel::ChannelVisibility =
|
||||
match extract_tag_value(event, "visibility") {
|
||||
Some(visibility_str) => visibility_str
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("invalid visibility: {visibility_str}"))?,
|
||||
None => channel_type.default_visibility(),
|
||||
};
|
||||
|
||||
let actor_bytes = event.pubkey.to_bytes().to_vec();
|
||||
let description = extract_tag_value(event, "about");
|
||||
|
||||
@@ -2658,6 +2658,7 @@ fn emit_fleet_stock_metrics(snapshot: buzz_db::usage::FleetStockSnapshot) {
|
||||
("forum", snapshot.channels_forum),
|
||||
("dm", snapshot.channels_dm),
|
||||
("workflow", snapshot.channels_workflow),
|
||||
("system", snapshot.channels_system),
|
||||
] {
|
||||
metrics::gauge!("buzz_total_channels", "type" => kind).set(value as f64);
|
||||
}
|
||||
@@ -2801,7 +2802,7 @@ async fn emit_db_usage_metrics(
|
||||
// Zero-fill across all (community, channel_type) pairs so a type that
|
||||
// drops to zero emits 0 rather than retaining its last nonzero value.
|
||||
{
|
||||
const CHANNEL_TYPES: &[&str] = &["stream", "forum", "dm", "workflow"];
|
||||
const CHANNEL_TYPES: &[&str] = &["stream", "forum", "dm", "workflow", "system"];
|
||||
let rows: HashMap<(Uuid, &str), i64> = channel_rows
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
|
||||
@@ -24,12 +24,16 @@ plain NIP-29, a client cannot:
|
||||
A client also cannot mark its own channels so that it can find them again.
|
||||
Channel labels (below) do this.
|
||||
|
||||
Also, every channel that a person belongs to shows in their client as a chat.
|
||||
So a group that exists only to control who can read some data appears in
|
||||
sidebars, search results and unread counts. System channels (below) fix this.
|
||||
|
||||
## Channel identity tags
|
||||
|
||||
On every channel-state event that the relay signs, the relay MUST put these
|
||||
tags in this order, before any other `t` or `P` tag:
|
||||
|
||||
1. `["t", <channel type>]`, for example `stream`, `forum`, `dm`, `workflow`.
|
||||
1. `["t", <channel type>]`, for example `stream`, `forum`, `dm`, `workflow`, `system`.
|
||||
2. `["t", <label>]` for each channel label, in stored order (see
|
||||
[Channel labels](#channel-labels)).
|
||||
3. `["P", <creator public key, hex>]`.
|
||||
@@ -149,6 +153,19 @@ ID.
|
||||
Every reader that follows this rule picks the same channel, even after either
|
||||
channel is edited.
|
||||
|
||||
## System channels
|
||||
|
||||
A system channel exists only to control who can read some data, for example
|
||||
config that only an agent and its owner can read. It has the channel type
|
||||
`system`. In every other way it is an ordinary channel: it has members, roles,
|
||||
messages and labels.
|
||||
|
||||
**Create.** A kind:9007 with `["channel_type", "system"]` creates a system
|
||||
channel. It is private unless the kind:9007 also has `["visibility", "open"]`.
|
||||
|
||||
**Hidden.** While a system channel is private, its kind:39000 carries the
|
||||
NIP-29 `hidden` tag.
|
||||
|
||||
## Trust
|
||||
|
||||
A client that relies on these tags MUST check that the relay's key signed the
|
||||
|
||||
10
migrations/0062_system_channel_type.sql
Normal file
10
migrations/0062_system_channel_type.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- System channels: channels that exist only for access control, for example
|
||||
-- to hold config that only an agent and its owner can read. They are ordinary
|
||||
-- channels in every other way. The relay leaves them out of queries unless a
|
||||
-- filter names the channel or asks for system channels with #t:["system"]
|
||||
-- on kinds 39000-39003.
|
||||
--
|
||||
-- Additive. Do not roll the relay back past this change while system
|
||||
-- channels exist: an older relay cannot read the value and does not exclude
|
||||
-- these channels.
|
||||
ALTER TYPE channel_type ADD VALUE IF NOT EXISTS 'system';
|
||||
@@ -1,3 +1,3 @@
|
||||
-- ── Custom types ──────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TYPE channel_type AS ENUM ('stream', 'forum', 'dm', 'workflow');
|
||||
CREATE TYPE channel_type AS ENUM ('stream', 'forum', 'dm', 'workflow', 'system');
|
||||
|
||||
Reference in New Issue
Block a user