feat(relay): add author-only-unless-shared read gate for kind 30175#2768
Merged
Conversation
Kind 30175 (persona) events carry a system prompt and respond_to_allowlist
that should not be visible to all community members as a side effect of
device sync. Add a shared-tag-gated read model:
- Events WITHOUT ["shared","true"] are author-only.
- Events WITH exactly ["shared","true"] are community-readable, enabling
the opt-in agent catalog ({kinds:[30175]} all-authors).
Unlike AUTHOR_ONLY_KINDS (30300/30350), persona filters from foreign readers
are legitimate (that IS the catalog query), so enforcement is per-event at
every read chokepoint rather than at the filter layer:
- REQ historical delivery: is_unshared_persona_event gate in main loop and
search lane silently omits foreign unshared events.
- Live fan-out: filter_fanout_by_access skips foreign connections when the
event is unshared.
- COUNT: filter_can_match_persona_shared_kinds forces the per-event fallback
path, preventing existence-leak via fast SQL count_events().
- ids-lookup: result-gated via the per-event check in the delivery loop.
- Ingest: validate_persona_envelope rejects malformed shared tags (wrong
value, duplicates) so no ambiguous heads can be stored.
The shared tag is a tag (not a content field) so toggling sharing does not
change content bytes or the drift/source_version hash (persona_content_hash)
used by persona sync.
No data migration: existing 30175 heads have no tag -> become author-only
on deploy. Device sync is unaffected (usePersonaSync.ts already filters
authors:[self]).
Changes:
- kind.rs: is_persona_shared_kind, is_unshared_persona_event,
persona_event_is_shared helpers + 8 unit tests; updated KIND_PERSONA doc
- ingest.rs: shared-tag validation in validate_persona_envelope + 6 tests
- req.rs: is_unshared_persona_event gate in REQ main loop + search lane;
filter_can_match_persona_shared_kinds helper
- event.rs: persona shared-read gate in filter_fanout_by_access
- count.rs: needs_persona_filtering flag forces fallback at both count sites
- e2e_persona.rs: 6 acceptance-criteria tests covering foreign isolation,
ids-lookup, COUNT fallback, live fan-out (including toggle), ingest
rejection, and mixed-kind filter non-leak
- NIP-AP.md: Relay Behavior section updated with shared-tag-gated access
model and updated Security considerations
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Apply the author-only-unless-shared read gate to the NIP-98 HTTP bridge
(/query, /count, search) and centralize the per-event visibility decision
into a single event_visible_to_reader helper callable from both WS and
HTTP adapters.
Bridge parity (CRITICAL):
- /query catchall: replaced the two-step is_author_only_event +
reader_authorized_for_event checks with event_visible_to_reader, which
also covers is_unshared_persona_event. A foreign authenticated caller
POSTing {kinds:[30175],authors:[victim]} or {ids:[unshared-id]} now
receives nothing.
- /count: added needs_persona_filtering to the fast-path guard (forces
fallback when the filter can match kind:30175) and replaced the two-step
checks in both fallback loops with event_visible_to_reader. Existence
leak via HTTP COUNT is closed on both the channel-scoped and unconstrained
paths.
- FTS search helper: replaced is_author_only_event with event_visible_to_reader
as defense-in-depth. Kind 30175 is not in the FTS allowlist today so the
branch is not reachable, but the check prevents a future allowlist change
from silently reopening the bypass (comment explains this at the site).
Structural centralization (IMPORTANT):
- Added event_visible_to_reader to handlers/req.rs, combining the three
per-event access predicates (is_author_only_event, is_unshared_persona_event,
reader_authorized_for_event) behind a single call. Both the WS and HTTP
adapters now call this helper at their result-visibility sites.
Test gaps closed (IMPORTANT):
- Reworked test_persona_live_fanout_shared_gate with explicit monotonic
created_at timestamps (t0 < t1 < t2) so NIP-33 head ordering is
deterministic, not dependent on event-id tie-break. Added self-query
assertions after each publish to verify the correct NIP-33 head is live,
and added a live-fan-out assertion that foreign receives nothing when the
shared head goes unshared.
- Added shared=x and missing-value wire-level rejection cases to
test_persona_ingest_shared_tag_validation (AC-5; previously only
shared=false and duplicate were tested at the wire level).
- Fixed test_persona_mixed_kind_filter_does_not_leak (AC-6): now publishes
a kind:9 event from the same author and asserts it IS returned while the
unshared persona is omitted. Previously the absence-only assertion would
pass if the whole mixed filter were dropped.
- Added two new HTTP bridge e2e tests:
test_persona_http_query_cross_author_gate: verifies /query returns only
shared persona to foreign, returns nothing for {ids:[unshared-id]}, and
returns the shared event for {ids:[shared-id]}.
test_persona_http_count_cross_author_gate: verifies /count returns 1
(shared only) to foreign and 3 (all) to author, on author-scoped and
wildcard filters.
NIP-AP.md (doc):
- Replaced aspirational every relay read chokepoint wording with an
enumerated list of gated surfaces including NIP-98 /query, /count, and
FTS/search with their exact enforcement mechanism named.
- Added Non-goal: side-band existence oracles note documenting the
adjudicated decision not to gate reaction/report/deletion target
resolution (leaks one bit of existence, never content; requires a known
event id unshared personas never hand out through any gated path).
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Task 1 — SQL visibility before LIMIT: add `persona_reader: Option<Vec<u8>>` to `EventQuery`. When set, `query_events` appends `AND (kind != 30175 OR pubkey = $reader OR tags @> '[["shared","true"]]')` before ORDER/LIMIT, preventing newer private personas from starving older shared ones off the candidate page. Wired from WS REQ, bridge /query, and both COUNT fallback paths (conditioned on filter_can_match_persona_shared_kinds to avoid the clause on non-persona queries). Two E2E tests added (WS REQ + /query limit-gate scenario) that fail on 312014d. Task 2 — Exact tag shape: tighten validate_persona_envelope to require parts.len() == 2 (was >= 2) so ["shared","true","extra"] is rejected at ingest. persona_event_is_shared independently fails closed on any non-exact shape (returns false for 3-element tags). Docstring updated. Unit tests in kind.rs (3-element and 1-element variants) and ingest.rs (3-element wire rejection). Wire-level E2E test added. Task 3 — Fix 3 deterministic E2E reds: - test_persona_live_fanout_shared_gate: replace t0=1_700_000_000 with now-relative timestamps (now-2, now-1, now) so ingest skew check passes. - test_persona_mixed_kind_filter_does_not_leak: add create_test_channel helper in e2e_persona.rs; kind:9 control event now carries h tag on an open- visibility channel so the foreign reader can access it. - test_persona_count_excludes_foreign_unshared: add RelayMessage::Count variant to buzz-ws-client parse_relay_message (new typed path replaces raw-string JSON parse that always fell into UnexpectedMessage). Task 4 — Single decision point: refactor event_visible_to_reader to take &[u8] only, deriving hex internally (no more caller-supplied inconsistent byte/hex pair). Replace the inlined 3-predicate sequences in WS REQ main loop, WS REQ search lane, and both WS COUNT fallback loops with the single helper. Remove now-unused viewer_hex variable and reader_pubkey_hex parameter from handle_search_req. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Parallel persona tests all publish kind:30175 events. The foreign live subscription in test_persona_live_fanout_shared_gate used an unscoped kind-only filter, so any concurrent test's 30175 tripped the leak-panic (false positive in the parallel Relay E2E job, CI run 30133257310). Scope the subscription to author_keys.public_key() — still a valid cross-author live fan-out test; scoping isolates it from the parallel suite. Also include event id and author pubkey in both leak-panic messages so any future failure identifies what was actually received. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
tlongwell-block
approved these changes
Jul 25, 2026
This was referenced Jul 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Kind 30175 persona sync events carry plaintext
system_promptandrespond_to_allowlist. This PR adds author-only-unless-shared read semantics: events without["shared","true"]are visible only to the author; events with that tag are community-readable.What changed
New read class (kind 30175)
Kind 30175 gets per-event gating at every relay read surface. The
sharedmarker is a tag, not a content field, so content bytes (which double as thesource_versiondrift basis) are not affected when toggling share state.event_visible_to_readerhelper (handlers/req.rs)Centralizes the three per-event access predicates —
is_author_only_event,is_unshared_persona_event,reader_authorized_for_event— into onepub(crate)fn callable from both WS and HTTP adapters. All result-visibility sites now call this single helper.NIP-98 HTTP bridge (
api/bridge.rs)POST /querycatchall: replaced the two-step author-only + result-gated checks withevent_visible_to_reader(now also covers the persona shared-gate).POST /count: addedneeds_persona_filteringto the fast-path guard (forces per-event fallback when filter can matchkind:30175) and replaced both fallback loops' individual checks withevent_visible_to_reader./searchbridge helper: replacedis_author_only_eventwithevent_visible_to_readeras defense-in-depth (30175 is not in the FTS allowlist today; comment at site explains the future-proofing intent).Ingest validation (
handlers/ingest.rs)validate_persona_enveloperejects malformedsharedtags: wrong value, missing value, duplicates. Accepts exactly["shared","true"]and tag-absent.Kind helpers (
buzz-core/src/kind.rs)is_persona_shared_kind,is_unshared_persona_event,filter_can_match_persona_shared_kinds.Tests (
e2e_persona.rs)8 unit tests in
kind.rs, 6 iningest.rs, 8 e2e tests total:test_persona_live_fanout_shared_gate: reworked with explicit monotoniccreated_attimestamps (t0 < t1 < t2) and per-step head assertions, eliminating the NIP-33 event-id tie-break race. Also asserts foreign live subscription receives nothing on shared→unshared transition.test_persona_ingest_shared_tag_validation: addedshared=xand missing-value wire-level rejection cases.test_persona_mixed_kind_filter_does_not_leak: publishes a kind-9 event and asserts it IS returned; absence-only assertion no longer sufficient.test_persona_http_query_cross_author_gate: NIP-98/querycross-author gate (authors filter, kindlessids— both blocked; sharedids— passes).test_persona_http_count_cross_author_gate: NIP-98/countcross-author gate (foreign sees 1/shared, author sees all, wildcard checked).NIP-AP.md
Replaced aspirational "every relay read chokepoint" wording with an enumerated list of gated surfaces including NIP-98
/query,/count, and FTS/search with their enforcement mechanism named. Added Non-goal note for side-band existence oracles (reaction/report/deletion target resolution).Existing tests
All pre-existing
e2e_personatests use{ids:[event_id]}or{authors:[self]}filters — author self-reads bypass the gate and are unaffected.Gates
just check✅ |just test-unit✅ |cargo test -p buzz-relay✅ (749 passed, 1 pre-existing failure indemo_join_forwarded_arm_round_trips_echo— flaky onmain, unrelated to this PR, verified red atorigin/mainbefore this branch)