mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(relay): rework NIP-37 drafts to channel-bound contract
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>
This commit is contained in:
co-authored by
Will Pfleger
parent
79a9a89a4d
commit
49fce89952
@@ -611,11 +611,12 @@ jobs:
|
||||
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.
|
||||
# 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
|
||||
|
||||
@@ -915,6 +915,38 @@ pub async fn get_latest_global_replaceable(
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the `channel_id` of the current NIP-33 head for a kind:31234 draft address.
|
||||
///
|
||||
/// Used by the relay ingest handler to enforce immutable channel binding: if a
|
||||
/// head already exists for `(community, author, 31234, d_tag)`, its `channel_id`
|
||||
/// must match the incoming event's `channel_id`. A draft cannot be re-bound to a
|
||||
/// different channel.
|
||||
///
|
||||
/// Returns `Ok(Some(Some(uuid)))` if a live head exists with a channel_id,
|
||||
/// `Ok(Some(None))` if a head exists but has NULL channel_id (should not happen
|
||||
/// in practice but handled defensively), `Ok(None)` if no live head exists,
|
||||
/// `Err` on database failure.
|
||||
pub async fn get_draft_head_channel_id(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
pubkey_bytes: &[u8],
|
||||
d_tag: &str,
|
||||
) -> Result<Option<Option<Uuid>>> {
|
||||
let row: Option<(Option<uuid::Uuid>,)> = sqlx::query_as(
|
||||
"SELECT channel_id FROM events \
|
||||
WHERE community_id = $1 AND kind = 31234 AND pubkey = $2 AND d_tag = $3 \
|
||||
AND deleted_at IS NULL \
|
||||
ORDER BY created_at DESC, id ASC LIMIT 1",
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(pubkey_bytes)
|
||||
.bind(d_tag)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|(ch,)| ch))
|
||||
}
|
||||
|
||||
/// Fetches a single event by its raw 32-byte ID, **including soft-deleted rows**.
|
||||
///
|
||||
/// Most callers should use [`get_event_by_id`] instead. This variant is needed
|
||||
|
||||
@@ -721,6 +721,20 @@ impl Db {
|
||||
event::get_latest_global_replaceable(&self.pool, community_id, kind, pubkey_bytes).await
|
||||
}
|
||||
|
||||
/// Fetch the `channel_id` of the current NIP-33 head for a kind:31234 draft address.
|
||||
///
|
||||
/// Returns `Ok(Some(Some(uuid)))` if a live head exists with a channel_id,
|
||||
/// `Ok(Some(None))` if a live head exists without a channel_id (defensive),
|
||||
/// `Ok(None)` if no live head exists, `Err` on database failure.
|
||||
pub async fn get_draft_head_channel_id(
|
||||
&self,
|
||||
community_id: CommunityId,
|
||||
pubkey_bytes: &[u8],
|
||||
d_tag: &str,
|
||||
) -> Result<Option<Option<uuid::Uuid>>> {
|
||||
event::get_draft_head_channel_id(&self.pool, community_id, pubkey_bytes, d_tag).await
|
||||
}
|
||||
|
||||
/// Fetches a single non-deleted event by its raw ID bytes.
|
||||
///
|
||||
/// Returns `None` if the event does not exist or has been soft-deleted.
|
||||
|
||||
@@ -404,11 +404,6 @@ 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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -441,6 +436,12 @@ pub(crate) fn requires_h_channel_scope(kind: u32) -> bool {
|
||||
| KIND_HUDDLE_PARTICIPANT_LEFT
|
||||
| KIND_HUDDLE_ENDED
|
||||
| KIND_HUDDLE_GUIDELINES
|
||||
// NIP-37: draft wraps are channel-bound author-private events.
|
||||
// Each draft is scoped to a specific channel or DM; the `h` tag
|
||||
// carries the channel UUID. The relay resolves and validates the
|
||||
// channel, enforces membership, and persists the draft with
|
||||
// channel_id set.
|
||||
| KIND_DRAFT
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1209,8 +1210,11 @@ fn validate_not_before(tag_value: &str) -> Result<u64, &'static str> {
|
||||
/// (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.
|
||||
/// 3. Exactly one `h` tag with a canonical UUID value — the channel or DM this
|
||||
/// draft is bound to. The relay resolves and validates the channel separately;
|
||||
/// this check only validates the tag's syntactic shape.
|
||||
/// 4. No outer `p` tag — prevents this event from entering the mention/feed index.
|
||||
/// Recipient/reply/edit details remain encrypted inside the payload.
|
||||
/// 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.
|
||||
@@ -1225,6 +1229,8 @@ fn validate_draft_wrap_envelope(event: &Event) -> Result<(), String> {
|
||||
let mut d_value: Option<&str> = None;
|
||||
let mut k_count = 0usize;
|
||||
let mut k_value: Option<&str> = None;
|
||||
let mut h_count = 0usize;
|
||||
let mut h_value: Option<&str> = None;
|
||||
let mut expiration_count = 0usize;
|
||||
let mut expiration_value: Option<&str> = None;
|
||||
|
||||
@@ -1243,9 +1249,8 @@ fn validate_draft_wrap_envelope(event: &Event) -> Result<(), String> {
|
||||
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(),
|
||||
);
|
||||
h_count += 1;
|
||||
h_value = Some(&parts[1]);
|
||||
}
|
||||
"p" => {
|
||||
return Err(
|
||||
@@ -1296,6 +1301,20 @@ fn validate_draft_wrap_envelope(event: &Event) -> Result<(), String> {
|
||||
"draft-wrap `k` tag value out of range (must fit unsigned 16-bit kind)".to_string()
|
||||
})?;
|
||||
|
||||
// Validate `h` tag — required, exactly one, must be a well-formed UUID.
|
||||
if h_count != 1 {
|
||||
return Err(format!(
|
||||
"draft-wrap event must have exactly one `h` tag (got {h_count}); \
|
||||
draft wraps are channel-bound"
|
||||
));
|
||||
}
|
||||
let h = h_value.unwrap();
|
||||
if uuid::Uuid::parse_str(h).is_err() {
|
||||
return Err(format!(
|
||||
"draft-wrap `h` tag must be a canonical UUID (channel or DM id), got: {h:?}"
|
||||
));
|
||||
}
|
||||
|
||||
// Validate `expiration` tag (optional, at most one).
|
||||
if expiration_count > 1 {
|
||||
return Err(format!(
|
||||
@@ -2362,6 +2381,46 @@ async fn ingest_event_inner(
|
||||
buzz_db::event::D_TAG_MAX_LEN,
|
||||
)));
|
||||
}
|
||||
|
||||
// Immutable channel binding for kind:31234 (NIP-37 draft wraps).
|
||||
//
|
||||
// The NIP-33 address (community, author, 31234, d_tag) is unique, but
|
||||
// channel_id is NOT part of the NIP-33 key. This means a replacement
|
||||
// with a different h-tag would silently re-bind the draft to a new
|
||||
// channel while still winning the NIP-01 replacement. To prevent that,
|
||||
// we reject any incoming kind:31234 update whose channel_id differs
|
||||
// from the stored head's channel_id.
|
||||
//
|
||||
// A tombstone (empty content) carries the same h-tag as the draft it
|
||||
// closes — this check enforces that invariant too.
|
||||
if kind_u32 == KIND_DRAFT {
|
||||
let pubkey_bytes = auth.pubkey().to_bytes().to_vec();
|
||||
match state
|
||||
.db
|
||||
.get_draft_head_channel_id(tenant.community(), &pubkey_bytes, &d_tag)
|
||||
.await
|
||||
{
|
||||
Ok(Some(head_ch)) => {
|
||||
// A live head exists. Its channel_id must match the incoming event's.
|
||||
if head_ch != channel_id {
|
||||
return Err(IngestError::Rejected(
|
||||
"invalid: draft-wrap channel binding is immutable — \
|
||||
`h` tag must match the existing head's channel"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// No live head — this is the first write for this address.
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(IngestError::Internal(format!(
|
||||
"error: checking draft channel binding: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state
|
||||
.db
|
||||
.replace_parameterized_event(tenant.community(), &event, &d_tag, channel_id)
|
||||
@@ -3553,7 +3612,8 @@ mod tests {
|
||||
#[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());
|
||||
let ch = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(&[&["d", &d], &["k", "9"], &["h", &ch]], &fake_nip44_v2());
|
||||
assert!(
|
||||
validate_draft_wrap_envelope(&ev).is_ok(),
|
||||
"canonical draft with ciphertext content must be accepted"
|
||||
@@ -3563,7 +3623,8 @@ mod tests {
|
||||
#[test]
|
||||
fn draft_wrap_accepts_blank_tombstone() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(&[&["d", &d], &["k", "9"]], "");
|
||||
let ch = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(&[&["d", &d], &["k", "9"], &["h", &ch]], "");
|
||||
assert!(
|
||||
validate_draft_wrap_envelope(&ev).is_ok(),
|
||||
"tombstone (empty content) must be accepted"
|
||||
@@ -3574,7 +3635,8 @@ mod tests {
|
||||
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]], "");
|
||||
let ch = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(&[&["d", &d], &["k", k], &["h", &ch]], "");
|
||||
assert!(
|
||||
validate_draft_wrap_envelope(&ev).is_ok(),
|
||||
"k={k} must be accepted"
|
||||
@@ -3586,14 +3648,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_rejects_missing_d_tag() {
|
||||
let ev = make_draft(&[&["k", "9"]], &fake_nip44_v2());
|
||||
let ch = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(&[&["k", "9"], &["h", &ch]], &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 ch = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(&[&["d", ""], &["k", "9"], &["h", &ch]], &fake_nip44_v2());
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("`d` tag"), "got: {err}");
|
||||
}
|
||||
@@ -3601,7 +3665,11 @@ mod tests {
|
||||
#[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 ch = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(
|
||||
&[&["d", &d], &["d", &d], &["k", "9"], &["h", &ch]],
|
||||
&fake_nip44_v2(),
|
||||
);
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("`d` tag"), "got: {err}");
|
||||
}
|
||||
@@ -3611,7 +3679,8 @@ mod tests {
|
||||
#[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 ch = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(&[&["d", &d], &["h", &ch]], &fake_nip44_v2());
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("`k` tag"), "got: {err}");
|
||||
}
|
||||
@@ -3619,7 +3688,11 @@ mod tests {
|
||||
#[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 ch = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(
|
||||
&[&["d", &d], &["k", "9"], &["k", "9"], &["h", &ch]],
|
||||
&fake_nip44_v2(),
|
||||
);
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("`k` tag"), "got: {err}");
|
||||
}
|
||||
@@ -3627,7 +3700,8 @@ mod tests {
|
||||
#[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 ch = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(&[&["d", &d], &["k", "0x9"], &["h", &ch]], &fake_nip44_v2());
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("canonical decimal"), "got: {err}");
|
||||
}
|
||||
@@ -3635,7 +3709,8 @@ mod tests {
|
||||
#[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 ch = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(&[&["d", &d], &["k", "09"], &["h", &ch]], &fake_nip44_v2());
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("leading zero"), "got: {err}");
|
||||
}
|
||||
@@ -3643,35 +3718,31 @@ mod tests {
|
||||
#[test]
|
||||
fn draft_wrap_rejects_k_tag_out_of_u16_range() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
let ch = uuid::Uuid::new_v4().to_string();
|
||||
// 65536 = u16::MAX + 1
|
||||
let ev = make_draft(&[&["d", &d], &["k", "65536"]], &fake_nip44_v2());
|
||||
let ev = make_draft(
|
||||
&[&["d", &d], &["k", "65536"], &["h", &ch]],
|
||||
&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}");
|
||||
}
|
||||
// ── p outer-tag exclusion ─────────────────────────────────────────────────
|
||||
// Note: `h` is now *required* (not forbidden); see h-tag validation section.
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_rejects_p_tag() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
let ch = 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()]],
|
||||
&[
|
||||
&["d", &d],
|
||||
&["k", "9"],
|
||||
&["h", &ch],
|
||||
&["p", &keys.public_key().to_hex()],
|
||||
],
|
||||
&fake_nip44_v2(),
|
||||
);
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
@@ -3683,7 +3754,8 @@ mod tests {
|
||||
#[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 ch = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(&[&["d", &d], &["k", "9"], &["h", &ch]], "not-a-ciphertext");
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(
|
||||
err.contains("base64") || err.contains("NIP-44"),
|
||||
@@ -3694,9 +3766,10 @@ mod tests {
|
||||
#[test]
|
||||
fn draft_wrap_rejects_wrong_nip44_version_byte() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
let ch = 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 ev = make_draft(&[&["d", &d], &["k", "9"], &["h", &ch]], &bad);
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(
|
||||
err.contains("NIP-44 v2") || err.contains("0x02"),
|
||||
@@ -3708,7 +3781,8 @@ mod tests {
|
||||
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 ch = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(&[&["d", &d], &["k", "9"], &["h", &ch]], "Ag==");
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(err.contains("too short"), "got: {err}");
|
||||
}
|
||||
@@ -3718,9 +3792,15 @@ mod tests {
|
||||
#[test]
|
||||
fn draft_wrap_accepts_valid_future_expiration() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
let ch = uuid::Uuid::new_v4().to_string();
|
||||
// A timestamp far in the future (year 2100).
|
||||
let ev = make_draft(
|
||||
&[&["d", &d], &["k", "9"], &["expiration", "4102444800"]],
|
||||
&[
|
||||
&["d", &d],
|
||||
&["k", "9"],
|
||||
&["h", &ch],
|
||||
&["expiration", "4102444800"],
|
||||
],
|
||||
"",
|
||||
);
|
||||
assert!(
|
||||
@@ -3732,10 +3812,12 @@ mod tests {
|
||||
#[test]
|
||||
fn draft_wrap_rejects_duplicate_expiration_tag() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
let ch = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(
|
||||
&[
|
||||
&["d", &d],
|
||||
&["k", "9"],
|
||||
&["h", &ch],
|
||||
&["expiration", "4102444800"],
|
||||
&["expiration", "4102444800"],
|
||||
],
|
||||
@@ -3748,8 +3830,14 @@ mod tests {
|
||||
#[test]
|
||||
fn draft_wrap_rejects_expiration_in_past() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
let ch = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(
|
||||
&[&["d", &d], &["k", "9"], &["expiration", "1000000000"]],
|
||||
&[
|
||||
&["d", &d],
|
||||
&["k", "9"],
|
||||
&["h", &ch],
|
||||
&["expiration", "1000000000"],
|
||||
],
|
||||
"",
|
||||
);
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
@@ -3759,8 +3847,14 @@ mod tests {
|
||||
#[test]
|
||||
fn draft_wrap_rejects_non_decimal_expiration() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
let ch = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(
|
||||
&[&["d", &d], &["k", "9"], &["expiration", "not-a-number"]],
|
||||
&[
|
||||
&["d", &d],
|
||||
&["k", "9"],
|
||||
&["h", &ch],
|
||||
&["expiration", "not-a-number"],
|
||||
],
|
||||
"",
|
||||
);
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
@@ -3770,12 +3864,20 @@ mod tests {
|
||||
// ── routing invariants ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_is_global_only() {
|
||||
// Draft wraps must never be channel-scoped; compose context lives in
|
||||
// the encrypted payload only.
|
||||
fn draft_wrap_requires_h_channel_scope() {
|
||||
// Draft wraps are channel-bound; compose context exposed via `h` tag.
|
||||
assert!(
|
||||
is_global_only_kind(KIND_DRAFT),
|
||||
"KIND_DRAFT must be global-only (no h-tag channel scope)"
|
||||
requires_h_channel_scope(KIND_DRAFT),
|
||||
"KIND_DRAFT must require an `h` tag (channel-bound)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_is_not_global_only() {
|
||||
// Draft wraps have a required `h` tag — they are channel-scoped, not global.
|
||||
assert!(
|
||||
!is_global_only_kind(KIND_DRAFT),
|
||||
"KIND_DRAFT must not be global-only (it requires an h tag)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3789,11 +3891,56 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── h-tag validation ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_does_not_require_h_tag() {
|
||||
fn draft_wrap_accepts_valid_h_tag_uuid() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
let ch = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(&[&["d", &d], &["k", "9"], &["h", &ch]], &fake_nip44_v2());
|
||||
assert!(
|
||||
!requires_h_channel_scope(KIND_DRAFT),
|
||||
"KIND_DRAFT must not require an h tag"
|
||||
validate_draft_wrap_envelope(&ev).is_ok(),
|
||||
"draft with valid UUID h tag must be accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_rejects_missing_h_tag() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(&[&["d", &d], &["k", "9"]], &fake_nip44_v2());
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(
|
||||
err.contains("`h` tag") || err.contains("channel-bound"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_rejects_duplicate_h_tag() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
let ch = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(
|
||||
&[&["d", &d], &["k", "9"], &["h", &ch], &["h", &ch]],
|
||||
&fake_nip44_v2(),
|
||||
);
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(
|
||||
err.contains("`h` tag") || err.contains("channel-bound"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_wrap_rejects_non_uuid_h_tag() {
|
||||
let d = uuid::Uuid::new_v4().to_string();
|
||||
let ev = make_draft(
|
||||
&[&["d", &d], &["k", "9"], &["h", "not-a-uuid"]],
|
||||
&fake_nip44_v2(),
|
||||
);
|
||||
let err = validate_draft_wrap_envelope(&ev).unwrap_err();
|
||||
assert!(
|
||||
err.contains("`h` tag") || err.contains("UUID"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user