mirror of
https://github.com/block/buzz.git
synced 2026-11-04 16:31:38 +00:00
## Summary Adds **default channels**: relay owners and admins can flag channels that every new community member is automatically joined into. **Setting (contract)** - New `channels.auto_join` column (migration `0080`, constant default `false`, so no table rewrite). - kind:9002 accepts `["auto_join", "true"|"false"]`. Authorization comes from the actor's **relay** roster role (owner/admin), not a channel role, so the actor doesn't need to be a channel member. API tokens need `AdminChannels`. - Only open, unarchived `stream` channels qualify. This is checked under the channel row lock, so one event can open and flag a channel in one step, and a concurrent archive can't slip past the check. - kind:39000 carries a bare `auto_join` tag only while the setting is effective (on, open and unarchived). Every change posts one `auto_join_changed` kind:40099 notice. - SDK `build_update_channel(.., auto_join)`, `buzz channels update --auto-join <true|false>`, a NOSTR.md "Default channels" section, and a NIP-CL tag-table row. **Behaviour** - kind:9021's body is pulled out into `join_channel_as_self`, so auto-join uses the same path as a self-join: the channel's `join_role` applies, and it emits `member_joined` 40099, 39000/39002 and 44100. - `auto_join_default_channels` runs inline right after a member is added at three sites: the v2 invite claim, the v1 invite claim, and kind:9030 add. It does not run on login or reconnect, so a later kind:9022 leave stands. - Best effort: each failure is logged and counted (`buzz_auto_join_failures_total`) but never retried, because the roster membership has already committed. This deliberately departs from Review-Proven Rule 1 and is documented in NOSTR.md. `buzz-admin add-member` writes the roster row only. **Communities without a membership roster** (`BUZZ_REQUIRE_RELAY_MEMBERSHIP=false`) - These have no invite claim or kind:9030 to hook, so the three sites above join nobody there. Instead, after the AUTH `OK`, the relay joins a person to every effective default channel they have **never had a membership row in** (`auto_join_never_joined_channels`, spawned so admission never waits on it). - The channel's own membership history is the once-only record, so this adds no new table, column or migration. A kind:9022 leave or a kind:9001 removal keeps the row with `removed_at`, so the person stays out. A new device or another relay pod finds the row and does nothing. `put_first_member` checks the row again under the `put_member` locks, so two admissions racing each other join once. - A channel flagged later is joined on each person's next admission. A failed membership write leaves no row, so the next admission retries it. - Each channel's join holds the session's NIP-FI effect permit, so once the socket closes or its admission expires, no further channels are joined. - Agents are skipped: any socket with an admitted owner (a NIP-OA tag, a NIP-FI agent token, or a stored `agent_owner_pubkey`). - The kind:9021 side effects moved into `announce_self_join`, so both join paths emit the same `member_joined`, 39000/39002 and 44100. **Review follow-ups** - `get_accessible_channels` now selects `auto_join`. Without it, every call failed with `ColumnNotFound`. - The final authorization branch of the 9002 validator now refuses by default. `list_auto_join_channels` filters to `stream` channels itself. ## Testing - Postgres-lane journeys: - `edit_metadata_postgres_tests`: who may set `auto_join`, the 40099 notice, the effective-state tag, and the refusals. - `claim_auto_joins_default_channels_once`: v2 and v1 claims, and leave then re-claim. - `adding_a_member_auto_joins_default_channels`: kind:9030 add. - Removing the auto-join call at any one of the three sites fails the matching test (checked by mutation). - `root_auth_joins_people_to_default_channels_without_a_roster`: through `handle_auth` on a roster-less community. A person is joined, a NIP-OA agent is not, and a leave holds when the person is admitted again. Mutations checked: removing the spawn, and dropping the agent check. Each check waits for that admission's own auto-join outcome (a skip reports through the same test hook as a finished task) and fails if none arrives. A third mutation drops the agent check and delays the task 3s; it still fails. - `admission_auto_join_stops_when_the_session_ends`: closing the socket before the first or second channel stops the remaining joins; a later admission joins what was skipped. Mutation checked: dropping the effect permit. - `first_member_put_joins_once_and_never_undoes_a_leave` (buzz-db): the person is joined once with the channel's `join_role` (`guest` on an announce channel), a second put does nothing, after a leave the channel is neither listed nor joined, and two racing puts write exactly once. Mutations checked: removing the row check, removing the channel lock, and removing the `NOT EXISTS`. - e2e `test_invite_claim_auto_joins_default_channel` against a local relay. - Unit coverage in the SDK and CLI builders and `group_metadata_tags`. ## What got simpler The roster-less path adds no schema and no new concept: it reuses the channel's membership history as its record. It also shares the kind:9021 join code. It replaces the per-device client workarounds, [buzz-app#2029](https://github.com/block/buzz-app/pull/2029) and [buzz#8795](https://github.com/block/buzz/pull/8795), which are now closed. ## Related - Client half (buzz-app): https://github.com/block/buzz-app/pull/1624 ## Demo Recorded against a local relay built from this branch with throwaway identities (Alice = owner, Bob = new member); no real accounts or hosted relays involved. Relay at 141aa01d4, client at buzz-app bb0d97d86 (browser dev build). **Owner turns on "Starter channel for new members" on two channels.** Alice creates `#general` and `#welcome`, then flips the switch on each (~0:25 and ~0:41). The switch greys out while the kind 9002 is in flight and only turns on once the republished 39000 carries the `auto_join` tag. https://github.com/user-attachments/assets/e590d343-11d3-4385-af43-2cfaf19f8468 **A brand-new member joins and lands in a starter channel.** Bob claims Alice's invite with empty storage; from ~0:18 he is reading `#welcome` with both `#general` and `#welcome` in the sidebar, and at 0:27 opens `#general` as a member with no Join prompt. The relay logged one `auto-joined default channel` line per flagged channel. https://github.com/user-attachments/assets/037a0aaa-b25d-474d-a316-8d25e5e54f89 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Matt Toohey <contact@matttoohey.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com> Signed-off-by: tornquist <tornquist@squareup.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
307 lines
13 KiB
Markdown
307 lines
13 KiB
Markdown
NIP-CL
|
||
======
|
||
|
||
Channel Identity and Discovery
|
||
------------------------------
|
||
|
||
`draft` `optional` `relay`
|
||
|
||
This NIP adds identity facts to the relay-signed NIP-29 channel-state events
|
||
(kinds 39000–39002). With these facts, a client can find channels with
|
||
ordinary NIP-01 filters. It needs no new filter key and no relay-specific API.
|
||
|
||
## Motivation
|
||
|
||
Plugins and agents often create a channel for their own use and must find it
|
||
again later, for example "the config channel that this agent created". With
|
||
plain NIP-29, a client cannot:
|
||
|
||
- tell who created a channel;
|
||
- read the channel type from any event except kind:39000; or
|
||
- tell which of two channels is older. The `created_at` of a kind:39000 event
|
||
is the time of the last edit, not the time of creation.
|
||
|
||
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`, `system`.
|
||
2. `["t", <label>]` for each channel label, in stored order (see
|
||
[Channel labels](#channel-labels)).
|
||
3. `["P", <creator public key, hex>]`.
|
||
|
||
kind:39000 also carries `["created_at", <unix seconds>]`.
|
||
|
||
Example kind:39000 (signing fields and unrelated tags omitted):
|
||
|
||
```json
|
||
{
|
||
"kind": 39000,
|
||
"pubkey": "<relay public key>",
|
||
"created_at": 1760025600,
|
||
"tags": [
|
||
["d", "9b353519-f4fe-4757-aef4-bec6cc0ae54c"],
|
||
["name", "agent-config"],
|
||
["t", "stream"],
|
||
["t", "agent-config"],
|
||
["P", "<creator public key>"],
|
||
["created_at", "1759939200"]
|
||
]
|
||
}
|
||
```
|
||
|
||
| Tag | Kinds | Meaning |
|
||
| --- | --- | --- |
|
||
| first `t` | 39000–39002 | Channel type. |
|
||
| later `t` | 39000–39002 | Channel labels. |
|
||
| `P` | 39000–39002 | Public key that signed the kind:9007 that created the channel. |
|
||
| `created_at` | 39000 | Time the channel was created, in unix seconds, as a decimal string. |
|
||
| `auto_join` | 39000 | Present (bare, no value) while the relay joins new community members into this channel: the channel's `auto_join` setting is on and it is open and unarchived. |
|
||
|
||
**Channel type.** Clients MUST read the channel type from the first `t` tag.
|
||
Later `t` tags are labels.
|
||
|
||
**Creator.** The relay writes `P` from its own record of the kind:9007 that
|
||
created the channel. No client can set it. It does not change when ownership
|
||
moves to another member or when the channel is edited.
|
||
|
||
Uppercase `P` follows NIP-22, NIP-34 and NIP-72, where `P` names the author of
|
||
the root object. On these kinds, lowercase `p` already lists members, admins
|
||
and DM participants. A filter matches tag values, not their roles, so `#p`
|
||
would mix creators with members.
|
||
|
||
**Creation time.** The `created_at` tag does not change when the channel is
|
||
edited. Use it, not the event's `created_at`, to compare the age of channels.
|
||
|
||
**Older events.** A relay adds these tags only when it publishes a
|
||
channel-state event. An event that a relay version without this NIP published
|
||
can lack them, including one published during an upgrade. Such an event stays
|
||
until the channel next changes, for example on an edit or a membership
|
||
change. A client MUST treat a missing tag as unknown. A missing `P` does not
|
||
mean that a key did not create the channel.
|
||
|
||
## Channel labels
|
||
|
||
A label groups channels across channel types, for example `workspace` or
|
||
`agent-config`. Labels have no meaning to the relay.
|
||
|
||
**Set labels.** A kind:9007 (create group) MAY carry up to 8 distinct
|
||
labels as `["t", <label>]` tags. On kind:9002 (edit metadata):
|
||
|
||
- `t` tags replace the whole label set;
|
||
- `["t", ""]` tags and no other `t` tag clear the set;
|
||
- no `t` tag leaves the labels as they are.
|
||
|
||
Only an owner or admin of the channel MAY change its labels.
|
||
|
||
**Rules.** A label is 1–64 characters from `a-z`, `0-9`, `.`, `:` and `-`. A
|
||
label MUST NOT be `stream`, `forum`, `dm`, `workflow` or `system`. These are
|
||
channel type names, so this rule stops a label from making a channel look like
|
||
another type. The relay MUST reject the whole event if it has more than 8
|
||
distinct labels, an invalid label, a reserved name, or `["t", ""]` together
|
||
with other labels. The relay stores repeated labels once, so a repeat does
|
||
not count toward the 8, and repeated `["t", ""]` tags clear the set like one.
|
||
|
||
## Finding channels
|
||
|
||
Use ordinary NIP-01 filters on the channel-state kinds.
|
||
|
||
| To find | Filter |
|
||
| --- | --- |
|
||
| Channels that a key created | `{"kinds": [39000], "#P": ["<creator>"]}` |
|
||
| Channels with a label | `{"kinds": [39000], "#t": ["<label>"]}` |
|
||
| One creator's channels with a label | `{"kinds": [39000], "#P": ["<creator>"], "#t": ["<label>"]}` |
|
||
| Channels of one type | `{"kinds": [39000], "#t": ["<type>"]}` |
|
||
|
||
A `#P` filter does not return a channel whose events lack `P` (see
|
||
**Older events**). So it can miss channels that a key created before the relay
|
||
supported this NIP.
|
||
|
||
Values in one tag filter combine with OR, as in NIP-01. So
|
||
`"#t": ["workspace", "notes"]` finds channels with either label. To require a
|
||
label and a creator, use `#t` and `#P` together.
|
||
|
||
The relay MUST match `#t` and `#P` before it applies `limit`, as NIP-01
|
||
requires. A relay that reads the newest channel events first and matches tags
|
||
after can return a short or empty page while matching channels exist.
|
||
|
||
## Choosing one channel
|
||
|
||
The relay does not make labels unique. Many channels can share a label, and
|
||
any owner or admin can set any label. So two creates for the same label can
|
||
both succeed, for example when two processes of one agent start at the same
|
||
time.
|
||
|
||
A client that needs one channel per creator and label:
|
||
|
||
1. queries `{"kinds": [39000], "#P": ["<creator>"], "#t": ["<label>"]}`;
|
||
2. creates the channel only if the query returns nothing; and
|
||
3. when the query returns more than one channel, uses the oldest one.
|
||
|
||
The oldest channel has the lowest `created_at` tag value, compared as an
|
||
integer. If two channels have the same value, the lowest channel ID (`d` tag,
|
||
compared as a string) wins. A channel without a `created_at` tag ranks after
|
||
every channel that has one, and two channels without one compare by channel
|
||
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.
|
||
|
||
### Reads that leave system channels out
|
||
|
||
The relay leaves system channels out of every read that does not name them.
|
||
Clients that do not know about system channels then stay correct with no
|
||
change.
|
||
|
||
A client sees a channel only if it knows the channel's type. A read that does
|
||
not name a channel returns only the **legacy types**: `stream`, `forum`, `dm`
|
||
and `workflow`, the types that every client knew before this NIP. This list is
|
||
frozen. The relay leaves out every other type, so a channel type added later
|
||
is also hidden from clients that do not know it. A filter that names such a
|
||
channel (see below) reads it under the ordinary rules, and no other filter in
|
||
the same request gains it. The opt-in in "Asking for system channels"
|
||
(`#t:["system"]` or `#P`) adds system channels only, never a channel of
|
||
another type. Each new type needs its own way to ask for all its channels.
|
||
This applies to:
|
||
|
||
- a filter without `#h`, for example the channels or messages a reader can see;
|
||
- kind:39002 `#p:["<reader>"]`, the channels a reader belongs to;
|
||
- COUNT and NIP-50 search; and
|
||
- any relay-specific query API that does the same reads.
|
||
|
||
A filter **names** a channel when it has the channel ID in `#h`, or in `#d` on
|
||
a filter whose kinds are all 39000–39003. This is the same for a system
|
||
channel and for a type added later. Then the ordinary read rules apply: the
|
||
reader can read the channel if they are a member or if it is open.
|
||
A client that has the ID, for example from a link, can open the channel. Thus
|
||
a system channel stays an ordinary NIP-29 group.
|
||
|
||
The relay decides this for each filter. A filter that names a channel does
|
||
not add it to another filter in the same request.
|
||
|
||
An **unlisted** open channel (Buzz's `unlisted` setting, see NOSTR.md) is
|
||
named-only in the same way for readers who are not members, whatever its
|
||
type. Its members read it like any other channel. The system-channel opt-in
|
||
never adds it.
|
||
|
||
Push notifications (NIP-PL) use the same rule. A lease filter wakes its
|
||
author for an event in a system channel or a channel of a type added later
|
||
only when the filter names that channel. A lease filter cannot have `#t` or
|
||
`#P`, so here "names" means `#h`. The relay still checks that the author can
|
||
read the event, as for every other channel.
|
||
|
||
### Membership notices carry the channel type
|
||
|
||
Every kind:44100 (added) and kind:44101 (removed) notice has one more tag,
|
||
`["t", <channel_type>]`, for example `["t", "stream"]` or `["t", "system"]`.
|
||
The relay sets it from the channel type when it sends the notice. A channel's
|
||
type does not change. The relay stores the notice like any other 44100/44101.
|
||
|
||
A client sees a notice only if it knows the notice's channel type. Old
|
||
clients do not know types added later, such as `system`, and would show the
|
||
channel from such a notice. So the relay MUST apply this rule:
|
||
|
||
- **A filter with `#t`** matches a notice only when the notice's type is one
|
||
of the `#t` values.
|
||
- **A filter without `#t`** matches a notice only when its type is one of the
|
||
**legacy types**: `stream`, `forum`, `dm`, `workflow`. This list is fixed.
|
||
A type added later is never on it, so old clients never see its notices.
|
||
- **A notice without a `t` tag** was stored before this rule. Every such
|
||
notice is about a legacy type, so the relay treats it as all of them: it
|
||
matches a filter without `#t`, and a filter whose `#t` has a legacy type.
|
||
On these two kinds, this replaces the plain NIP-01 `#t` match.
|
||
|
||
The rule applies on every path: REQ history, live delivery, COUNT, and any
|
||
relay-specific query API. Existing clients do not send `#t` on these kinds,
|
||
so they see no change.
|
||
|
||
A client that knows every current type sends one filter:
|
||
|
||
```json
|
||
{"kinds": [44100, 44101], "#p": ["<me>"], "#t": ["stream", "forum", "dm", "workflow", "system"]}
|
||
```
|
||
|
||
When Buzz adds a type, a client adds its value to `#t` when it supports that
|
||
type.
|
||
|
||
The `#p` rule for 44100/44101 still applies: it must be the reader's own
|
||
pubkey. An `ids` filter does not lift it: the relay returns, counts or
|
||
delivers a 44100/44101 only to the pubkey in its `p` tag, even to a reader
|
||
who knows the event id.
|
||
|
||
Because the notices are stored, a client that reconnects can set `since` to
|
||
the newest notice it has seen. One subscription then returns the notices it
|
||
missed and the new ones, so no change falls between a read and a
|
||
subscription. A notice is a hint: the channel's group-state events decide
|
||
membership.
|
||
|
||
### Asking for system channels
|
||
|
||
A filter whose kinds are all 39000–39003 also returns the system channels that
|
||
the reader can read when it has:
|
||
|
||
- `"#t"` with the value `system`; or
|
||
- any `"#P"`. A query for the channels of named creators returns a small
|
||
result, so it does not fill generic channel lists.
|
||
|
||
The filter then matches as usual. A label alone does not ask for system
|
||
channels. These filters add only system channels. A channel of a type added
|
||
later reaches only a filter that names it.
|
||
|
||
| To find | Filter |
|
||
| --- | --- |
|
||
| My system channels | `{"kinds": [39002], "#p": ["<me>"], "#t": ["system"]}`, then their kind:39000 by `#d` |
|
||
| Open system channels | `{"kinds": [39000], "#t": ["system"]}` |
|
||
| One creator's channel with a label, system or not | `{"kinds": [39000], "#P": ["<creator>"], "#t": ["<label>"]}` |
|
||
|
||
`"#t": ["system", "<label>"]` means `system` **or** the label. It does not
|
||
mean "a system channel with this label". Use `#P` and the label instead.
|
||
|
||
Private system channels stay hidden from non-members, as for every private
|
||
channel.
|
||
|
||
## Advertising support
|
||
|
||
Until this draft has an upstream integer NIP number, relays MUST NOT advertise
|
||
it in NIP-11 `supported_nips`. A relay that supports this NIP adds `"nip-cl"`
|
||
to its NIP-11 `supported_extensions` string array.
|
||
|
||
A relay without this NIP drops label tags on create and refuses
|
||
`channel_type=system`. So a client SHOULD NOT create a channel with labels or
|
||
a system channel on a relay whose NIP-11 document does not contain `"nip-cl"`.
|
||
Lookups by label on such a relay find nothing, which is safe.
|
||
|
||
## Trust
|
||
|
||
A client that relies on these tags MUST check that the relay's key signed the
|
||
event, as for all NIP-29 group-state events.
|
||
|
||
Labels give no trust. Any owner or admin can set them. Only `P` says who
|
||
created a channel.
|
||
|
||
## Relation to other NIPs
|
||
|
||
- NIP-29: the group-state events that this NIP adds tags to.
|
||
- NIP-01: the filters that find channels.
|
||
- NIP-22, NIP-34 and NIP-72: the convention for uppercase `P`.
|