mirror of
https://github.com/block/buzz.git
synced 2026-11-02 15:31:27 +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>
65 lines
3.5 KiB
SQL
65 lines
3.5 KiB
SQL
-- ── Channels ──────────────────────────────────────────────────────────────────
|
|
-- Conformance: "Channels and channel membership". `community_id` immutable.
|
|
-- Channel UUIDs stay valid wire identifiers, but they are NOT globally unique:
|
|
-- the PK is `(community_id, id)`, so the same UUID may legitimately exist in two
|
|
-- communities (conformance lists "same channel UUID collision in two
|
|
-- communities" as a required isolation test). Handlers always carry `ctx`, so
|
|
-- `(ctx.community, h)` names exactly one channel; a client-supplied `h` can
|
|
-- never reach another community's channel.
|
|
|
|
CREATE TABLE channels (
|
|
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
|
community_id UUID NOT NULL REFERENCES communities(id),
|
|
name VARCHAR(255) NOT NULL,
|
|
channel_type channel_type NOT NULL DEFAULT 'stream',
|
|
visibility channel_visibility NOT NULL DEFAULT 'open',
|
|
description TEXT,
|
|
canvas TEXT,
|
|
created_by BYTEA NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
archived_at TIMESTAMPTZ,
|
|
deleted_at TIMESTAMPTZ,
|
|
nip29_group_id VARCHAR(255),
|
|
topic_required BOOLEAN NOT NULL DEFAULT FALSE,
|
|
max_members INT,
|
|
topic TEXT,
|
|
topic_set_by BYTEA,
|
|
topic_set_at TIMESTAMPTZ,
|
|
purpose TEXT,
|
|
purpose_set_by BYTEA,
|
|
purpose_set_at TIMESTAMPTZ,
|
|
participant_hash BYTEA,
|
|
ttl_seconds INT,
|
|
ttl_deadline TIMESTAMPTZ,
|
|
-- Labels, published as ["t", <label>] after the channel type tag on the
|
|
-- relay-signed group-state events. The relay validates their shape.
|
|
labels TEXT[] NOT NULL DEFAULT '{}'
|
|
CONSTRAINT channels_labels_bounded CHECK (cardinality(labels) <= 8),
|
|
join_role TEXT NOT NULL DEFAULT 'member'
|
|
CHECK (join_role IN ('member', 'guest')),
|
|
-- An unlisted open channel is readable and joinable by id but left out of
|
|
-- channel listings for non-members. Matters only while the channel is open.
|
|
unlisted BOOLEAN NOT NULL DEFAULT FALSE,
|
|
auto_join BOOLEAN NOT NULL DEFAULT false,
|
|
PRIMARY KEY (community_id, id),
|
|
CONSTRAINT chk_channels_id_not_nil CHECK (id <> '00000000-0000-0000-0000-000000000000'::uuid)
|
|
);
|
|
|
|
-- nip29 group id and DM participant hash are unique WITHIN a community, not globally.
|
|
CREATE UNIQUE INDEX idx_channels_nip29_group ON channels (community_id, nip29_group_id)
|
|
WHERE nip29_group_id IS NOT NULL;
|
|
CREATE UNIQUE INDEX idx_channels_dm_hash ON channels (community_id, participant_hash)
|
|
WHERE participant_hash IS NOT NULL;
|
|
CREATE INDEX idx_channels_community_type ON channels (community_id, channel_type);
|
|
CREATE INDEX idx_channels_community_visibility ON channels (community_id, visibility);
|
|
CREATE INDEX idx_channels_created_by ON channels (community_id, created_by);
|
|
CREATE INDEX idx_channels_ttl_expiry ON channels (ttl_deadline)
|
|
WHERE ttl_seconds IS NOT NULL AND archived_at IS NULL AND deleted_at IS NULL;
|
|
-- Tenant-independent channel-id → community lookups (Db::communities_of_channels,
|
|
-- Db::community_of_channel) carry no community_id predicate, so no
|
|
-- community_id-leading index can serve them. Covering + partial: index-only scan.
|
|
-- Not UNIQUE — the same channel id may exist under more than one community.
|
|
CREATE INDEX idx_channels_id_live ON channels (id) INCLUDE (community_id)
|
|
WHERE deleted_at IS NULL;
|