mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(relay): relax not_before to optional, add d-tag validation
The spec requires not_before on pending reminders only — terminal states (done/cancelled) and bookmarks omit it. The validator incorrectly required exactly one not_before on all kind:30300 events, blocking the entire completion/cancellation lifecycle. Also adds d-tag structure validation per spec: reject zero, empty, or duplicate d tags. Co-authored-by: Will Pfleger <will@pfleger.dev> Signed-off-by: Will Pfleger <will@pfleger.dev>
This commit is contained in:
co-authored by
Will Pfleger
parent
0b3bed62bf
commit
84b0dadee3
@@ -987,13 +987,16 @@ fn validate_not_before(tag_value: &str) -> Result<u64, &'static str> {
|
||||
/// reaches NIP-33 parameterized replacement.
|
||||
///
|
||||
/// The relay never decrypts the reminder; it only enforces the public schedule
|
||||
/// tags. A reminder MUST carry exactly one valid `not_before`, and — when an
|
||||
/// optional NIP-40 `expiration` is present — `expiration` MUST be strictly after
|
||||
/// `not_before` (an `expiration <= not_before` window would expire the reminder
|
||||
/// before it ever became due).
|
||||
/// tags. A reminder carries at most one `not_before` (omitted on terminal
|
||||
/// states), and — when both `not_before` and an optional NIP-40 `expiration`
|
||||
/// are present — `expiration` MUST be strictly after `not_before` (an
|
||||
/// `expiration <= not_before` window would expire the reminder before it ever
|
||||
/// became due).
|
||||
fn validate_event_reminder(event: &Event) -> Result<(), &'static str> {
|
||||
let mut not_before: Option<u64> = None;
|
||||
let mut expiration: Option<&str> = None;
|
||||
let mut d_count = 0u8;
|
||||
let mut d_empty = false;
|
||||
|
||||
for tag in event.tags.iter() {
|
||||
let parts = tag.as_slice();
|
||||
@@ -1010,20 +1013,35 @@ fn validate_event_reminder(event: &Event) -> Result<(), &'static str> {
|
||||
not_before = Some(validate_not_before(&parts[1])?);
|
||||
}
|
||||
"expiration" => expiration = Some(&parts[1]),
|
||||
"d" => {
|
||||
d_count = d_count.saturating_add(1);
|
||||
if parts[1].is_empty() {
|
||||
d_empty = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let not_before = not_before.ok_or("malformed not_before")?;
|
||||
// d-tag: must have exactly one, non-empty
|
||||
if d_count == 0 {
|
||||
return Err("missing d tag");
|
||||
}
|
||||
if d_count > 1 {
|
||||
return Err("duplicate d tag");
|
||||
}
|
||||
if d_empty {
|
||||
return Err("empty d tag");
|
||||
}
|
||||
|
||||
// `expiration` is optional and governed by NIP-40, not this validator.
|
||||
// We enforce only the spec's ordering rule (line 130), and only when the
|
||||
// value parses — an unparseable `expiration` is NIP-40's concern, so we
|
||||
// must not assert a false ordering reason for it.
|
||||
if let Some(exp) = expiration {
|
||||
if let Ok(exp) = exp.parse::<u64>() {
|
||||
if exp <= not_before {
|
||||
return Err("expiration before not_before");
|
||||
// `not_before` is optional — terminal states (done/cancelled) and bookmarks
|
||||
// omit it. The ordering check only applies when both are present.
|
||||
if let Some(nb) = not_before {
|
||||
if let Some(exp) = expiration {
|
||||
if let Ok(exp) = exp.parse::<u64>() {
|
||||
if exp <= nb {
|
||||
return Err("expiration before not_before");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2434,9 +2452,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reminder_rejects_missing_not_before() {
|
||||
fn reminder_accepts_missing_not_before() {
|
||||
// Terminal states (done/cancelled) and bookmarks omit not_before
|
||||
let ev = make_reminder(&[&["d", "abc"]]);
|
||||
assert_eq!(validate_event_reminder(&ev), Err("malformed not_before"));
|
||||
assert!(validate_event_reminder(&ev).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2500,4 +2519,42 @@ mod tests {
|
||||
assert!(!requires_h_channel_scope(KIND_EVENT_REMINDER));
|
||||
assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reminder_accepts_expiration_without_not_before() {
|
||||
// A terminal/bookmark with expiration but no not_before is valid —
|
||||
// no ordering check applies when not_before is absent.
|
||||
let ev = make_reminder(&[&["d", "abc"], &["expiration", "1777542730"]]);
|
||||
assert!(validate_event_reminder(&ev).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reminder_rejects_missing_d_tag() {
|
||||
let ev = make_event_with_tags(
|
||||
KIND_EVENT_REMINDER,
|
||||
"ciphertext",
|
||||
&[&["not_before", "1717000000"]],
|
||||
);
|
||||
assert_eq!(validate_event_reminder(&ev), Err("missing d tag"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reminder_rejects_empty_d_tag() {
|
||||
let ev = make_event_with_tags(
|
||||
KIND_EVENT_REMINDER,
|
||||
"ciphertext",
|
||||
&[&["d", ""], &["not_before", "1717000000"]],
|
||||
);
|
||||
assert_eq!(validate_event_reminder(&ev), Err("empty d tag"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reminder_rejects_duplicate_d_tag() {
|
||||
let ev = make_event_with_tags(
|
||||
KIND_EVENT_REMINDER,
|
||||
"ciphertext",
|
||||
&[&["d", "abc"], &["d", "def"], &["not_before", "1717000000"]],
|
||||
);
|
||||
assert_eq!(validate_event_reminder(&ev), Err("duplicate d tag"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,18 +145,17 @@ async fn test_reminder_accepted_with_valid_not_before() {
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_reminder_rejected_missing_not_before() {
|
||||
async fn test_reminder_accepted_missing_not_before() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let d_tag = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
// No not_before tag at all
|
||||
// No not_before tag — valid for terminal states (done/cancelled) and bookmarks
|
||||
let event = build_reminder(&keys, &d_tag, vec![]);
|
||||
let (accepted, msg) = submit_event_http(&client, &keys, &event).await;
|
||||
assert!(!accepted, "should reject missing not_before");
|
||||
let (accepted, _msg) = submit_event_http(&client, &keys, &event).await;
|
||||
assert!(
|
||||
msg.contains("malformed not_before"),
|
||||
"unexpected message: {msg}"
|
||||
accepted,
|
||||
"should accept missing not_before (bookmark/terminal)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -339,6 +338,93 @@ async fn test_reminder_accepted_with_malformed_expiration() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── d-tag validation tests ──────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_reminder_rejected_missing_d_tag() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
|
||||
// Build event without d tag
|
||||
let tags = vec![Tag::parse(["not_before", "1717000000"]).unwrap()];
|
||||
let event = EventBuilder::new(
|
||||
Kind::Custom(KIND_EVENT_REMINDER),
|
||||
"nip44-ciphertext-placeholder",
|
||||
)
|
||||
.tags(tags)
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
let (accepted, msg) = submit_event_http(&client, &keys, &event).await;
|
||||
assert!(!accepted, "should reject missing d tag");
|
||||
assert!(msg.contains("missing d tag"), "unexpected message: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_reminder_rejected_empty_d_tag() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
|
||||
let tags = vec![
|
||||
Tag::parse(["d", ""]).unwrap(),
|
||||
Tag::parse(["not_before", "1717000000"]).unwrap(),
|
||||
];
|
||||
let event = EventBuilder::new(
|
||||
Kind::Custom(KIND_EVENT_REMINDER),
|
||||
"nip44-ciphertext-placeholder",
|
||||
)
|
||||
.tags(tags)
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
let (accepted, msg) = submit_event_http(&client, &keys, &event).await;
|
||||
assert!(!accepted, "should reject empty d tag");
|
||||
assert!(msg.contains("empty d tag"), "unexpected message: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_reminder_rejected_duplicate_d_tag() {
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
|
||||
let tags = vec![
|
||||
Tag::parse(["d", "abc"]).unwrap(),
|
||||
Tag::parse(["d", "def"]).unwrap(),
|
||||
Tag::parse(["not_before", "1717000000"]).unwrap(),
|
||||
];
|
||||
let event = EventBuilder::new(
|
||||
Kind::Custom(KIND_EVENT_REMINDER),
|
||||
"nip44-ciphertext-placeholder",
|
||||
)
|
||||
.tags(tags)
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
let (accepted, msg) = submit_event_http(&client, &keys, &event).await;
|
||||
assert!(!accepted, "should reject duplicate d tag");
|
||||
assert!(msg.contains("duplicate d tag"), "unexpected message: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_reminder_accepted_expiration_without_not_before() {
|
||||
// Terminal/bookmark with expiration but no not_before — no ordering check applies
|
||||
let client = http_client();
|
||||
let keys = Keys::generate();
|
||||
let d_tag = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let event = build_reminder(
|
||||
&keys,
|
||||
&d_tag,
|
||||
vec![Tag::parse(["expiration", "1777542730"]).unwrap()],
|
||||
);
|
||||
let (accepted, msg) = submit_event_http(&client, &keys, &event).await;
|
||||
assert!(
|
||||
accepted,
|
||||
"expiration without not_before should be accepted: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Read-path filtering tests (HTTP bridge) ──────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user