Files
buzz/crates
Matt Toohey 4e81ffe145 fix(db): refuse new members of an archived channel under the channel row lock
An auto-join could add a new community member to a default channel after
it was archived (jedwards27, second review on #8594). The auto-join lists
the effective default channels and `join_channel_as_self` checks the
archive state before the write, but `put_members` never read
`archived_at`, and nothing ordered an archive against the membership
write. An archive landing between the list and the insert still created
the row and emitted the kind:40099, 39000/39002 and 44100.

`put_members` and `put_members_with_event` now take the membership lock,
the shared TTL lock and then the channel row `FOR NO KEY UPDATE`, the
order a huddle join already uses, and refuse a target who is not an
active member with `AccessDenied("channel is archived")` while
`archived_at` is set. An auto-join that loses the race is logged and
counted as a failed channel, with no row and no side effects. Active
members keep their rows; only admission is refused.

The review suggested treating archive and unarchive as admission
changes so they take the membership lock. This fix locks the row
instead, because the row is what every archive writes: `archive_channel`,
a kind:9002 `archived` tag and the TTL reaper's single UPDATE, which
takes no advisory lock at all and so would still race under the
membership-lock version. It also leaves every archive writer unchanged.
The refusal is not scoped to self-joins: the only production callers are
kind:9000 (already refused on an archived channel at ingest), kind:9021
and the auto-join. No restore or import path re-adds members through
`put_members`, and no test relies on adding to an archived channel.

The cost is that a membership write now holds the channel row for its
transaction. Writers of that row (settings writes, huddle joins, TTL
refreshes, the reaper) wait for it, and reads do not.

NOSTR.md "Default channels" now says a channel archived or made private
while the join runs is one of the logged, unretried failures.

Tests:
- `put_member_waits_for_a_concurrent_archive` (buzz-db Postgres): an
  uncommitted reaper-style archive makes a join wait. Once the archive
  commits the join is refused with no row, and re-adding a removed member
  is refused too. In the other order, `archive_channel` waits for an
  admission that holds the locks, and the admitted member stays.
- `auto_join_skips_a_default_channel_archived_while_it_joins` (buzz-relay
  Postgres): a flagged channel is archived in a held transaction. The
  auto-join lists it, passes the join body's check, then waits in
  `put_member`. After the archive commits there is no membership row, no
  kind:39002 entry, no kind:44100 and no `member_joined` notice.
- Mutations: dropping the row lock from `lock_channel_for_member_put`
  fails both tests, and so does dropping the archived refusal from
  `put_members_on`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
2026-10-30 16:58:53 +11:00
..