Skip to content

fix(relay): hide DMs from recipients until the first message arrives - #3790

Open
Maxwellimus wants to merge 1 commit into
mainfrom
maxwell/dm-defer-recipient-visibility
Open

fix(relay): hide DMs from recipients until the first message arrives#3790
Maxwellimus wants to merge 1 commit into
mainfrom
maxwell/dm-defer-recipient-visibility

Conversation

@Maxwellimus

Copy link
Copy Markdown

Problem

Clicking a person in search fires kind:41010 (DM open) immediately — before anything is typed. The relay created the DM with all participants visible, so the recipient's client received a kind:44100 member-added notification and NIP-29 discovery events (39000/39002) right away, and an empty DM window appeared on their end. The recipient should not see the DM until the first message arrives.

Fix (relay-side, no client changes)

New channel_members.hidden_pending column (migrations/0027_dm_hidden_pending.sql, also in schema/schema.sql):

  • Create (crates/buzz-db/src/dm.rs): non-creator DM members are inserted with hidden_at = NOW(), hidden_pending = TRUE. The creator stays visible. handle_dm_open / handle_dm_add_member now emit the create-time kind:44100 to the creator/actor only — 44100s are stored globally (channel_id = NULL), so notifying everyone leaked the empty DM. (dm_add_member mints a new channel — DM participant sets are immutable — so the same rule applies.)
  • Reveal (crates/buzz-relay/src/handlers/side_effects.rs, called from ingest and the workflow sink): the first real message in a DM — kind:9/40002 text, kind:40008 diff, or kind:48100 huddle ring (is_dm_revealing_kind) — runs a single race-free UPDATE … WHERE hidden_pending RETURNING pubkey, then invalidates membership caches and emits each revealed member's deferred kind:44100 with the message author as actor. Best-effort: a failed reveal never rejects the stored message.
  • Read scoping (crates/buzz-db/src/channel.rs): get_accessible_channel_ids (REQ/COUNT/HTTP-bridge scoping) excludes hidden_pending memberships, so pending recipients can't discover the channel via 39000/39002/44100 queries. Deliberately narrow: explicitly hidden DMs remain readable (clients filter those via the NIP-DV 30622 snapshot).
  • Sticky explicit hide: kind:41012 sets hidden_pending = FALSE, so a DM the recipient explicitly hid after the first message stays hidden through later messages. list_hidden_dms excludes pending rows so they never leak into the NIP-DV snapshot.
  • Moderation-notice DMs are unaffected: that path calls unhide_dm (clears both flags) right after open_dm.

Idempotent re-open of an existing DM early-returns before the member insert, so it never re-hides a recipient who has already seen the DM.

Review hardening (from adversarial Claude review + codex review of the change):

  • The reveal gate also accepts legacy kind:40002 stream messages, so an older client's first message still reveals the DM. Edits, pins, scheduled-message requests, and canvas writes deliberately don't reveal — none delivers a first message.
  • Codex found two first-message paths the original gate missed, both fixed: kind:40008 diff messages (rendered timeline content — an agent opening a review DM with a diff would have left the recipient hidden from a DM that has content), and relay-signed kind:9s inserted directly by the workflow sink (workflow_sink.rs bypasses ingest). The reveal now lives in a shared reveal_dm_members_on_first_message helper called from both ingest and the workflow sink. Scheduled messages deliver client-side through ingest, and huddle kind:48100 is creator-signed through ingest, so those paths were already covered; relay-signed kind:40099 system messages deliberately never reveal.
  • New is_visible_member check: the REQ/COUNT stale-cache repair path previously confirmed access via raw is_member, which would have let a pending recipient who learned the channel UUID out-of-band read the channel. The repair path now excludes hidden_pending rows (explicit hides still count as visible, matching get_accessible_channel_ids). Covered by a #h-probe assertion in the e2e test, verified red without the fix.

Known narrow edge, deferred: an ACP agent recipient replays history from its membership timestamp minus 5s of skew when the deferred kind:44100 arrives; a sender clock more than ~5s slow can stamp the first message below that floor and the agent misses it (relay ingest tolerates ±900s drift). Fixing this belongs in the ACP harness (unfloored first fetch for newly discovered DM channels) — follow-up, not this relay change.

Testing (TDD)

  • New e2e test test_dm_open_invisible_to_recipient_until_first_message (crates/buzz-test-client/tests/e2e_nostr_interop.rs), written first and failing on the unfixed relay at the exact leak (recipient received the 44100 for a message-less DM). Three phases: (1) after A opens a DM, B sees no 44100/39002/39000 for it and a #h REQ with the known channel id is CLOSED; (2) A's first message delivers the deferred 44100 live to B, the channel becomes discoverable, and the #h REQ is granted; (3) B's explicit kind:41012 hide survives a second message.
  • New e2e test test_dm_first_diff_message_reveals_recipient, also written first and failing at the missing reveal, covering the kind:40008-first path end to end. The workflow-sink call site is glue on the same helper this test exercises (a workflow-webhook e2e harness doesn't exist yet).
  • Full e2e_nostr_interop suite passes (27/27) against the fixed relay, including the pre-existing DM discovery and NIP-DV tests.
  • just test green; migrator test updated for migration 0027 (renumbered after 0026_replica_heartbeat landed on main). just ci green except one pre-existing mobile widget-test failure (ChannelDetailPage keeps follow mode off while a tall newest message stays visible) that reproduces on mobile code identical to main.
  • No UI changes: the desktop already keys DM appearance off the kind:44100 live subscription (desktop/src/features/channels/useMembershipNotifications.ts) and relay-provided channel lists, so deferring the relay events is sufficient — no screenshots needed.

🤖 Generated with Claude Code

Clicking a person in search fires kind:41010 (DM open) before anything is
typed, and the recipient immediately saw an empty DM window. Non-creator DM
members now start hidden_pending; the first kind:9/40002 message (or 48100
huddle ring) atomically reveals them, emits their deferred kind:44100, and
invalidates membership caches. Pending memberships are excluded from read
scoping and from the REQ/COUNT stale-cache repair path (new
is_visible_member), and an explicit kind:41012 hide stays sticky.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Max Lampert <maxwell@squareup.com>
@Maxwellimus
Maxwellimus requested a review from a team as a code owner July 30, 2026 19:06
@Maxwellimus

Copy link
Copy Markdown
Author

🤖 CI note from Max's AI agent: the Desktop Smoke E2E (3) failure (messaging.spec.ts › opens a single-level thread panel with inline expansion, click timeout ×3 retries) is pre-existing on main — the same test fails identically on the latest main run and on the run for f48f3f05, this branch's base commit. This PR changes relay-side Rust only; the desktop smoke suite runs against the mocked Tauri bridge (no relay involved). All other checks pass.

@Chessing234

Copy link
Copy Markdown
Contributor

solid direction. can you keep the change behind a clear flag until the rollout checklist is done?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants