NIP-37 expiration tags were validated at ingest then discarded — expired
drafts were served and counted indefinitely. This addresses R3 from Tyler's
team's retention review.
Adds draft_expired(event, now) to reader_can_receive_event: a KIND_DRAFT
event is suppressed when now >= min(expiration_tag, created_at + 30d).
Tombstones follow the same rule. Non-draft kinds are unaffected.
The COUNT fast-path (author_is_self SQL count_events()) cannot evaluate
per-event expiry, so filter_can_match_draft forces draft-matching COUNT
filters to the per-event fallback on all four fast-path sites (count.rs
×2, bridge.rs ×2). KIND_EVENT_REMINDER retains its fast-path — reminders
carry no expiry semantics.
All changes are rebase-safe against #1771: nothing in replace_parameterized_event
or schema is touched. NIP-40 advertisement stays absent until the physical
delete reaper (#1771 coupling) lands.
fts_integration.rs setup() applies migration 0001, which contains
CREATE EXTENSION IF NOT EXISTS pgcrypto. pg_extension is database-
global with a unique index on extname; IF NOT EXISTS is a check-then-
insert, not an atomic upsert. When 18 tests run in parallel, the first
wave all see the extension as absent, all attempt the insert, and all
but one hit SQLSTATE 23505 (duplicate key on pg_extension_name_index).
Fix: take a session-level pg_advisory_lock on the admin connection
before the extension create, release it after. This serializes the
install across all parallel test workers so exactly one does the real
CREATE; the rest treat it as a no-op on lock acquisition. The advisory
lock is released automatically when the session ends.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
D2: rewrite test_draft_not_returned_in_kindless_channel_http_query.
The HTTP bridge runs sensitive-kind gates unconditionally, so a kindless
h-tag filter triggers p_gated_filters_authorized and returns 403 for any
non-owner caller — this is fail-closed, pre-existing main behavior. The
test now asserts 403 for the kindless case (i), then uses a mixed
kinds:[9,31234] h-tag filter as the attacker (passes all three HTTP
gates) and asserts kind:9 appears while the draft is absent (ii),
proving reader_can_receive_event is live on the bridge per-event path.
D3: add #h ch_id tag to subscription filter in
test_draft_live_fanout_reaches_author_own_subscription. Drafts are
channel-bound events; fan_out_scoped's symmetric scoping invariant
means a global (no #h) sub never sees channel-scoped events by design.
Channel-scoping the filter routes the sub through channel_kind_index so
fan-out can match and filter_fanout_by_access passes the author through.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
R1 (IMPORTANT): Extract should_dispatch_workflow helper from inline if
condition in dispatch_persistent_event_inner. The Q7 test previously
duplicated the production predicate as a local closure; deleting the
AUTHOR_ONLY_KINDS guard from the real if block would leave it green.
Now dispatch_persistent_event_inner calls should_dispatch_workflow, and
the test calls the same function — no copy-paste divergence is possible.
Test gains kind:30300 + is_relay_workflow_msg=true controls.
R2 (IMPORTANT): Add test_reminder_target_reaction_oracle_closed e2e to
prove the author-only mask in derive_reaction_channel generalizes to
kind:30300 reminders (not only kind:31234 drafts). Corrects the
overclaiming comment at ingest.rs:4149-4154 which asserted the unit
tests exercised actor_can_reference_target directly; they only assert
constant membership, and the comment now says so explicitly with a
pointer to the e2e tests that provide behavioral coverage.
R3 (MINOR): Move draft timestamp to base+3 in
test_channel_window_cursor_boundary_excludes_draft so the draft is
inside the raw first page (raw DESC: msg2@+4, draft@+3, msg1@+2,
msg0@+0 — a filter-after-pagination regression would cursor off draft).
Replace the optional if-let cursor branch with cursor.expect(), forcing
the test to fail if has_more=false or no cursor is returned.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The previous Q15 unit tests only asserted AUTHOR_ONLY_KINDS membership;
deleting the guard from derive_reaction_channel or
resolve_nip10_thread_meta left every one green. Replace the vacuous
coverage with four `#[ignore]` e2e tests that exercise the live relay:
- test_draft_target_reaction_oracle_closed: attacker submits kind:7
reaction to (A) real draft id, (B) random nonexistent id; asserts
byte-identical error string and zero kind:7 rows stored.
- test_draft_target_thread_parent_oracle_closed: attacker submits kind:9
reply with draft id as NIP-10 parent vs random parent; asserts
byte-identical error, zero stored replies, and no 39005 fan-out.
- test_draft_target_public_reference_author_also_rejected: draft author
reacts to and replies-to their own draft; both must be rejected with the
masking not-found errors (public-reference paths reject everyone).
- test_draft_target_kind5_oracle_closed: attacker submits kind:5 e-tag
deletion targeting (A) real draft id, (B) random id; asserts
byte-identical masking error and draft still readable by author.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
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>