mirror of
https://github.com/block/buzz.git
synced 2026-11-02 07:22:55 +00:00
feat(relay): add NIP-37 draft wrap support (kind:31234)
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>
This commit is contained in:
committed by
Will Pfleger
parent
458c7f9154
commit
79a9a89a4d
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@@ -610,6 +610,15 @@ 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): write-path
|
||||
# validation, NIP-01 replacement/tombstone ordering, author-only privacy
|
||||
# across all read paths (WS REQ, WS COUNT, HTTP /query, /count, live
|
||||
# fan-out), known-d privacy tripwires, FTS/NIP-50 exclusion, 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: Upload relay log
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
|
||||
@@ -101,16 +101,31 @@ 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
|
||||
/// no channel scope (`channel_id = NULL`); compose context (channel, 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;
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -542,7 +542,7 @@ mod tests {
|
||||
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
|
||||
migrations.sort_by_key(|migration| migration.version);
|
||||
|
||||
assert_eq!(migrations.len(), 11);
|
||||
assert_eq!(migrations.len(), 12);
|
||||
assert_eq!(migrations[0].version, 1);
|
||||
assert_eq!(&*migrations[0].description, "initial schema");
|
||||
assert!(migrations[0]
|
||||
@@ -710,6 +710,24 @@ mod tests {
|
||||
.contains("CREATE OR REPLACE FUNCTION purge_soft_deleted_nip_rs"));
|
||||
assert!(migrations[10].sql.as_str().contains("tag->>0 = 'd'"));
|
||||
assert!(migrations[10].sql.as_str().contains(") = 1"));
|
||||
|
||||
// NIP-37 (kind 31234) FTS exclusion: additive conditional migration.
|
||||
// On legacy-blocklist DBs: drops and re-adds search_tsv with 31234.
|
||||
// On fresh-install allowlist DBs: no-op (31234 already unsearchable).
|
||||
// 0001 must NOT carry 31234; migration 12 must carry it separately.
|
||||
assert_eq!(migrations[11].version, 12);
|
||||
assert!(
|
||||
migrations[11].sql.as_str().contains("search_tsv"),
|
||||
"migration 0012 must reference the search_tsv generated column"
|
||||
);
|
||||
assert!(
|
||||
migrations[11].sql.as_str().contains("31234"),
|
||||
"migration 0012 must add kind 31234 to the FTS exclusion list"
|
||||
);
|
||||
assert!(
|
||||
!migrations[0].sql.as_str().contains("31234"),
|
||||
"kind 31234 must not be folded into the initial migration"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -38,12 +38,13 @@ fn bounded_kind_label(kind: u32) -> String {
|
||||
8000..=8003 | 9000..=9022 | 9030..=9036 => kind.to_string(),
|
||||
13534..=13535 => kind.to_string(),
|
||||
20000..=29999 => kind.to_string(),
|
||||
30023 | 30315 | 39000..=39003 => kind.to_string(),
|
||||
30023 | 30078 | 30174 | 30175..=30177 | 30300 | 30315 | 31234 | 39000..=39003 => {
|
||||
kind.to_string()
|
||||
}
|
||||
40002..=40100 => kind.to_string(),
|
||||
41001 | 41010..=41012 => kind.to_string(),
|
||||
43001..=43006 => kind.to_string(),
|
||||
44100..=44101 => kind.to_string(),
|
||||
44200 => kind.to_string(),
|
||||
44100..=44101 | 44200 => kind.to_string(),
|
||||
45001..=45003 => kind.to_string(),
|
||||
46001..=46012 | 46020 | 46030..=46031 => kind.to_string(),
|
||||
48001 | 48100..=48103 | 48106 => kind.to_string(),
|
||||
@@ -506,6 +507,11 @@ async fn dispatch_persistent_event_inner(
|
||||
&& !buzz_core::kind::is_command_kind(kind_u32)
|
||||
&& !is_relay_workflow_msg
|
||||
&& kind_u32 != KIND_GIFT_WRAP
|
||||
// Author-only kinds (NIP-ER reminders, NIP-37 draft wraps) are private per-user
|
||||
// state that must not trigger workspace-level workflows. Excluded here explicitly
|
||||
// because today's channel-less engine no-ops on them, but this guard is the
|
||||
// invariant that must hold for any future workflow expansion.
|
||||
&& !AUTHOR_ONLY_KINDS.contains(&kind_u32)
|
||||
{
|
||||
let workflow_engine = Arc::clone(&state.workflow_engine);
|
||||
let workflow_event = stored_event.clone();
|
||||
|
||||
@@ -15,18 +15,18 @@ use buzz_core::kind::{
|
||||
is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_AGENT_TURN_METRIC,
|
||||
KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET,
|
||||
KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN,
|
||||
KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_EVENT_REMINDER, KIND_FOLLOW_SET, KIND_FORUM_COMMENT,
|
||||
KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, KIND_GIT_ISSUE, KIND_GIT_PATCH,
|
||||
KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE,
|
||||
KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN,
|
||||
KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED,
|
||||
KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, KIND_IA_ARCHIVE_REQUEST,
|
||||
KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, KIND_MEMBER_ADDED_NOTIFICATION,
|
||||
KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MESH_LLM_RELAY_STATUS, KIND_MODERATION_BAN,
|
||||
KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN,
|
||||
KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT,
|
||||
KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST,
|
||||
KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER,
|
||||
KIND_DRAFT, KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_EVENT_REMINDER, KIND_FOLLOW_SET,
|
||||
KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, KIND_GIT_ISSUE,
|
||||
KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT,
|
||||
KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED,
|
||||
KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES,
|
||||
KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED,
|
||||
KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT,
|
||||
KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MESH_LLM_RELAY_STATUS,
|
||||
KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT,
|
||||
KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP,
|
||||
KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA,
|
||||
KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER,
|
||||
KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST,
|
||||
KIND_PRESENCE_UPDATE, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_REPORT,
|
||||
KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF,
|
||||
@@ -157,6 +157,8 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
|
||||
| KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT => {
|
||||
Ok(Scope::UsersWrite)
|
||||
}
|
||||
// NIP-37: draft wraps are author-private global state (UsersWrite scope).
|
||||
KIND_DRAFT => Ok(Scope::UsersWrite),
|
||||
// NIP-AM: agent turn metrics are agent-authored global events (encrypted to owner).
|
||||
KIND_AGENT_TURN_METRIC => Ok(Scope::MessagesWrite),
|
||||
// NIP-56 reports are ordinary member writes into the mod-only queue.
|
||||
@@ -402,6 +404,11 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool {
|
||||
// NIP-AM: agent turn metrics are owner-scoped global events.
|
||||
// Channel identity is encrypted inside the payload — no `h` tag.
|
||||
| KIND_AGENT_TURN_METRIC
|
||||
// NIP-37: draft wraps are author-private global events.
|
||||
// Compose context (channel, DM, reply target) is encrypted inside
|
||||
// the payload — no outer `h` tag. A stray `h` tag is rejected at
|
||||
// validation time (see `validate_draft_wrap_envelope`).
|
||||
| KIND_DRAFT
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1192,6 +1199,147 @@ fn validate_not_before(tag_value: &str) -> Result<u64, &'static str> {
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// Validate the public envelope of a NIP-37 `kind:31234` draft wrap before it
|
||||
/// reaches NIP-33 parameterized replacement.
|
||||
///
|
||||
/// The relay cannot decrypt or verify the payload; it enforces the mandatory outer
|
||||
/// tag shape so a malformed event cannot win replacement against a valid head:
|
||||
///
|
||||
/// 1. Exactly one non-empty `d` tag within `D_TAG_MAX_LEN`. Grammar is unrestricted
|
||||
/// (NIP-37 does not prescribe it); the relay accepts any non-empty opaque identifier.
|
||||
/// 2. Exactly one `k` tag with a canonical ASCII-decimal inner kind value in the
|
||||
/// unsigned 16-bit range (0..=65535) with no leading zeros (except bare "0").
|
||||
/// 3. No outer `h` tag — compose context is encrypted inside the payload.
|
||||
/// 4. No outer `p` tag — prevents this event from entering the mention/feed index.
|
||||
/// 5. Non-empty content must be a syntactically plausible NIP-44 v2 ciphertext
|
||||
/// (reuses `validate_engram_nip44_content`). Empty content is the NIP-37
|
||||
/// deletion tombstone and is explicitly valid.
|
||||
/// 6. At most one `expiration` tag, whose value must be canonical ASCII decimal,
|
||||
/// strictly greater than both `event.created_at` and the relay's current time.
|
||||
/// If present, it must be in the safe-integer range for JSON interoperability.
|
||||
fn validate_draft_wrap_envelope(event: &Event) -> Result<(), String> {
|
||||
let now_secs = chrono::Utc::now().timestamp() as u64;
|
||||
let event_created_at = event.created_at.as_secs();
|
||||
|
||||
let mut d_count = 0usize;
|
||||
let mut d_value: Option<&str> = None;
|
||||
let mut k_count = 0usize;
|
||||
let mut k_value: Option<&str> = None;
|
||||
let mut expiration_count = 0usize;
|
||||
let mut expiration_value: Option<&str> = None;
|
||||
|
||||
for tag in event.tags.iter() {
|
||||
let parts = tag.as_slice();
|
||||
if parts.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
match parts[0].as_str() {
|
||||
"d" => {
|
||||
d_count += 1;
|
||||
d_value = Some(&parts[1]);
|
||||
}
|
||||
"k" => {
|
||||
k_count += 1;
|
||||
k_value = Some(&parts[1]);
|
||||
}
|
||||
"h" => {
|
||||
return Err(
|
||||
"draft-wrap event must not have an `h` tag (compose context belongs inside the encrypted payload)".to_string(),
|
||||
);
|
||||
}
|
||||
"p" => {
|
||||
return Err(
|
||||
"draft-wrap event must not have a `p` tag (prevents mention/feed indexing)"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
"expiration" => {
|
||||
expiration_count += 1;
|
||||
expiration_value = Some(&parts[1]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate `d` tag.
|
||||
if d_count != 1 {
|
||||
return Err(format!(
|
||||
"draft-wrap event must have exactly one `d` tag (got {d_count})"
|
||||
));
|
||||
}
|
||||
let d = d_value.unwrap();
|
||||
if d.is_empty() {
|
||||
return Err("draft-wrap `d` tag must not be empty".to_string());
|
||||
}
|
||||
if d.len() > buzz_db::event::D_TAG_MAX_LEN {
|
||||
return Err(format!(
|
||||
"draft-wrap `d` tag too long ({} bytes, max {})",
|
||||
d.len(),
|
||||
buzz_db::event::D_TAG_MAX_LEN,
|
||||
));
|
||||
}
|
||||
|
||||
// Validate `k` tag — canonical decimal, fits u16, no leading zeros.
|
||||
if k_count != 1 {
|
||||
return Err(format!(
|
||||
"draft-wrap event must have exactly one `k` tag (got {k_count})"
|
||||
));
|
||||
}
|
||||
let k = k_value.unwrap();
|
||||
if k.is_empty() || !k.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err("draft-wrap `k` tag must be a canonical decimal integer".to_string());
|
||||
}
|
||||
if k.len() > 1 && k.starts_with('0') {
|
||||
return Err("draft-wrap `k` tag must not have leading zeros".to_string());
|
||||
}
|
||||
let _inner_kind: u16 = k.parse().map_err(|_| {
|
||||
"draft-wrap `k` tag value out of range (must fit unsigned 16-bit kind)".to_string()
|
||||
})?;
|
||||
|
||||
// Validate `expiration` tag (optional, at most one).
|
||||
if expiration_count > 1 {
|
||||
return Err(format!(
|
||||
"draft-wrap event must have at most one `expiration` tag (got {expiration_count})"
|
||||
));
|
||||
}
|
||||
if let Some(exp_str) = expiration_value {
|
||||
if exp_str.is_empty() || !exp_str.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(
|
||||
"draft-wrap `expiration` tag must be a canonical decimal Unix timestamp"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if exp_str.len() > 1 && exp_str.starts_with('0') {
|
||||
return Err("draft-wrap `expiration` tag must not have leading zeros".to_string());
|
||||
}
|
||||
const MAX_SAFE_INT: u64 = 9_007_199_254_740_991;
|
||||
let exp: u64 = exp_str.parse().map_err(|_| {
|
||||
"draft-wrap `expiration` tag value out of safe-integer range".to_string()
|
||||
})?;
|
||||
if exp > MAX_SAFE_INT {
|
||||
return Err(
|
||||
"draft-wrap `expiration` tag value exceeds JSON safe-integer limit".to_string(),
|
||||
);
|
||||
}
|
||||
if exp <= event_created_at {
|
||||
return Err(
|
||||
"draft-wrap `expiration` must be strictly after event `created_at`".to_string(),
|
||||
);
|
||||
}
|
||||
if exp <= now_secs {
|
||||
return Err("draft-wrap `expiration` must be in the future".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Validate content: either empty (tombstone) or NIP-44 v2 ciphertext.
|
||||
if !event.content.is_empty() {
|
||||
validate_engram_nip44_content(&event.content)
|
||||
.map_err(|e| e.replace("agent-engram", "draft-wrap"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate the public tag envelope of a NIP-ER `kind:30300` event before it
|
||||
/// reaches NIP-33 parameterized replacement.
|
||||
///
|
||||
@@ -1900,6 +2048,11 @@ async fn ingest_event_inner(
|
||||
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
|
||||
}
|
||||
|
||||
if kind_u32 == KIND_DRAFT {
|
||||
validate_draft_wrap_envelope(&event)
|
||||
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
|
||||
}
|
||||
|
||||
if kind_u32 == KIND_PERSONA {
|
||||
validate_persona_envelope(&event)
|
||||
.map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?;
|
||||
@@ -3386,4 +3539,261 @@ mod tests {
|
||||
// error comes from validate_engram_nip44_content with label replaced
|
||||
assert!(err.contains("agent-turn-metric"), "got: {err}");
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// validate_draft_wrap_envelope tests (kind:31234 / NIP-37)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn make_draft(tags: &[&[&str]], content: &str) -> Event {
|
||||
make_event_with_tags(KIND_DRAFT, content, tags)
|
||||
}
|
||||
|
||||
// ── acceptance ────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_accepts_ciphertext_content() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(&[&["d", &d], &["k", "9"]], &fake_nip44_v2());
|
||||
assert!(
|
||||
validate_draft_wrap_envelope(&ev).is_ok(),
|
||||
"canonical draft with ciphertext content must be accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_accepts_blank_tombstone() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(&[&["d", &d], &["k", "9"]], "");
|
||||
assert!(
|
||||
validate_draft_wrap_envelope(&ev).is_ok(),
|
||||
"tombstone (empty content) must be accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_accepts_various_valid_k_values() {
|
||||
for k in ["0", "1", "9", "1000", "30023", "65535"] {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(&[&["d", &d], &["k", k]], "");
|
||||
assert!(
|
||||
validate_draft_wrap_envelope(&ev).is_ok(),
|
||||
"k={k} must be accepted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── d-tag validation ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_rejects_missing_d_tag() {
|
||||
let ev = make_draft(&[&["k", "9"]], &fake_nip44_v2());
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("`d` tag"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_rejects_empty_d_tag() {
|
||||
let ev = make_draft(&[&["d", ""], &["k", "9"]], &fake_nip44_v2());
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("`d` tag"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_rejects_duplicate_d_tag() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(&[&["d", &d], &["d", &d], &["k", "9"]], &fake_nip44_v2());
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("`d` tag"), "got: {err}");
|
||||
}
|
||||
|
||||
// ── k-tag validation ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_rejects_missing_k_tag() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(&[&["d", &d]], &fake_nip44_v2());
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("`k` tag"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_rejects_duplicate_k_tag() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(&[&["d", &d], &["k", "9"], &["k", "9"]], &fake_nip44_v2());
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("`k` tag"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_rejects_k_tag_non_decimal() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(&[&["d", &d], &["k", "0x9"]], &fake_nip44_v2());
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("canonical decimal"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_rejects_k_tag_leading_zero() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(&[&["d", &d], &["k", "09"]], &fake_nip44_v2());
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("leading zero"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_rejects_k_tag_out_of_u16_range() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
// 65536 = u16::MAX + 1
|
||||
let ev = make_draft(&[&["d", &d], &["k", "65536"]], &fake_nip44_v2());
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("range"), "got: {err}");
|
||||
}
|
||||
|
||||
// ── h / p outer-tag exclusion ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_rejects_h_tag() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(
|
||||
&[
|
||||
&["d", &d],
|
||||
&["k", "9"],
|
||||
&["h", &uuid::Uuid::new_v4().to_string()],
|
||||
],
|
||||
&fake_nip44_v2(),
|
||||
);
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("`h` tag"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_rejects_p_tag() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
let keys = nostr::Keys::generate();
|
||||
let ev = make_draft(
|
||||
&[&["d", &d], &["k", "9"], &["p", &keys.public_key().to_hex()]],
|
||||
&fake_nip44_v2(),
|
||||
);
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("`p` tag"), "got: {err}");
|
||||
}
|
||||
|
||||
// ── content validation ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_rejects_non_base64_content() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(&[&["d", &d], &["k", "9"]], "not-a-ciphertext");
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(
|
||||
err.contains("base64") || err.contains("NIP-44"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_rejects_wrong_nip44_version_byte() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
// 132 chars of valid base64 but version byte decodes to 0x00, not 0x02.
|
||||
let bad = "A".repeat(132);
|
||||
let ev = make_draft(&[&["d", &d], &["k", "9"]], &bad);
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(
|
||||
err.contains("NIP-44 v2") || err.contains("0x02"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_rejects_short_ciphertext() {
|
||||
// 1-byte decoded (version prefix only, no payload) — too short.
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(&[&["d", &d], &["k", "9"]], "Ag==");
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("too short"), "got: {err}");
|
||||
}
|
||||
|
||||
// ── expiration tag validation ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_accepts_valid_future_expiration() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
// A timestamp far in the future (year 2100).
|
||||
let ev = make_draft(
|
||||
&[&["d", &d], &["k", "9"], &["expiration", "4102444800"]],
|
||||
"",
|
||||
);
|
||||
assert!(
|
||||
validate_draft_wrap_envelope(&ev).is_ok(),
|
||||
"valid future expiration must be accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_rejects_duplicate_expiration_tag() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(
|
||||
&[
|
||||
&["d", &d],
|
||||
&["k", "9"],
|
||||
&["expiration", "4102444800"],
|
||||
&["expiration", "4102444800"],
|
||||
],
|
||||
"",
|
||||
);
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("expiration"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_rejects_expiration_in_past() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(
|
||||
&[&["d", &d], &["k", "9"], &["expiration", "1000000000"]],
|
||||
"",
|
||||
);
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("expiration"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_rejects_non_decimal_expiration() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(
|
||||
&[&["d", &d], &["k", "9"], &["expiration", "not-a-number"]],
|
||||
"",
|
||||
);
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("expiration"), "got: {err}");
|
||||
}
|
||||
|
||||
// ── routing invariants ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_is_global_only() {
|
||||
// Draft wraps must never be channel-scoped; compose context lives in
|
||||
// the encrypted payload only.
|
||||
assert!(
|
||||
is_global_only_kind(KIND_DRAFT),
|
||||
"KIND_DRAFT must be global-only (no h-tag channel scope)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_requires_users_write_scope() {
|
||||
let dummy = make_dummy_event();
|
||||
assert_eq!(
|
||||
required_scope_for_kind(KIND_DRAFT, &dummy).unwrap(),
|
||||
Scope::UsersWrite,
|
||||
"KIND_DRAFT must require UsersWrite scope"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_does_not_require_h_tag() {
|
||||
assert!(
|
||||
!requires_h_channel_scope(KIND_DRAFT),
|
||||
"KIND_DRAFT must not require an h tag"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ use crate::config::DEFAULT_MAX_FRAME_BYTES;
|
||||
///
|
||||
/// NIP-43 (relay membership) is advertised separately by [`RelayInfo::build`]
|
||||
/// only when membership enforcement is actually enabled — see that function.
|
||||
pub(crate) const SUPPORTED_NIPS: &[u32] = &[1, 2, 10, 11, 16, 17, 23, 25, 29, 33, 38, 42, 50, 56];
|
||||
pub(crate) const SUPPORTED_NIPS: &[u32] =
|
||||
&[1, 2, 10, 11, 16, 17, 23, 25, 29, 33, 37, 38, 42, 50, 56];
|
||||
|
||||
/// NIP-43 (relay membership). Advertised only when the relay actually
|
||||
/// enforces membership (`BUZZ_REQUIRE_RELAY_MEMBERSHIP=true`) AND has a
|
||||
@@ -297,6 +298,24 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_nips_includes_nip37() {
|
||||
assert!(
|
||||
SUPPORTED_NIPS.contains(&37),
|
||||
"NIP-37 (draft wraps) must be advertised — kind:31234 ingest and author-only reads are live"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_nips_does_not_include_nip40() {
|
||||
// NIP-40 requires expired stored rows to be suppressed on read; Buzz does not
|
||||
// do this generically yet. Desktop must not rely on relay-side expiry suppression.
|
||||
assert!(
|
||||
!SUPPORTED_NIPS.contains(&40),
|
||||
"NIP-40 must NOT be advertised until expired stored rows are generically suppressed on read"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_advertises_buzz_repository_url() {
|
||||
let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None);
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
//! Run with a local PG: `BUZZ_TEST_DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz cargo test -p buzz-search --tests -- --include-ignored`
|
||||
//!
|
||||
//! Each test creates a uniquely-named schema, applies the full migration chain
|
||||
//! (0001 through 0008) into it, exercises a scenario, and drops it. Tests are
|
||||
//! (0001 through 0012) into it, exercises a scenario, and drops it. Tests are
|
||||
//! parallel-safe.
|
||||
|
||||
use buzz_core::{
|
||||
kind::{
|
||||
AUTHOR_ONLY_KINDS, KIND_AGENT_TURN_METRIC, KIND_MEMBER_ADDED_NOTIFICATION,
|
||||
AUTHOR_ONLY_KINDS, KIND_AGENT_TURN_METRIC, KIND_DRAFT, KIND_MEMBER_ADDED_NOTIFICATION,
|
||||
KIND_MEMBER_REMOVED_NOTIFICATION, P_GATED_KINDS,
|
||||
},
|
||||
CommunityId,
|
||||
@@ -27,6 +27,7 @@ const MIGRATION_0006_SQL: &str = include_str!("../../../migrations/0006_moderati
|
||||
const MIGRATION_0007_SQL: &str = include_str!("../../../migrations/0007_nip_rs_retention.sql");
|
||||
const MIGRATION_0008_SQL: &str =
|
||||
include_str!("../../../migrations/0008_fresh_install_search_allowlist.sql");
|
||||
const MIGRATION_0012_SQL: &str = include_str!("../../../migrations/0012_draft_wrap_fts.sql");
|
||||
|
||||
async fn setup() -> (PgPool, String) {
|
||||
let url = std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.to_string());
|
||||
@@ -77,6 +78,9 @@ async fn setup() -> (PgPool, String) {
|
||||
pool.execute(MIGRATION_0008_SQL)
|
||||
.await
|
||||
.expect("apply 0008 migration");
|
||||
pool.execute(MIGRATION_0012_SQL)
|
||||
.await
|
||||
.expect("apply 0012 migration");
|
||||
(pool, schema)
|
||||
}
|
||||
|
||||
|
||||
1213
crates/buzz-test-client/tests/e2e_nip37_draft.rs
Normal file
1213
crates/buzz-test-client/tests/e2e_nip37_draft.rs
Normal file
File diff suppressed because it is too large
Load Diff
37
migrations/0012_draft_wrap_fts.sql
Normal file
37
migrations/0012_draft_wrap_fts.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
-- Exclude kind 31234 (NIP-37 draft wraps) from full-text search.
|
||||
--
|
||||
-- NIP-37 draft wraps carry NIP-44-v2 ciphertext in `content` (or empty string
|
||||
-- for deletion tombstones). Indexing ciphertext would waste storage and violate
|
||||
-- the "channel/DM context lives only inside the encrypted payload" invariant
|
||||
-- that makes draft wraps author-private. The relay also must not index
|
||||
-- plaintext compose context through any search surface.
|
||||
--
|
||||
-- Additive migration: previously applied files must not change checksum.
|
||||
-- We must DROP the generated column and re-ADD it with the extended exclusion
|
||||
-- list; ALTER COLUMN cannot change a GENERATED expression in Postgres.
|
||||
--
|
||||
-- Final kind exclusion list after this migration:
|
||||
-- 1059 = KIND_GIFT_WRAP (NIP-17 ciphertext)
|
||||
-- 30300 = KIND_EVENT_REMINDER (AUTHOR_ONLY_KINDS — defense in depth)
|
||||
-- 30622 = KIND_DM_VISIBILITY (per-viewer private hide state)
|
||||
-- 31234 = KIND_DRAFT (NIP-37: AUTHOR_ONLY_KINDS — ciphertext or tombstone)
|
||||
-- 44100 = KIND_MEMBER_ADDED_NOTIFICATION (p-gated membership notice)
|
||||
-- 44101 = KIND_MEMBER_REMOVED_NOTIFICATION (p-gated membership notice)
|
||||
-- 44200 = KIND_AGENT_TURN_METRIC (NIP-AM: p-gated encrypted turn metrics)
|
||||
-- Constants kept in `buzz_core::kind`; inlined here because a sqlx migration
|
||||
-- is frozen SQL and cannot import the Rust constant. If a new privacy-sensitive
|
||||
-- kind is added there, add a new additive migration following this pattern and
|
||||
-- add a regression test in `buzz-search/tests/fts_integration.rs`.
|
||||
--
|
||||
-- NULL tsvector never matches `@@`, so excluded rows are storage-level
|
||||
-- unsearchable.
|
||||
|
||||
ALTER TABLE events DROP COLUMN search_tsv;
|
||||
ALTER TABLE events ADD COLUMN search_tsv TSVECTOR GENERATED ALWAYS AS (
|
||||
CASE WHEN kind IN (1059, 30300, 30622, 31234, 44100, 44101, 44200) THEN NULL::tsvector
|
||||
ELSE to_tsvector('simple', content)
|
||||
END
|
||||
) STORED;
|
||||
|
||||
-- Recreate the GIN index dropped with the column.
|
||||
CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv);
|
||||
@@ -206,9 +206,9 @@ CREATE TABLE events (
|
||||
-- Privacy: encrypted/private routing wrappers and p-gated membership notices
|
||||
-- must never be discoverable through NIP-50 full-text search. NULL tsvector
|
||||
-- never matches `@@`.
|
||||
-- Keep in sync with migrations (final state: 0001 + 0005_agent_turn_metric_fts).
|
||||
-- Keep in sync with migrations (final state: 0001 + 0005_agent_turn_metric_fts + 0006_moderation + 0007_draft_wrap_fts).
|
||||
search_tsv TSVECTOR GENERATED ALWAYS AS (
|
||||
CASE WHEN kind IN (1059, 30300, 30622, 44100, 44101, 44200) THEN NULL::tsvector
|
||||
CASE WHEN kind IN (1059, 30300, 30622, 31234, 44100, 44101, 44200) THEN NULL::tsvector
|
||||
ELSE to_tsvector('simple', content)
|
||||
END
|
||||
) STORED,
|
||||
|
||||
Reference in New Issue
Block a user