Skip to content

fix(dm): keep hidden DMs hidden across refetch via relay-signed visibility snapshot (NIP-DV)#857

Merged
wesbillman merged 3 commits into
mainfrom
brain/fix-dm-hide
Jun 9, 2026
Merged

fix(dm): keep hidden DMs hidden across refetch via relay-signed visibility snapshot (NIP-DV)#857
wesbillman merged 3 commits into
mainfrom
brain/fix-dm-hide

Conversation

@wesbillman

@wesbillman wesbillman commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Problem

Hiding a DM removed it from the list optimistically, but the next get_channels refetch rebuilt the DM list purely from kind:39002 membership and brought it back. Per-viewer hide state lives only in the relay DB (channel_members.hidden_at) and was never exposed as a queryable Nostr event, so the thin client had no way to learn it. Re-open (41010) carries no #h, so a pure-client filter couldn't be correct either.

Fix

A relay-signed, per-viewer parameterized-replaceable snapshot (kind:30622, NIP-DV):

  • Emitted on hide (41012) and re-open (41010 that clears hidden_at), recompute-and-replace so the latest event is the authoritative hidden set (d = viewer → unique per viewer).
  • Both client read paths (get_channels Rust + e2e bridge) filter on it.
  • Read path stays pure-Nostr/event-sourced, per-viewer correct, live across devices.

Privacy (owner-scoped, every surface)

  • Filter-level #p gate rejects queries for another viewer's snapshot (explicit kinds:[30622] → 403).
  • Result-level owner check (reader_authorized_for_event) at every delivery surface — HTTP /query, WebSocket historical, live fan-out, and NIP-50 search — closes the kindless-ids bypass (kindless ids:[…] → 200 + empty).
  • 30622 is excluded from the search index as defense in depth.

Review

Independently reviewed by @Pinky across three adversarial passes — three real CHANGE-level issues caught and fixed:

  1. Per-viewer snapshot collision (wrong replacement key) → replace_parameterized_event.
  2. ids privacy bypass → #p=self enforced for explicit-kind filters.
  3. NIP-50 search surface missing the result-level owner check → fixed + index exclusion.

Code/privacy shape is blessed.

Tests

  • Relay unit suite 273 pass, clippy/typecheck/biome clean, relay builds.
  • e2e crate compiles.

⚠️ Must-do before merge

  • Run the 5 live NIP-DV e2e against a real relay. They compile but have not been run locally yet due to a local Postgres PG17/PG18 data-dir/image mismatch. CI should exercise them; if CI doesn't run the #[ignore]d NIP-DV e2e, run them locally before merging. Tests: test_nipdv_hide_then_reopen_updates_snapshot, test_nipdv_two_viewers_independent_snapshots, test_nipdv_ws_req_rejects_third_party, test_nipdv_ids_query_rejects_third_party + test_nipdv_explicit_kind_query_forbidden_for_third_party, test_nipdv_search_rejects_third_party.

Docs

New docs/nips/NIP-DV.md — event format, client behavior, privacy + write-protection model. NIP says untrusted/multi-relay clients MUST verify NIP-11 self; notes that Sprout Desktop currently trusts its configured relay like NIP-IA (document-the-gap, not broadened scope).

@wesbillman wesbillman requested a review from a team as a code owner June 4, 2026 21:27
wesbillman and others added 2 commits June 8, 2026 11:49
…ility snapshot (NIP-DV)

Hiding a DM removed it from the list optimistically, but the next
`get_channels` refetch rebuilt the DM list purely from `kind:39002`
membership and brought it back. Per-viewer hide state lives only in the
relay DB (`channel_members.hidden_at`) and was never exposed as a
queryable Nostr event, so the thin client had no way to learn it.

Add a relay-signed, per-viewer parameterized-replaceable snapshot
(`kind:30622`, NIP-DV): emitted on hide (41012) and re-open (41010 that
clears `hidden_at`), recompute-and-replace so the latest event is the
authoritative hidden set. Both client read paths filter on it.

Privacy: snapshots are owner-scoped. A filter-level `#p` gate rejects
queries for another viewer's snapshot, and a result-level owner check
(`reader_authorized_for_event`) is applied at every delivery surface —
HTTP query, WebSocket historical, live fan-out, and NIP-50 search — to
close the kindless-`ids` bypass. 30622 is also excluded from the search
index as defense in depth.

Docs: new docs/nips/NIP-DV.md describes the event, client behavior, and
privacy/write-protection model.

Tests: relay unit suite green (273), incl. per-viewer independence,
ids/kindless-ids gating, and search-surface owner checks. Five live e2e
(hide->reopen, two-viewer independence, WS/REST/search third-party
rejection) are written and compile but are NOT yet run against a live
relay due to a local Postgres PG17/PG18 mismatch — running them is a
must-do before merge.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
…d updates

publish_dm_visibility_snapshot signed the replacement event with the default
current-second created_at. replace_parameterized_event rejects a same-second
replacement whose random event id sorts higher, so a hide -> re-open within one
wall-clock second could strand the stale hide snapshot ~50% of the time --
reintroducing the "hidden DMs come back" symptom under double-action timing.

Force created_at = max(now, prior + 1) for the viewer's (kind=30622, relay
pubkey, d=viewer) snapshot, mirroring the existing emit_addressable_discovery_event
guard. Add a same-second hide->reopen regression test (existing e2e tests sleep
multi-second and miss this window).

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
@wesbillman wesbillman force-pushed the brain/fix-dm-hide branch from d178b1c to be09981 Compare June 8, 2026 18:11
Two harness-only fixes; zero relay code changes:

1. Fix REST paths: helpers referenced /api/events, /api/query,
   /api/channels/... but the router serves /events, /query,
   /channels/... — and /api/dms does not exist at all (DM creation
   is kind:41010 only). Every NIP-DV e2e test 404'd before reaching
   any visibility logic. create_dm now posts a signed kind:41010 and
   parses the channel_id from the relay's response message.

2. Backdate create_dm's initial 41010 by 10s: a later re-open with
   identical tags in the same wall-clock second mints a byte-identical
   event id, which the relay correctly dedupes — making the
   hide->reopen tests fail spuriously. Real clients cannot hit this.

With these fixes all 8 live NIP-DV e2e tests pass against a real
relay (PG18 + Typesense), including the same-second hide->reopen
regression test for the monotonic snapshot fix.

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
@wesbillman wesbillman merged commit c38301b into main Jun 9, 2026
16 checks passed
@wesbillman wesbillman deleted the brain/fix-dm-hide branch June 9, 2026 17:23
tlongwell-block pushed a commit that referenced this pull request Jun 10, 2026
* origin/main:
  Fix post-compact handoff context for OpenAI providers (#931)
  chore(release): release version 0.3.15 (#936)
  fix: persona is source of truth at spawn + thread-depth conventions (#930)
  fix: skip avatar reconciliation for legacy agent records (#933)
  feat(desktop): add nest commit identity guidance with human sign-off (#929)
  feat: provider/model selection for personas and runtime-aware env injection (#794)
  fix: reconcile agent profile on startup when relay publish was missed (#921)
  Revamp first-run onboarding (#924)
  Update setup loading screen (#926)
  fix(dm): keep hidden DMs hidden across refetch via relay-signed visibility snapshot (NIP-DV) (#857)
  Maximize desktop window on launch (#925)
  feat: preview features (experiments settings UI) (#888)
  fix(updater): send no-cache header on update check to avoid stale manifest (#922)
  fix(desktop): refresh channel state after unarchive (#923)
  Add channel visibility & ephemeral TTL controls to manage sidebar (#911)
  ci(release): add Intel macOS (x86_64) DMG as a release target (#748)

Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>

# Conflicts:
#	desktop/src/features/sidebar/ui/AppSidebar.tsx
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