The bridge top_level channel-window filter (handle_channel_window_filter)
passed all window rows to the response without an author-only guard. Since
next_cursor and has_more are computed at the DB layer before any in-memory
filtering, a bridge-level skip would still expose draft ids via the 39006
bounds overlay and leave has_more counts inflated by draft rows.
Fix: add an author_pubkey param to get_channel_window. When Some, the query
appends AND (e.kind NOT IN (30300, 31234) OR e.pubkey = $N), excluding
author-only kinds (KIND_DRAFT=31234, KIND_EVENT_REMINDER=30300) for rows
whose pubkey does not match the requester. This keeps the cursor, has_more,
and all overlay values computed against the already-restricted row set.
Pass Some(&pubkey_bytes) from handle_channel_window_filter via the new
pubkey_bytes parameter; internal / test callers pass None.
Tests: two new e2e tests in e2e_nip37_draft.rs:
- test_channel_window_draft_excluded_for_non_author: mixed kinds:[9,31234]
query by a channel member who is not the author must return zero draft
rows and zero draft ids anywhere in the response (rows, aux, overlays).
kind:9 positive control row must still be present.
- test_channel_window_draft_visible_to_author: the author sees their own
kind:31234 draft in the window, consistent with all other author-only
read paths.
Closes the last unguarded author-only read surface identified in code review
of PR #1757.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
A kind:5 e-tag deletion that resolved to a kind:31234 event was
accepted by validate_standard_deletion_event (which only checked
authorship, not target kind). After soft-delete the live head row was
gone, allowing a second write with a different channel h-tag to bypass
the immutable-binding invariant. That path was fixed in the previous
commit on this branch.
A second bypass existed via kind:9005 (channel admin delete): the 9005
branch in validate_admin_event resolved the e-tag target and authorized
the actor (including channel admins who do not own the draft), then
returned Ok(()). Post-storage side effects would soft-delete the head
row, re-opening the cross-channel rebind window.
Fix: in the kind:9005 branch of validate_admin_event (side_effects.rs),
immediately after target resolution, reject pre-storage if the target
kind is KIND_DRAFT. Authorship and agent-owner actors receive the
tombstone-guidance error (mirror of the kind:5 wording). All other
actors — including channel admins — receive the generic
"target event not found" response, byte-identical to the missing-target
branch, so the validator cannot act as a draft-existence oracle.
Add two new E2E regressions:
- test_nip09_kind9005_deletion_of_draft_is_rejected_and_binding_holds:
full bypass sequence (publish draft h=A -> kind:9005 e=draft rejected
-> head still live -> h=B rebind rejected), deterministic base/base+1
timestamps.
- test_nip09_kind9005_admin_deletion_of_draft_is_masked_as_not_found:
channel admin submits kind:9005 targeting a draft; verifies response
is exactly "target event not found" (oracle-masking tripwire).
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
EventBuilder silently drops p tags whose value equals the signer's own
pubkey (NIP self-tagging suppression, enabled by default). The test was
using owner.public_key().to_hex() as the p tag value, so the tag was
stripped before signing and the event arrived at the relay with no p tag
at all — the validator always saw a clean envelope and the rejection was
never exercised.
Fix: generate a separate Keys pair for the p tag value so it survives
EventBuilder's self-tag filter and reaches the relay's validator.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Fix all remaining quality gaps identified in the pre-Thufir review:
Clippy (FIX-11):
- Remove 17 needless borrows in e2e_nip37_draft.rs (auto-fixed)
- sort_by → sort_by_key in tie-break test (auto-fixed)
- while_let_loop → while let loop in removed-member fan-out test
- splitn(3, ':').next() → split(':').next() in ingest.rs NIP-09 guard
DB tests (CRITICAL-A-test, FIX-4, FIX-5):
- Add build_test_draft_at helper (explicit timestamp control)
- Add query_draft_head helper (reusable across tests)
- Expand draft_is_confined_to_its_community: full A/B lifecycle (insert →
query → replace → tombstone) with scoped head assertions after each step;
uses same d_tag in both communities to prove community_id is the real
isolation boundary
- Add draft_channel_binding_is_immutable_across_sequential_calls: sequential
rebind attempt on an already-bound address → DraftChannelMismatch; stored
head still v1 after failed rebind
- Add post-race head query to concurrent_different_channel_drafts_one_wins_one_loses:
assert exactly one live head bound to the winning channel after the race
E2E tests (CRITICAL-B-test, FIX-6, FIX-7, FIX-8, FIX-9, FIX-10):
- Add test_nip09_a_tag_deletion_of_draft_is_rejected: kind:5 a-tag targeting
31234:<pubkey>:<d> must be rejected; draft must still be live head after
- FIX-7: Expand workflow tripwire to evaluate the actual dispatch predicate
(is_workflow_execution_kind && is_command_kind && AUTHOR_ONLY_KINDS) for
kind:31234 (→ false) and kind:9 (→ true, positive control)
- FIX-8: DM test — replace silent return with panic! on missing channel_id;
use strictly increasing timestamps (base-2, base-1, base) to guarantee
deterministic ordering across v1/v2/tombstone
- FIX-6: test_draft_not_returned_in_kindless_channel_query — rewrite to use
attacker (not owner) as requester; the author-only gate strips drafts from
non-author queries, not from the author's own channel queries
- FIX-9: Removed-member live fan-out — use author(owner) subscription so the
probe event (owner draft) actually matches the filter and exercises the gate
- FIX-10: Rename test_draft_tenant_confinement_channel_from_different_community
→ inline note + pointer to the existing test_draft_rejected_nonexistent_channel_h_tag
(the old name was misleading; true cross-tenant confinement is the DB test)
CI (FIX-CI):
- Wire buzz-db NIP-37 draft Postgres tests to backend-integration job
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
validate_draft_wrap_envelope already had the parsed.to_string() != h guard
(hardening commit 2f27d7c). Add two explicit unit tests exercising:
- uppercase UUID form (parse-valid, non-canonical)
- simple 32-hex form without hyphens (parse-valid, non-canonical)
Both tests confirm the relay rejects these forms with a diagnostic message
mentioning 'lowercase', 'canonical', or 'UUID'.
Also removes the now-dead get_draft_head_channel_id function from
buzz-db (event.rs + lib.rs wrapper). The preflight call was replaced by
the atomic binding check inside replace_parameterized_event; no callers
remain outside buzz-db itself.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
- Move immutable-channel binding check inside replace_parameterized_event
under the advisory lock (atomically safe, race-proof). Remove the
preflight get_draft_head_channel_id call. Add DraftChannelMismatch
error variant. All other replace_parameterized_event callers pass None.
- Move validate_draft_wrap_envelope before channel extraction so that
structural failures (missing/duplicate/non-UUID h-tag, p-tag) report
via the right gate rather than the channel-scope gate.
- Require canonical lowercase-hyphenated UUID in h-tag validator
(parsed.to_string() == h); reject uppercase and simple-hex forms.
- Fix test_draft_same_second_tie_break: add per-candidate _tiebreak tag
to force distinct event hashes and non-empty candidate set.
- Replace two timing-prone kindless WS privacy tests with explicit
kinds=[0,31234] and kinds=[30023,31234] mixed-kinds tests.
- FTS test: use explicit kinds=[1,31234] search filter as author.
- excluded_kinds_are_storage_level_unsearchable: add kind:31234 row,
update event count and forbidden list.
- Add Postgres DB integration tests: draft_is_confined_to_its_community
(two-community tenant confinement) and
concurrent_different_channel_drafts_one_wins_one_loses (race guard).
- Add workflow-dispatch tripwire unit test in event.rs confirming
AUTHOR_ONLY_KINDS.contains(&KIND_DRAFT) at the guard seam.
- Add DM channel path test (kind:41010 draft acceptance/replacement/
tombstone) and removed-member read-denial test (historical REQ/COUNT
+ live fan-out denial after removal).
- Fix stale-write test to assert accepted:true result before head check.
- Update stale 'channel-less/global' docs in kind.rs, ingest.rs,
event.rs to reflect channel-bound reality.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Draft wraps (kind:31234) now require exactly one `h` UUID tag binding
them to a specific Buzz channel or DM. The relay enforces:
- Exactly one valid UUID `h` tag on every kind:31234 event
- Channel existence: the `h` UUID must resolve to a live channel
- Membership: author must be a member of that channel at write time
- Immutable binding: once a (author, d_tag) draft is written to
channel A, replacement events must carry the same h=A; rebinding
to a different channel is rejected at the ingest layer
The previous channel-less/global-state design is removed. Draft fan-out
already applied the author-only gate (AUTHOR_ONLY_KINDS); with channel_id
now non-NULL for kind:31234, the existing channel visibility/membership
filter in fan-out applies naturally with no additional changes.
E2E test suite rewritten for the channel-bound contract:
- h-tag validation: missing, duplicate, non-UUID, nonexistent channel
- Non-member author rejection + removed-member regression
- Immutable binding: rebind rejected, same-channel replacement accepted
- Author-only reads: WS REQ/COUNT, HTTP /query, /count, live fan-out
- known-#d privacy tripwires (exclusive and kindless)
- Tombstone head queryable by author, tombstone replaces live draft
- NIP-01 same-second tie-break (distinct candidates enforced)
- Stale write cannot supersede current head
- Workflow / channel kindless query exclusion
- Tenant confinement (alien channel rejected)
- FTS exclusion (NULL search_tsv confirmed)
- NIP-11 advertises NIP-37, not NIP-40
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Add kind:31234 as an author-only, channel-less, parameterized-replaceable
event kind for encrypted draft wraps per NIP-37.
Privacy enforcement spans every relay read path:
- WS REQ: AUTHOR_ONLY_KINDS gate closes the subscription with
restricted: for any requester who isn't the author
- WS COUNT: same gate applied before the count query executes
- HTTP bridge /query + /count: post-filter and guard use AUTHOR_ONLY_KINDS
- Live fan-out: AUTHOR_ONLY_KINDS check in dispatch_persistent_event_inner
prevents draft events from being pushed to non-author subscribers
- FTS (NIP-50): migration 0007 sets search_tsv = NULL for kind:31234,
making drafts storage-level unsearchable
Ingest validation (validate_draft_wrap_envelope):
- Exactly one non-empty d tag (any bounded value; relay is grammar-agnostic)
- Exactly one k tag with canonical u16 decimal (no leading zeros, fits u16)
- No h or p outer tags (compose context belongs in encrypted payload only)
- Content: empty string (tombstone) or NIP-44 v2 ciphertext shape check
- Optional expiration: at most one, decimal, strictly future, ≤ safe integer
NIP-11 now advertises NIP-37. NIP-40 is intentionally not advertised
because Buzz does not yet suppress expired rows on read.
Schema migration 0007 extends the search_tsv generated column exclusion
list with kind 31234.
New tests:
- 23 unit tests for validate_draft_wrap_envelope in ingest.rs covering
every acceptance and rejection path
- Comprehensive E2E test suite in e2e_nip37_draft.rs covering write
validation, NIP-01 replacement ordering, tombstone persistence,
author-only REQ/COUNT/HTTP, kindless/mixed filter privacy, known-d
privacy tripwires, live fan-out isolation, and NIP-11 advertisement
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>