Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
79a9a89
feat(relay): add NIP-37 draft wrap support (kind:31234)
Jul 11, 2026
49fce89
feat(relay): rework NIP-37 drafts to channel-bound contract
Jul 11, 2026
268ea02
fix(relay): harden NIP-37 draft-wrap contract
Jul 11, 2026
e3a0e25
test(relay): add non-canonical UUID h-tag rejection tests; drop dead …
Jul 11, 2026
6fcb8a7
test(relay): harden NIP-37 coverage matrix — non-vacuous tests, Clipp…
Jul 11, 2026
edd1608
test(relay): fix test_draft_rejected_p_tag — use third-party pubkey i…
Jul 11, 2026
1e82a57
fix(relay): block all e-tag deletion routes targeting kind:31234 drafts
Jul 11, 2026
ed274bd
fix(relay): exclude author-only kinds from channel-window path
Jul 12, 2026
5c3ac62
fix(relay): close write-path id-oracle and complete Q2-Q16 review fixes
Jul 12, 2026
4a7ea47
test(relay): add Q15 e2e oracle-closure tests for write-path draft id…
Jul 12, 2026
985c20f
fix(relay): address pass-1 review findings R1-R3
Jul 12, 2026
6dda6fd
test(relay): fix D2/D3 CI-failing e2e tests for draft HTTP query and …
Jul 12, 2026
6eb6ab1
test(search): serialize pgcrypto install to fix parallel-setup race
Jul 12, 2026
a633025
fix(relay): suppress expired drafts at read time with 30-day server TTL
wpfleger96 Jul 14, 2026
cf593a4
fix(relay): close channel-window bypass and fix e2e draft-expiry tests
wpfleger96 Jul 14, 2026
35bbf6f
fix(tests): widen e2e draft expiry margin and factor shared helper
wpfleger96 Jul 14, 2026
ea6c2fc
fix(db): make 0012 draft FTS migration conditional on live expression
wpfleger96 Jul 14, 2026
f31b405
fix(db): use deref instead of clone on Copy type DateTime<Utc>
wpfleger96 Jul 14, 2026
0530ab4
fix(db): use shape-based allowlist detection in migration 0012
wpfleger96 Jul 14, 2026
7edc3cf
test(search): add behavioral coverage for 0012 legacy-blocklist rewrite
wpfleger96 Jul 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,33 @@ jobs:
run: cargo test --profile ci -p buzz-test-client --test e2e_event_reminder -- --ignored
env:
RELAY_URL: ws://localhost:3000
- name: NIP-37 draft wrap e2e
# Feature e2e for NIP-37 draft wraps (kind:31234), channel-bound
# contract: h-tag validation, channel existence + membership gates,
# immutable channel binding, author-only privacy (WS REQ, WS COUNT,
# HTTP /query, /count, live fan-out), known-d privacy tripwires,
# FTS/NIP-50 exclusion, workflow exclusion, tenant confinement, and
# NIP-11 advertisement.
run: cargo test --profile ci -p buzz-test-client --test e2e_nip37_draft -- --ignored
env:
RELAY_URL: ws://localhost:3000
- name: NIP-37 draft wrap DB tests
# DB-layer tests for NIP-37 draft wraps: tenant confinement (A/B
# independent heads across communities), immutable channel-binding
# (sequential rebind → DraftChannelMismatch), and race-guard
# (concurrent writes → exactly one winner + one DraftChannelMismatch).
# These run directly against Postgres without a relay process.
run: cargo test --profile ci -p buzz-db -- draft --ignored
env:
DATABASE_URL: postgres://buzz:buzz_dev@localhost:5432/buzz
- name: buzz-search FTS integration tests
# Storage-layer FTS guarantee tests, including the NIP-37 draft exclusion:
# kind:31234 rows must have NULL search_tsv so they never surface in
# NIP-50 results regardless of content. These tests are #[ignore]
# because they require a live Postgres; the CI job already provisions one.
run: cargo test --profile ci -p buzz-search --tests -- --include-ignored
env:
BUZZ_TEST_DATABASE_URL: postgres://buzz:buzz_dev@localhost:5432/buzz
- name: Upload relay log
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
Expand Down
177 changes: 177 additions & 0 deletions crates/buzz-core/src/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use nostr::Filter;

use crate::event::StoredEvent;
use crate::kind::{event_kind_u32, DRAFT_MAX_TTL_SECS, KIND_DRAFT};

/// Returns `true` if the event matches any of the provided NIP-01 filters.
pub fn filters_match(filters: &[Filter], event: &StoredEvent) -> bool {
Expand Down Expand Up @@ -32,6 +33,71 @@ pub fn reader_authorized_for_event(event: &nostr::Event, reader_pubkey_hex: &str
.any(|t| t.content() == Some(reader_pubkey_hex))
}

/// Returns `true` if the event is an author-only kind and the requester is NOT
/// the author. Used as a per-event filter during historical delivery and fan-out
/// to silently omit unauthorized events from mixed-kind result sets.
pub fn is_author_only_event(event: &nostr::Event, requester_pubkey_bytes: &[u8]) -> bool {
let kind_u32 = crate::kind::event_kind_u32(event);
crate::kind::AUTHOR_ONLY_KINDS.contains(&kind_u32)
&& event.pubkey.to_bytes() != requester_pubkey_bytes
}

/// Returns `true` if a draft event should be suppressed at read time due to expiry.
///
/// Only applies to `KIND_DRAFT` events; all other kinds return `false`.
///
/// Effective expiry is `min(client_expiration_tag, created_at + DRAFT_MAX_TTL_SECS)`.
/// When no `expiration` tag is present the server ceiling (`created_at + 30d`) governs.
/// A client tag shorter than the 30-day ceiling is honoured; one longer is clamped to it.
/// Tombstones (empty-content drafts) follow the same rule — expiry is a property of the
/// event envelope, not its payload.
///
/// The `now` parameter is supplied by the caller so unit tests can inject an arbitrary
/// clock. Production call sites pass `nostr::Timestamp::now()`.
pub fn draft_expired(event: &nostr::Event, now: nostr::Timestamp) -> bool {
if event_kind_u32(event) != KIND_DRAFT {
return false;
}
let server_ceil = event
.created_at
.as_secs()
.saturating_add(DRAFT_MAX_TTL_SECS);
let effective_expiry = event
.tags
.iter()
.find_map(|t| {
if t.kind().to_string() == "expiration" {
t.content().and_then(|v| v.parse::<u64>().ok())
} else {
None
}
})
.map(|client_exp| client_exp.min(server_ceil))
.unwrap_or(server_ceil);
now.as_secs() >= effective_expiry
}

/// Canonical per-event read-authorization gate: combines `reader_authorized_for_event`
/// (p-gated/result-gated kinds), `is_author_only_event` (author-private kinds), and
/// `draft_expired` (time-based draft suppression) into a single predicate.
///
/// Every delivery surface — WS historical pull, WS fan-out, HTTP bridge (all
/// branches: feed, thread, search, catchall, channel-window), and COUNT fallback
/// — must pass each event through this function before serializing it to the wire.
/// Using one canonical gate instead of composing the predicates at each call site
/// prevents future read surfaces from accidentally omitting half the privacy model.
///
/// Returns `true` if `reader` MAY receive the event.
pub fn reader_can_receive_event(
event: &nostr::Event,
reader_pubkey_hex: &str,
reader_pubkey_bytes: &[u8],
) -> bool {
reader_authorized_for_event(event, reader_pubkey_hex)
&& !is_author_only_event(event, reader_pubkey_bytes)
&& !draft_expired(event, nostr::Timestamp::now())
}

fn filter_match_one(f: &Filter, ev: &StoredEvent) -> bool {
if let Some(kinds) = &f.kinds {
if !kinds.contains(&ev.event.kind) {
Expand Down Expand Up @@ -297,4 +363,115 @@ mod tests {
"the authoring agent must NOT be authorized to read its own metric event (owner-only)"
);
}

// --- draft_expired unit tests ---
//
// All tests inject an explicit `now` so they don't depend on the wall clock.
// Draft events use KIND_DRAFT (31234). Non-draft events are TextNote.

fn make_draft(keys: &Keys, created_at_secs: u64, expiration_tag: Option<u64>) -> nostr::Event {
use crate::kind::KIND_DRAFT;
let mut builder = EventBuilder::new(Kind::Custom(KIND_DRAFT as u16), "ciphertext")
.custom_created_at(Timestamp::from(created_at_secs));
if let Some(exp) = expiration_tag {
builder = builder.tags([Tag::parse(["expiration", &exp.to_string()]).unwrap()]);
}
builder.sign_with_keys(keys).expect("sign")
}

#[test]
fn draft_expired_tag_in_past_returns_true() {
let keys = Keys::generate();
let now = nostr::Timestamp::now();
// created 1 day ago, expiration 1 hour ago
let created = now.as_secs() - 86400;
let exp = now.as_secs() - 3600;
let draft = make_draft(&keys, created, Some(exp));
assert!(draft_expired(&draft, now), "expired tag must be suppressed");
}

#[test]
fn draft_expired_tag_in_future_returns_false() {
let keys = Keys::generate();
let now = nostr::Timestamp::now();
let created = now.as_secs() - 3600;
let exp = now.as_secs() + 86400; // expires tomorrow
let draft = make_draft(&keys, created, Some(exp));
assert!(!draft_expired(&draft, now), "future tag must be served");
}

#[test]
fn draft_expired_no_tag_over_30d_returns_true() {
use crate::kind::DRAFT_MAX_TTL_SECS;
let keys = Keys::generate();
let now = nostr::Timestamp::now();
// created 31 days ago, no expiration tag
let created = now.as_secs() - DRAFT_MAX_TTL_SECS - 86400;
let draft = make_draft(&keys, created, None);
assert!(
draft_expired(&draft, now),
"draft older than 30d with no tag must be suppressed"
);
}

#[test]
fn draft_expired_no_tag_under_30d_returns_false() {
let keys = Keys::generate();
let now = nostr::Timestamp::now();
// created 1 day ago, no expiration tag
let created = now.as_secs() - 86400;
let draft = make_draft(&keys, created, None);
assert!(
!draft_expired(&draft, now),
"draft under 30d with no tag must be served"
);
}

#[test]
fn draft_expired_tag_longer_than_30d_capped_at_ceiling() {
use crate::kind::DRAFT_MAX_TTL_SECS;
let keys = Keys::generate();
let now = nostr::Timestamp::now();
// created 31 days ago; client tag says 40 days — should be clamped to 30d ceiling
let created = now.as_secs() - DRAFT_MAX_TTL_SECS - 86400;
let long_exp = created + DRAFT_MAX_TTL_SECS + (10 * 86400); // created + 40d
let draft = make_draft(&keys, created, Some(long_exp));
assert!(
draft_expired(&draft, now),
"tag longer than 30d must be clamped to server ceiling — draft must be suppressed"
);
}

#[test]
fn draft_expired_non_draft_kind_never_suppressed() {
let keys = Keys::generate();
let now = nostr::Timestamp::now();
// TextNote with an expiration tag that is in the past — must NOT suppress
let past_exp = now.as_secs() - 3600;
let event = EventBuilder::new(Kind::TextNote, "hello")
.tags([Tag::parse(["expiration", &past_exp.to_string()]).unwrap()])
.sign_with_keys(&keys)
.expect("sign");
assert!(
!draft_expired(&event, now),
"non-draft kinds must never be suppressed by draft_expired"
);
}

#[test]
fn draft_expired_tombstone_follows_same_expiry_rule() {
use crate::kind::DRAFT_MAX_TTL_SECS;
let keys = Keys::generate();
let now = nostr::Timestamp::now();
// Tombstone (empty content) created 31 days ago — same rule as regular draft
let created = now.as_secs() - DRAFT_MAX_TTL_SECS - 86400;
let tombstone = EventBuilder::new(Kind::Custom(crate::kind::KIND_DRAFT as u16), "")
.custom_created_at(Timestamp::from(created))
.sign_with_keys(&keys)
.expect("sign");
assert!(
draft_expired(&tombstone, now),
"tombstone draft must be suppressed by the same expiry rule"
);
}
}
33 changes: 28 additions & 5 deletions crates/buzz-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,16 +101,39 @@ pub const KIND_AGENT_ENGRAM: u32 = 30174;
/// author-only (see [`AUTHOR_ONLY_KINDS`]). See `docs/nips/NIP-ER.md`.
pub const KIND_EVENT_REMINDER: u32 = 30300;

/// NIP-37: Draft wrap (parameterized replaceable, author-only).
///
/// Encrypted draft of an unsent message, addressed by `(pubkey, kind=31234, d_tag)`.
/// Content is NIP-44 v2 ciphertext (to self) containing an unsigned inner event,
/// or the empty string as a NIP-37 deletion tombstone. The outer envelope carries
/// one `h` UUID binding the draft to a Buzz channel or DM; compose context
/// (reply target, etc.) lives only inside the encrypted payload.
///
/// Reads are strictly author-only — see [`AUTHOR_ONLY_KINDS`] and the author-only
/// gate in the REQ/COUNT/fan-out handlers. Draft wraps are excluded from FTS
/// (`search_tsv = NULL`) and must not trigger workflow dispatch.
pub const KIND_DRAFT: u32 = 31234;

/// Server-owned maximum lifetime for draft events (30 days).
///
/// A draft without a client-supplied `expiration` tag is served for at most
/// this many seconds from `created_at`. A client tag shorter than the ceiling
/// takes precedence; one longer than it is clamped to it. See
/// `buzz_core::filter::draft_expired`.
pub const DRAFT_MAX_TTL_SECS: u64 = 30 * 24 * 3600;

/// Kinds whose stored events are readable only by their author.
///
/// The relay must never reveal the existence, count, tags, content, schedule,
/// or search matches of these events to anyone but the authenticated author.
/// Shared across the ingest write path (NIP-ER `not_before` validation) and the
/// read path (REQ/COUNT/subscription author-only filtering).
/// Shared across the ingest write path and the read path (REQ/COUNT/subscription
/// author-only filtering). Also drives the FTS null-tsvector tripwire in
/// `buzz-search/tests/fts_integration.rs` — every kind added here MUST also
/// appear in the `search_tsv` generated column exclusion list in
/// `schema/schema.sql` and the additive migration that introduced it.
///
/// Currently O(1) with a single entry. If this grows past ~4 kinds, convert to
/// a compile-time bitset or sorted array with binary search for hot-path use.
pub const AUTHOR_ONLY_KINDS: &[u32] = &[KIND_EVENT_REMINDER];
/// Sorted for binary-search readiness if the list grows.
pub const AUTHOR_ONLY_KINDS: &[u32] = &[KIND_EVENT_REMINDER, KIND_DRAFT];

/// Kinds that require a result-level read gate beyond the filter-layer
/// `#p` check: even a reader who knows an event id MUST match the event's
Expand Down
19 changes: 19 additions & 0 deletions crates/buzz-db/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,25 @@ pub enum DbError {
/// A stored timestamp value could not be interpreted.
#[error("invalid timestamp: {0}")]
InvalidTimestamp(i64),

/// A kind:31234 draft-wrap update tried to rebind to a different channel.
///
/// The `(community, author, 31234, d_tag)` address is already bound to a
/// channel UUID. Incoming updates must use the same `h` tag.
#[error(
"draft-wrap channel binding is immutable — `h` tag must match the existing head's channel"
)]
DraftChannelMismatch,

/// A kind:31234 draft-wrap was submitted without a channel binding.
///
/// Every draft wrap requires a `channel_id` (resolved from the `h` tag
/// by ingest). A `None` channel_id at the DB layer means the caller
/// bypassed the ingest validator and must be rejected.
#[error(
"draft-wrap channel_id is required — draft wraps must be bound to a channel via `h` tag"
)]
DraftChannelRequired,
}

/// Convenience alias for `Result<T, DbError>`.
Expand Down
Loading
Loading