commit 9656fb4f6ccf20c79fe27e4bf61ce5d1b5b797ba
parent bc545e69fba28b4dee35ad6749768b3862482c67
Author: triesap <tyson@radroots.org>
Date: Sat, 18 Jul 2026 09:23:58 +0000
calendar: enforce collection and RSVP boundaries
- replace unchecked kind 31924 and 31925 models with explicit authored, parsed, and admitted states
- reserve calendar publication for the NIP-52 codec and reject generic list and list-set bypasses
- register strict contracts, operations, mappings, and executable conformance vectors
- preserve independent relay hints, RSVP semantics, and Blossom media trust boundaries
Diffstat:
31 files changed, 6193 insertions(+), 679 deletions(-)
diff --git a/contracts/conformance/vectors/calendar/nip52_baseline.v1.json b/contracts/conformance/vectors/calendar/nip52_baseline.v1.json
@@ -328,6 +328,658 @@
"tags": [["d", "wrong-kind"], ["title", "Wrong kind"], ["start", "2026-06-20"]]
},
"expected": { "error": "invalid_kind" }
+ },
+ {
+ "id": "baseline_collection_empty_001",
+ "kind": "calendar.baseline.collection.valid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [["d", "Victoria farm calendar"], ["title", "Victoria farm calendar"]]
+ },
+ "expected": {
+ "d": "Victoria farm calendar",
+ "title": "Victoria farm calendar",
+ "content": "",
+ "list_description": null,
+ "image": null,
+ "event_references": []
+ }
+ },
+ {
+ "id": "baseline_collection_ordered_mixed_nip51_metadata_001",
+ "kind": "calendar.baseline.collection.valid",
+ "input": {
+ "kind": 31924,
+ "content": "Shared schedule for Victoria growers.",
+ "tags": [
+ ["d", "regional-farm-calendar"],
+ ["title", "Regional farm calendar"],
+ ["description", "NIP-51 client-facing set description"],
+ ["image", "https://ordinary.example.test/calendars/victoria.jpg"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:moss-street-market", "wss://date-relay.example.test/events"],
+ ["a", "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:harvest-window", "WSS://Time-Relay.Example.Test/events"]
+ ]
+ },
+ "expected": {
+ "d": "regional-farm-calendar",
+ "title": "Regional farm calendar",
+ "content": "Shared schedule for Victoria growers.",
+ "list_description": "NIP-51 client-facing set description",
+ "image": "https://ordinary.example.test/calendars/victoria.jpg",
+ "event_references": [
+ {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:moss-street-market",
+ "relay": "wss://date-relay.example.test/events"
+ },
+ {
+ "coordinate": "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:harvest-window",
+ "relay": "WSS://Time-Relay.Example.Test/events"
+ }
+ ]
+ }
+ },
+ {
+ "id": "baseline_collection_uppercase_coordinate_and_relay_001",
+ "kind": "calendar.baseline.collection.valid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [
+ ["d", "uppercase-observed"],
+ ["title", "Uppercase observed"],
+ ["a", "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:farm-tour", "WSS://Relay.Example.Test/events"]
+ ]
+ },
+ "expected": {
+ "d": "uppercase-observed",
+ "title": "Uppercase observed",
+ "content": "",
+ "list_description": null,
+ "image": null,
+ "event_references": [
+ {
+ "coordinate": "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:farm-tour",
+ "relay": "WSS://Relay.Example.Test/events"
+ }
+ ]
+ }
+ },
+ {
+ "id": "baseline_collection_duplicate_coordinate_observed_001",
+ "kind": "calendar.baseline.collection.valid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [
+ ["d", "duplicates-observed"],
+ ["title", "Duplicates observed"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour", "wss://one.example.test"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour", "wss://two.example.test"]
+ ]
+ },
+ "expected": {
+ "d": "duplicates-observed",
+ "title": "Duplicates observed",
+ "content": "",
+ "list_description": null,
+ "image": null,
+ "event_references": [
+ {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour",
+ "relay": "wss://one.example.test"
+ },
+ {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour",
+ "relay": "wss://two.example.test"
+ }
+ ]
+ }
+ },
+ {
+ "id": "baseline_collection_missing_d_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {"kind": 31924, "content": "", "tags": [["title", "Missing identifier"]]},
+ "expected": {"error": "missing_tag", "tag": "d"}
+ },
+ {
+ "id": "baseline_collection_missing_title_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {"kind": 31924, "content": "", "tags": [["d", "missing-title"]]},
+ "expected": {"error": "missing_tag", "tag": "title"}
+ },
+ {
+ "id": "baseline_collection_duplicate_d_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [["d", "one"], ["d", "two"], ["title", "Duplicate identifier"]]
+ },
+ "expected": {"error": "duplicate_tag", "tag": "d"}
+ },
+ {
+ "id": "baseline_collection_duplicate_title_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [["d", "duplicate-title"], ["title", "One"], ["title", "Two"]]
+ },
+ "expected": {"error": "duplicate_tag", "tag": "title"}
+ },
+ {
+ "id": "baseline_collection_d_extra_element_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [["d", "extra", "unexpected"], ["title", "Bad identifier tag"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "d"}
+ },
+ {
+ "id": "baseline_collection_title_extra_element_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [["d", "bad-title-tag"], ["title", "Bad title tag", "unexpected"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "title"}
+ },
+ {
+ "id": "baseline_collection_a_extra_element_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [
+ ["d", "bad-a-arity"],
+ ["title", "Bad a arity"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour", "wss://relay.example.test", "unexpected"]
+ ]
+ },
+ "expected": {"error": "invalid_tag", "tag": "a"}
+ },
+ {
+ "id": "baseline_collection_malformed_coordinate_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [["d", "bad-coordinate"], ["title", "Bad coordinate"], ["a", "31922:not-a-pubkey:farm-tour"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "a"}
+ },
+ {
+ "id": "baseline_collection_wrong_coordinate_kind_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [["d", "wrong-coordinate-kind"], ["title", "Wrong coordinate kind"], ["a", "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:another-calendar"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "a"}
+ },
+ {
+ "id": "baseline_collection_invalid_relay_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [
+ ["d", "bad-relay"],
+ ["title", "Bad relay"],
+ ["a", "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:harvest-window", "https://relay.example.test"]
+ ]
+ },
+ "expected": {"error": "invalid_tag", "tag": "a"}
+ },
+ {
+ "id": "baseline_collection_tag_element_over_limit_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [["d", "oversized-title"], ["title", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"]]
+ },
+ "expected": {"error": "invalid_envelope"}
+ },
+ {
+ "id": "baseline_rsvp_minimal_accepted_001",
+ "kind": "calendar.baseline.rsvp.valid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "farm-tour-rsvp"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"],
+ ["status", "accepted"]
+ ]
+ },
+ "expected": {
+ "d": "farm-tour-rsvp",
+ "event_reference": {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour",
+ "relay": null
+ },
+ "revision_reference": null,
+ "status": "accepted",
+ "observed_free_busy": null,
+ "effective_free_busy": null,
+ "author_hint": null,
+ "note": null
+ }
+ },
+ {
+ "id": "baseline_rsvp_complete_independent_hints_001",
+ "kind": "calendar.baseline.rsvp.valid",
+ "input": {
+ "kind": 31925,
+ "content": "I can help at the produce table.",
+ "tags": [
+ ["d", "market-rsvp"],
+ ["a", "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:market-shift", "wss://coordinate-relay.example.test/events"],
+ ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "wss://event-relay.example.test/events"],
+ ["status", "tentative"],
+ ["fb", "busy"],
+ ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://author-relay.example.test/events"]
+ ]
+ },
+ "expected": {
+ "d": "market-rsvp",
+ "event_reference": {
+ "coordinate": "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:market-shift",
+ "relay": "wss://coordinate-relay.example.test/events"
+ },
+ "revision_reference": {
+ "id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ "relay": "wss://event-relay.example.test/events"
+ },
+ "status": "tentative",
+ "observed_free_busy": "busy",
+ "effective_free_busy": "busy",
+ "author_hint": {
+ "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relay": "wss://author-relay.example.test/events"
+ },
+ "note": "I can help at the produce table."
+ }
+ },
+ {
+ "id": "baseline_rsvp_declined_observed_free_busy_001",
+ "kind": "calendar.baseline.rsvp.valid",
+ "input": {
+ "kind": 31925,
+ "content": "Unable to attend.",
+ "tags": [
+ ["d", "declined-rsvp"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:seed-swap"],
+ ["status", "declined"],
+ ["fb", "busy"]
+ ]
+ },
+ "expected": {
+ "d": "declined-rsvp",
+ "event_reference": {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:seed-swap",
+ "relay": null
+ },
+ "revision_reference": null,
+ "status": "declined",
+ "observed_free_busy": "busy",
+ "effective_free_busy": null,
+ "author_hint": null,
+ "note": "Unable to attend."
+ }
+ },
+ {
+ "id": "baseline_rsvp_uppercase_identifiers_and_relays_001",
+ "kind": "calendar.baseline.rsvp.valid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "uppercase-rsvp"],
+ ["a", "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:farm-tour", "WSS://Coordinate-Relay.Example.Test/events"],
+ ["e", "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", "WSS://Event-Relay.Example.Test/events"],
+ ["status", "accepted"],
+ ["fb", "free"],
+ ["p", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "WSS://Author-Relay.Example.Test/events"]
+ ]
+ },
+ "expected": {
+ "d": "uppercase-rsvp",
+ "event_reference": {
+ "coordinate": "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:farm-tour",
+ "relay": "WSS://Coordinate-Relay.Example.Test/events"
+ },
+ "revision_reference": {
+ "id": "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",
+ "relay": "WSS://Event-Relay.Example.Test/events"
+ },
+ "status": "accepted",
+ "observed_free_busy": "free",
+ "effective_free_busy": "free",
+ "author_hint": {
+ "pubkey": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
+ "relay": "WSS://Author-Relay.Example.Test/events"
+ },
+ "note": null
+ }
+ },
+ {
+ "id": "baseline_rsvp_missing_d_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"]]
+ },
+ "expected": {"error": "missing_tag", "tag": "d"}
+ },
+ {
+ "id": "baseline_rsvp_missing_a_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {"kind": 31925, "content": "", "tags": [["d", "missing-a"], ["status", "accepted"]]},
+ "expected": {"error": "missing_tag", "tag": "a"}
+ },
+ {
+ "id": "baseline_rsvp_missing_status_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [["d", "missing-status"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"]]
+ },
+ "expected": {"error": "missing_tag", "tag": "status"}
+ },
+ {
+ "id": "baseline_rsvp_duplicate_d_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [["d", "one"], ["d", "two"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"]]
+ },
+ "expected": {"error": "duplicate_tag", "tag": "d"}
+ },
+ {
+ "id": "baseline_rsvp_duplicate_a_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "duplicate-a"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"],
+ ["a", "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-shift"],
+ ["status", "accepted"]
+ ]
+ },
+ "expected": {"error": "duplicate_tag", "tag": "a"}
+ },
+ {
+ "id": "baseline_rsvp_duplicate_status_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [["d", "duplicate-status"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"], ["status", "tentative"]]
+ },
+ "expected": {"error": "duplicate_tag", "tag": "status"}
+ },
+ {
+ "id": "baseline_rsvp_duplicate_e_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "duplicate-e"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"],
+ ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"],
+ ["e", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"],
+ ["status", "accepted"]
+ ]
+ },
+ "expected": {"error": "duplicate_tag", "tag": "e"}
+ },
+ {
+ "id": "baseline_rsvp_duplicate_fb_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [["d", "duplicate-fb"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"], ["fb", "free"], ["fb", "busy"]]
+ },
+ "expected": {"error": "duplicate_tag", "tag": "fb"}
+ },
+ {
+ "id": "baseline_rsvp_duplicate_p_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "duplicate-p"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"],
+ ["status", "accepted"],
+ ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"],
+ ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"]
+ ]
+ },
+ "expected": {"error": "duplicate_tag", "tag": "p"}
+ },
+ {
+ "id": "baseline_rsvp_a_extra_element_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "bad-a-arity"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour", "wss://relay.example.test", "unexpected"],
+ ["status", "accepted"]
+ ]
+ },
+ "expected": {"error": "invalid_tag", "tag": "a"}
+ },
+ {
+ "id": "baseline_rsvp_e_extra_element_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "bad-e-arity"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"],
+ ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "wss://relay.example.test", "unexpected"],
+ ["status", "accepted"]
+ ]
+ },
+ "expected": {"error": "invalid_tag", "tag": "e"}
+ },
+ {
+ "id": "baseline_rsvp_p_role_element_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "bad-p-role"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"],
+ ["status", "accepted"],
+ ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://relay.example.test", "host"]
+ ]
+ },
+ "expected": {"error": "invalid_tag", "tag": "p"}
+ },
+ {
+ "id": "baseline_rsvp_wrong_coordinate_kind_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [["d", "wrong-kind"], ["a", "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-calendar"], ["status", "accepted"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "a"}
+ },
+ {
+ "id": "baseline_rsvp_malformed_coordinate_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {"kind": 31925, "content": "", "tags": [["d", "bad-coordinate"], ["a", "31922:bad:farm-tour"], ["status", "accepted"]]},
+ "expected": {"error": "invalid_tag", "tag": "a"}
+ },
+ {
+ "id": "baseline_rsvp_malformed_event_id_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [["d", "bad-event-id"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["e", "not-an-event-id"], ["status", "accepted"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "e"}
+ },
+ {
+ "id": "baseline_rsvp_malformed_author_pubkey_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [["d", "bad-author"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"], ["p", "not-a-pubkey"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "p"}
+ },
+ {
+ "id": "baseline_rsvp_invalid_a_relay_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [["d", "bad-a-relay"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour", "https://relay.example.test"], ["status", "accepted"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "a"}
+ },
+ {
+ "id": "baseline_rsvp_invalid_e_relay_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [["d", "bad-e-relay"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "https://relay.example.test"], ["status", "accepted"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "e"}
+ },
+ {
+ "id": "baseline_rsvp_invalid_p_relay_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [["d", "bad-p-relay"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"], ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "https://relay.example.test"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "p"}
+ },
+ {
+ "id": "baseline_rsvp_invalid_status_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [["d", "bad-status"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "maybe"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "status"}
+ },
+ {
+ "id": "baseline_rsvp_invalid_free_busy_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [["d", "bad-fb"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"], ["fb", "unknown"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "fb"}
+ },
+ {
+ "id": "baseline_collection_duplicate_nip51_description_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "NIP-52 content description.",
+ "tags": [
+ ["d", "duplicate-description"],
+ ["title", "Duplicate description"],
+ ["description", "One"],
+ ["description", "Two"]
+ ]
+ },
+ "expected": {"error": "duplicate_tag", "tag": "description"}
+ },
+ {
+ "id": "baseline_collection_duplicate_image_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [
+ ["d", "duplicate-image"],
+ ["title", "Duplicate image"],
+ ["image", "https://one.example.test/calendar.jpg"],
+ ["image", "https://two.example.test/calendar.jpg"]
+ ]
+ },
+ "expected": {"error": "duplicate_tag", "tag": "image"}
+ },
+ {
+ "id": "baseline_collection_nip51_description_extra_element_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [["d", "bad-description-arity"], ["title", "Bad description arity"], ["description", "Description", "unexpected"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "description"}
+ },
+ {
+ "id": "baseline_collection_image_extra_element_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [["d", "bad-image-arity"], ["title", "Bad image arity"], ["image", "https://example.test/calendar.jpg", "unexpected"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "image"}
+ },
+ {
+ "id": "baseline_rsvp_status_extra_element_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "bad-status-arity"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"],
+ ["status", "accepted", "unexpected"]
+ ]
+ },
+ "expected": {"error": "invalid_tag", "tag": "status"}
+ },
+ {
+ "id": "baseline_rsvp_fb_extra_element_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "bad-fb-arity"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"],
+ ["status", "accepted"],
+ ["fb", "free", "unexpected"]
+ ]
+ },
+ "expected": {"error": "invalid_tag", "tag": "fb"}
}
]
}
diff --git a/contracts/conformance/vectors/calendar/radroots_profile.v1.json b/contracts/conformance/vectors/calendar/radroots_profile.v1.json
@@ -251,6 +251,657 @@
"tags": [["d", "long-range"], ["title", "Long range"], ["start", "0"], ["end", "31708800"], ["D", "0"]]
},
"expected": {"error": "covered_day_limit_exceeded", "max": 366, "actual": 367}
+ },
+ {
+ "id": "profile_authored_collection_empty_001",
+ "kind": "calendar.profile.authored.collection.valid",
+ "input": {
+ "d": "AAAAAAAAAAAAAAAAAAAAAA",
+ "title": "Victoria farm calendar",
+ "event_references": []
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 31924,
+ "content": "",
+ "tags": [["d", "AAAAAAAAAAAAAAAAAAAAAA"], ["title", "Victoria farm calendar"]]
+ }
+ }
+ },
+ {
+ "id": "profile_authored_collection_ordered_mixed_001",
+ "kind": "calendar.profile.authored.collection.valid",
+ "input": {
+ "d": "AAAAAAAAAAAAAAAAAAAAAA",
+ "title": "South Island farm schedule",
+ "content": "Market dates and farm work windows for Victoria growers.",
+ "list_description": "NIP-51 set description for compatible clients.",
+ "event_references": [
+ {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": "wss://date-relay.example.test/events"
+ },
+ {
+ "coordinate": "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:CCCCCCCCCCCCCCCCCCCCCg",
+ "relay": "wss://time-relay.example.test/events"
+ }
+ ]
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 31924,
+ "content": "Market dates and farm work windows for Victoria growers.",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAA"],
+ ["title", "South Island farm schedule"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "wss://date-relay.example.test/events"],
+ ["a", "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:CCCCCCCCCCCCCCCCCCCCCg", "wss://time-relay.example.test/events"],
+ ["description", "NIP-51 set description for compatible clients."]
+ ]
+ }
+ }
+ },
+ {
+ "id": "profile_authored_collection_byte_verified_blossom_image_001",
+ "kind": "calendar.profile.authored.collection.valid",
+ "input": {
+ "d": "AAAAAAAAAAAAAAAAAAAAAA",
+ "title": "Victoria farm calendar",
+ "content": "A shared calendar with a verified cover image.",
+ "image": {
+ "bytes_utf8": "victoria-calendar",
+ "descriptor": {
+ "url": "https://media.example/aba72b48bb536e341ab81c5f86053981c443029766a4374ee86a4f56577ebc84.webp",
+ "sha256": "aba72b48bb536e341ab81c5f86053981c443029766a4374ee86a4f56577ebc84",
+ "size": 17,
+ "type": "image/webp",
+ "uploaded": 1784347200
+ }
+ },
+ "event_references": [
+ {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": null
+ }
+ ]
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 31924,
+ "content": "A shared calendar with a verified cover image.",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAA"],
+ ["title", "Victoria farm calendar"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"],
+ ["image", "https://media.example/aba72b48bb536e341ab81c5f86053981c443029766a4374ee86a4f56577ebc84.webp"]
+ ]
+ },
+ "media_verification": "byte_verified",
+ "upload_completion": "not_attested_by_codec"
+ }
+ },
+ {
+ "id": "profile_authored_collection_human_identifier_001",
+ "kind": "calendar.profile.authored.collection.invalid",
+ "input": {"d": "victoria-calendar", "title": "Victoria farm calendar", "event_references": []},
+ "expected": {"error": "invalid_calendar_uid"}
+ },
+ {
+ "id": "profile_authored_collection_noncanonical_title_001",
+ "kind": "calendar.profile.authored.collection.invalid",
+ "input": {"d": "AAAAAAAAAAAAAAAAAAAAAA", "title": " Victoria farm calendar ", "event_references": []},
+ "expected": {"error": "invalid_title"}
+ },
+ {
+ "id": "profile_authored_collection_duplicate_coordinate_001",
+ "kind": "calendar.profile.authored.collection.invalid",
+ "input": {
+ "d": "AAAAAAAAAAAAAAAAAAAAAA",
+ "title": "Duplicate event calendar",
+ "event_references": [
+ {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": "wss://one.example.test"
+ },
+ {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": "wss://two.example.test"
+ }
+ ]
+ },
+ "expected": {"error": "duplicate_event_reference"}
+ },
+ {
+ "id": "profile_authored_collection_uppercase_relay_001",
+ "kind": "calendar.profile.authored.collection.invalid",
+ "input": {
+ "d": "AAAAAAAAAAAAAAAAAAAAAA",
+ "title": "Uppercase relay",
+ "event_references": [
+ {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": "WSS://Relay.Example.Test/events"
+ }
+ ]
+ },
+ "expected": {"error": "invalid_event_reference"}
+ },
+ {
+ "id": "profile_authored_collection_wrong_coordinate_kind_001",
+ "kind": "calendar.profile.authored.collection.invalid",
+ "input": {
+ "d": "AAAAAAAAAAAAAAAAAAAAAA",
+ "title": "Wrong coordinate kind",
+ "event_references": [
+ {
+ "coordinate": "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": null
+ }
+ ]
+ },
+ "expected": {"error": "invalid_event_reference"}
+ },
+ {
+ "id": "profile_authored_collection_media_hash_mismatch_001",
+ "kind": "calendar.profile.authored.collection.invalid",
+ "input": {
+ "d": "AAAAAAAAAAAAAAAAAAAAAA",
+ "title": "Mismatched media",
+ "image": {
+ "bytes_utf8": "victoria-calendar",
+ "descriptor": {
+ "url": "https://media.example/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.webp",
+ "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "size": 17,
+ "type": "image/webp",
+ "uploaded": 1784347200
+ }
+ },
+ "event_references": []
+ },
+ "expected": {"error": "blob_hash_mismatch"}
+ },
+ {
+ "id": "profile_admit_collection_empty_001",
+ "kind": "calendar.profile.admit.collection.valid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [["d", "AAAAAAAAAAAAAAAAAAAAAA"], ["title", "Victoria farm calendar"]]
+ },
+ "expected": {
+ "d": "AAAAAAAAAAAAAAAAAAAAAA",
+ "event_references": [],
+ "blossom_image": false
+ }
+ },
+ {
+ "id": "profile_admit_collection_ordered_mixed_blossom_001",
+ "kind": "calendar.profile.admit.collection.valid",
+ "input": {
+ "kind": 31924,
+ "content": "Market dates and farm work windows for Victoria growers.",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAA"],
+ ["title", "South Island farm schedule"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "wss://date-relay.example.test/events"],
+ ["a", "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:CCCCCCCCCCCCCCCCCCCCCg", "wss://time-relay.example.test/events"],
+ ["description", "NIP-51 set description for compatible clients."],
+ ["image", "https://media.example/aba72b48bb536e341ab81c5f86053981c443029766a4374ee86a4f56577ebc84.webp"]
+ ]
+ },
+ "expected": {
+ "d": "AAAAAAAAAAAAAAAAAAAAAA",
+ "content": "Market dates and farm work windows for Victoria growers.",
+ "list_description": "NIP-51 set description for compatible clients.",
+ "event_references": [
+ {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": "wss://date-relay.example.test/events"
+ },
+ {
+ "coordinate": "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:CCCCCCCCCCCCCCCCCCCCCg",
+ "relay": "wss://time-relay.example.test/events"
+ }
+ ],
+ "blossom_image": true,
+ "media_verification": "structural_only"
+ }
+ },
+ {
+ "id": "profile_admit_collection_uppercase_coordinate_and_relay_001",
+ "kind": "calendar.profile.admit.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAA"],
+ ["title", "Uppercase reference"],
+ ["a", "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:BBBBBBBBBBBBBBBBBBBBBQ", "WSS://Relay.Example.Test/events"]
+ ]
+ },
+ "expected": {"error": "non_canonical_field", "field": "event_reference"}
+ },
+ {
+ "id": "profile_admit_collection_duplicate_coordinate_001",
+ "kind": "calendar.profile.admit.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAA"],
+ ["title", "Duplicate reference"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "wss://one.example.test"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "wss://two.example.test"]
+ ]
+ },
+ "expected": {"error": "duplicate_event_reference"}
+ },
+ {
+ "id": "profile_admit_collection_human_identifier_001",
+ "kind": "calendar.profile.admit.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [["d", "victoria-calendar"], ["title", "Victoria farm calendar"]]
+ },
+ "expected": {"error": "non_canonical_field", "field": "d"}
+ },
+ {
+ "id": "profile_admit_collection_non_blossom_image_001",
+ "kind": "calendar.profile.admit.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAA"],
+ ["title", "Ordinary image"],
+ ["image", "https://ordinary.example.test/victoria-calendar.jpg"]
+ ]
+ },
+ "expected": {"error": "non_blossom_image"}
+ },
+ {
+ "id": "profile_authored_rsvp_minimal_accepted_001",
+ "kind": "calendar.profile.authored.rsvp.valid",
+ "input": {
+ "d": "DDDDDDDDDDDDDDDDDDDDDw",
+ "event_reference": {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": null
+ },
+ "status": "accepted"
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "DDDDDDDDDDDDDDDDDDDDDw"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"],
+ ["status", "accepted"]
+ ]
+ }
+ }
+ },
+ {
+ "id": "profile_authored_rsvp_complete_independent_hints_001",
+ "kind": "calendar.profile.authored.rsvp.valid",
+ "input": {
+ "d": "DDDDDDDDDDDDDDDDDDDDDw",
+ "event_reference": {
+ "coordinate": "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": "wss://coordinate-relay.example.test/events"
+ },
+ "revision_reference": {
+ "id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ "relay": "wss://event-relay.example.test/events"
+ },
+ "status": "tentative",
+ "free_busy": "busy",
+ "author_hint": {
+ "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relay": "wss://author-relay.example.test/events"
+ },
+ "note": "I can help with the wash-pack shift."
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 31925,
+ "content": "I can help with the wash-pack shift.",
+ "tags": [
+ ["d", "DDDDDDDDDDDDDDDDDDDDDw"],
+ ["a", "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "wss://coordinate-relay.example.test/events"],
+ ["status", "tentative"],
+ ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "wss://event-relay.example.test/events"],
+ ["fb", "busy"],
+ ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://author-relay.example.test/events"]
+ ]
+ },
+ "observed_free_busy": "busy",
+ "effective_free_busy": "busy"
+ }
+ },
+ {
+ "id": "profile_authored_rsvp_declined_with_free_busy_001",
+ "kind": "calendar.profile.authored.rsvp.invalid",
+ "input": {
+ "d": "DDDDDDDDDDDDDDDDDDDDDw",
+ "event_reference": {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": null
+ },
+ "status": "declined",
+ "free_busy": "busy"
+ },
+ "expected": {"error": "declined_free_busy_forbidden"}
+ },
+ {
+ "id": "profile_authored_rsvp_human_identifier_001",
+ "kind": "calendar.profile.authored.rsvp.invalid",
+ "input": {
+ "d": "farm-tour-rsvp",
+ "event_reference": {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": null
+ },
+ "status": "accepted"
+ },
+ "expected": {"error": "invalid_calendar_uid"}
+ },
+ {
+ "id": "profile_authored_rsvp_uppercase_event_id_001",
+ "kind": "calendar.profile.authored.rsvp.invalid",
+ "input": {
+ "d": "DDDDDDDDDDDDDDDDDDDDDw",
+ "event_reference": {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": null
+ },
+ "revision_reference": {
+ "id": "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",
+ "relay": null
+ },
+ "status": "accepted"
+ },
+ "expected": {"error": "invalid_revision_reference"}
+ },
+ {
+ "id": "profile_authored_rsvp_author_mismatch_001",
+ "kind": "calendar.profile.authored.rsvp.invalid",
+ "input": {
+ "d": "DDDDDDDDDDDDDDDDDDDDDw",
+ "event_reference": {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": null
+ },
+ "status": "accepted",
+ "author_hint": {
+ "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ "relay": null
+ }
+ },
+ "expected": {"error": "author_hint_mismatch"}
+ },
+ {
+ "id": "profile_authored_rsvp_uppercase_event_relay_001",
+ "kind": "calendar.profile.authored.rsvp.invalid",
+ "input": {
+ "d": "DDDDDDDDDDDDDDDDDDDDDw",
+ "event_reference": {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": "WSS://Relay.Example.Test/events"
+ },
+ "status": "accepted"
+ },
+ "expected": {"error": "invalid_event_reference"}
+ },
+ {
+ "id": "profile_authored_rsvp_wrong_coordinate_kind_001",
+ "kind": "calendar.profile.authored.rsvp.invalid",
+ "input": {
+ "d": "DDDDDDDDDDDDDDDDDDDDDw",
+ "event_reference": {
+ "coordinate": "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": null
+ },
+ "status": "accepted"
+ },
+ "expected": {"error": "invalid_event_reference"}
+ },
+ {
+ "id": "profile_admit_rsvp_minimal_accepted_001",
+ "kind": "calendar.profile.admit.rsvp.valid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "DDDDDDDDDDDDDDDDDDDDDw"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"],
+ ["status", "accepted"]
+ ]
+ },
+ "expected": {
+ "d": "DDDDDDDDDDDDDDDDDDDDDw",
+ "status": "accepted",
+ "observed_free_busy": null,
+ "effective_free_busy": null
+ }
+ },
+ {
+ "id": "profile_admit_rsvp_complete_independent_hints_001",
+ "kind": "calendar.profile.admit.rsvp.valid",
+ "input": {
+ "kind": 31925,
+ "content": "I can help with the wash-pack shift.",
+ "tags": [
+ ["d", "DDDDDDDDDDDDDDDDDDDDDw"],
+ ["a", "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "wss://coordinate-relay.example.test/events"],
+ ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "wss://event-relay.example.test/events"],
+ ["status", "tentative"],
+ ["fb", "busy"],
+ ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://author-relay.example.test/events"]
+ ]
+ },
+ "expected": {
+ "d": "DDDDDDDDDDDDDDDDDDDDDw",
+ "event_reference": {
+ "coordinate": "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": "wss://coordinate-relay.example.test/events"
+ },
+ "revision_reference": {
+ "id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ "relay": "wss://event-relay.example.test/events"
+ },
+ "author_hint": {
+ "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relay": "wss://author-relay.example.test/events"
+ },
+ "status": "tentative",
+ "observed_free_busy": "busy",
+ "effective_free_busy": "busy"
+ }
+ },
+ {
+ "id": "profile_admit_rsvp_declined_observed_free_busy_001",
+ "kind": "calendar.profile.admit.rsvp.valid",
+ "input": {
+ "kind": 31925,
+ "content": "Unable to attend.",
+ "tags": [
+ ["d", "DDDDDDDDDDDDDDDDDDDDDw"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"],
+ ["status", "declined"],
+ ["fb", "busy"]
+ ]
+ },
+ "expected": {
+ "d": "DDDDDDDDDDDDDDDDDDDDDw",
+ "status": "declined",
+ "observed_free_busy": "busy",
+ "effective_free_busy": null
+ }
+ },
+ {
+ "id": "profile_admit_rsvp_uppercase_fields_001",
+ "kind": "calendar.profile.admit.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "DDDDDDDDDDDDDDDDDDDDDw"],
+ ["a", "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:BBBBBBBBBBBBBBBBBBBBBQ", "WSS://Coordinate-Relay.Example.Test/events"],
+ ["e", "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", "WSS://Event-Relay.Example.Test/events"],
+ ["status", "accepted"],
+ ["p", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "WSS://Author-Relay.Example.Test/events"]
+ ]
+ },
+ "expected": {"error": "non_canonical_field"}
+ },
+ {
+ "id": "profile_admit_rsvp_author_mismatch_001",
+ "kind": "calendar.profile.admit.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "DDDDDDDDDDDDDDDDDDDDDw"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"],
+ ["status", "accepted"],
+ ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"]
+ ]
+ },
+ "expected": {"error": "author_hint_mismatch"}
+ },
+ {
+ "id": "profile_admit_rsvp_human_identifier_001",
+ "kind": "calendar.profile.admit.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "farm-tour-rsvp"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"],
+ ["status", "accepted"]
+ ]
+ },
+ "expected": {"error": "non_canonical_field", "field": "d"}
+ },
+ {
+ "id": "profile_admit_collection_uppercase_coordinate_001",
+ "kind": "calendar.profile.admit.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAA"],
+ ["title", "Uppercase coordinate"],
+ ["a", "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:BBBBBBBBBBBBBBBBBBBBBQ"]
+ ]
+ },
+ "expected": {"error": "non_canonical_field", "field": "event_reference"}
+ },
+ {
+ "id": "profile_admit_collection_uppercase_relay_001",
+ "kind": "calendar.profile.admit.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAA"],
+ ["title", "Uppercase relay"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "WSS://Relay.Example.Test/events"]
+ ]
+ },
+ "expected": {"error": "non_canonical_field", "field": "event_reference"}
+ },
+ {
+ "id": "profile_admit_rsvp_uppercase_coordinate_001",
+ "kind": "calendar.profile.admit.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "DDDDDDDDDDDDDDDDDDDDDw"],
+ ["a", "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:BBBBBBBBBBBBBBBBBBBBBQ"],
+ ["status", "accepted"]
+ ]
+ },
+ "expected": {"error": "non_canonical_field", "field": "event_reference"}
+ },
+ {
+ "id": "profile_admit_rsvp_uppercase_event_id_001",
+ "kind": "calendar.profile.admit.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "DDDDDDDDDDDDDDDDDDDDDw"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"],
+ ["e", "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"],
+ ["status", "accepted"]
+ ]
+ },
+ "expected": {"error": "non_canonical_field", "field": "revision_reference"}
+ },
+ {
+ "id": "profile_admit_rsvp_uppercase_author_pubkey_001",
+ "kind": "calendar.profile.admit.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "DDDDDDDDDDDDDDDDDDDDDw"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"],
+ ["status", "accepted"],
+ ["p", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"]
+ ]
+ },
+ "expected": {"error": "non_canonical_field", "field": "author_hint"}
+ },
+ {
+ "id": "profile_admit_rsvp_uppercase_a_relay_001",
+ "kind": "calendar.profile.admit.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "DDDDDDDDDDDDDDDDDDDDDw"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "WSS://Coordinate-Relay.Example.Test/events"],
+ ["status", "accepted"]
+ ]
+ },
+ "expected": {"error": "non_canonical_field", "field": "event_reference"}
+ },
+ {
+ "id": "profile_admit_rsvp_uppercase_e_relay_001",
+ "kind": "calendar.profile.admit.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "DDDDDDDDDDDDDDDDDDDDDw"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"],
+ ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "WSS://Event-Relay.Example.Test/events"],
+ ["status", "accepted"]
+ ]
+ },
+ "expected": {"error": "non_canonical_field", "field": "revision_reference"}
+ },
+ {
+ "id": "profile_admit_rsvp_uppercase_p_relay_001",
+ "kind": "calendar.profile.admit.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "DDDDDDDDDDDDDDDDDDDDDw"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"],
+ ["status", "accepted"],
+ ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "WSS://Author-Relay.Example.Test/events"]
+ ]
+ },
+ "expected": {"error": "non_canonical_field", "field": "author_hint"}
}
]
}
diff --git a/contracts/conformance/vectors/social/production.v1.json b/contracts/conformance/vectors/social/production.v1.json
@@ -95,106 +95,6 @@
}
},
{
- "id": "social_calendar_collection_valid_005",
- "kind": "social.calendar.build_tags.valid",
- "input": {
- "calendar": {
- "d_tag": "AAAAAAAAAAAAAAAAAAAA_A",
- "title": "farm calendar",
- "description": "Shared schedule for farm operations.",
- "events": [
- {
- "kind": "address",
- "address": "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:AAAAAAAAAAAAAAAAAAAA-A",
- "author": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
- "event_kind": 31923,
- "relays": [
- "wss://relay.example.test"
- ]
- }
- ]
- }
- },
- "expected": {
- "result": "ok",
- "required_tags": [
- "d",
- "title",
- "a"
- ],
- "content": "Shared schedule for farm operations."
- }
- },
- {
- "id": "social_calendar_collection_empty_invalid_006",
- "kind": "social.calendar.build_tags.invalid",
- "input": {
- "calendar": {
- "d_tag": "AAAAAAAAAAAAAAAAAAAA_A",
- "title": "farm calendar",
- "events": []
- }
- },
- "expected": {
- "result": "error",
- "error_class": "encode_error",
- "field": "events"
- }
- },
- {
- "id": "social_calendar_rsvp_valid_007",
- "kind": "social.calendar_rsvp.build_tags.valid",
- "input": {
- "rsvp": {
- "d_tag": "AAAAAAAAAAAAAAAAAAAAAQ",
- "event": {
- "kind": "address",
- "address": "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:AAAAAAAAAAAAAAAAAAAA-A",
- "author": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
- "event_kind": 31923,
- "relays": [
- "wss://relay.example.test"
- ]
- },
- "event_id": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
- "status": "tentative",
- "free_busy": "busy"
- }
- },
- "expected": {
- "result": "ok",
- "required_tags": [
- "d",
- "a",
- "e",
- "status",
- "fb"
- ]
- }
- },
- {
- "id": "social_calendar_rsvp_event_target_invalid_008",
- "kind": "social.calendar_rsvp.build_tags.invalid",
- "input": {
- "rsvp": {
- "d_tag": "AAAAAAAAAAAAAAAAAAAAAQ",
- "event": {
- "kind": "event",
- "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
- "author": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
- "event_kind": 31923,
- "relays": []
- },
- "status": "accepted"
- }
- },
- "expected": {
- "result": "error",
- "error_class": "encode_error",
- "field": "event"
- }
- },
- {
"id": "social_report_event_and_file_valid_009",
"kind": "social.report.build_tags.valid",
"input": {
diff --git a/contracts/conformance/vectors/social/upgraded_boundaries.v1.json b/contracts/conformance/vectors/social/upgraded_boundaries.v1.json
@@ -92,35 +92,6 @@
}
},
{
- "id": "social_list_set_calendar_address_valid_006",
- "kind": "social.list_set.parse_event.valid",
- "input": {
- "event": {
- "kind": 31924,
- "content": "Shared schedule for farm operations.",
- "tags": [
- [
- "d",
- "AAAAAAAAAAAAAAAAAAAA_A"
- ],
- [
- "title",
- "farm calendar"
- ],
- [
- "a",
- "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:AAAAAAAAAAAAAAAAAAAA-A"
- ]
- ]
- }
- },
- "expected": {
- "result": "ok",
- "typed_model": "RadrootsCalendar",
- "generic_model": "RadrootsListSet"
- }
- },
- {
"id": "social_farm_rejects_private_ops_json_invalid_007",
"kind": "social.farm.parse_event.invalid",
"input": {
diff --git a/contracts/event_boundary_matrix.md b/contracts/event_boundary_matrix.md
@@ -22,12 +22,14 @@ contract package.
## Calendar boundary rule
-Calendar kinds `31922` and `31923` expose separate authored, baseline-parsed, and
-strict-admitted types. A structural `RadrootsEventEnvelope` is not cryptographic verification, and
-neither the NIP-52 parser nor Radroots admission verifies the declared event id or Schnorr
-signature. A read-side runtime must keep the parsed or admitted value bound to an envelope whose id
-and signature it has independently verified and whose kind the corresponding parser accepted.
-Outbound authored models produce unsigned wire parts and require runtime signing and transport.
+Calendar kinds `31922` through `31925` expose separate authored,
+baseline-parsed, and strict-admitted types. A structural
+`RadrootsEventEnvelope` is not cryptographic verification, and neither the
+NIP-52 parser nor Radroots admission verifies the declared event id or Schnorr
+signature. A read-side runtime must keep the parsed or admitted value bound to
+an envelope whose id and signature it has independently verified and whose kind
+the corresponding parser accepted. Outbound authored models produce unsigned
+wire parts and require runtime signing and transport.
## Coverage matrix
@@ -48,15 +50,15 @@ Outbound authored models produce unsigned wire parts and require runtime signing
| report | 1984 | RadrootsReport | events.report.publish, events.report.list, events.report.get | NIP-56 report with required reported pubkey |
| list | 10000..10102 | RadrootsList | events.list.publish, events.list.list, events.list.get | replaceable NIP-51 list kinds excluding kind 3 |
| relay_list | 10002 | RadrootsList | events.relay_list.publish, events.relay_list.list, events.relay_list.get | NIP-65 relay list entries with `read` or `write` markers |
-| list_set | 30000..39092 | RadrootsListSet | events.list_set.publish, events.list_set.list, events.list_set.get | addressable NIP-51 list sets with `d` tag |
+| list_set | 30000..30007, 30015, 30030, 30063, 30267, 39089, 39092 | RadrootsListSet | events.list_set.publish, events.list_set.list, events.list_set.get | enumerated addressable NIP-51 list sets with `d` tag; NIP-52 kind `31924` is exclusively the calendar surface |
| article | 30023 | RadrootsArticle | events.article.publish, events.article.list, events.article.get | NIP-23 long-form content |
| knowledge | 818, 3460..3465, 30450..30451, 30818..30819 | RadrootsKnowledgeEvent | events.knowledge.publish, events.knowledge.list, events.knowledge.get | NIP-54 wiki plus Radroots knowledge source, claim, relation, review, field-report, bounty, proposal, and contribution contracts |
| app_data | 30078 | RadrootsAppData | events.app_data.publish, events.app_data.list, events.app_data.get | addressable app data with `d` tag |
| app_handler | 31990 | KIND_APPLICATION_HANDLER | events.app_handler.publish, events.app_handler.list, events.app_handler.get | optional discoverability |
| calendar_date | 31922 | RadrootsAuthoredCalendarDateEvent / RadrootsParsedNip52CalendarDateEvent / RadrootsAdmittedCalendarDateEvent | events.calendar_date.publish, events.calendar_date.list, events.calendar_date.get | NIP-52 date event; baseline retains uppercase-`D` extensions, strict admission rejects them |
| calendar_time | 31923 | RadrootsAuthoredCalendarTimeEvent / RadrootsParsedNip52CalendarTimeEvent / RadrootsAdmittedCalendarTimeEvent | events.calendar_time.publish, events.calendar_time.list, events.calendar_time.get | NIP-52 time event; baseline applies required day anchoring, strict admission requires exact bounded UTC-day coverage |
-| calendar | 31924 | RadrootsCalendar | events.calendar.publish, events.calendar.list, events.calendar.get | NIP-52 calendar collection |
-| calendar_rsvp | 31925 | RadrootsCalendarEventRsvp | events.calendar_rsvp.publish, events.calendar_rsvp.list, events.calendar_rsvp.get | NIP-52 calendar RSVP |
+| calendar | 31924 | RadrootsAuthoredCalendar / RadrootsParsedNip52Calendar / RadrootsAdmittedCalendar | events.calendar.publish, events.calendar.list, events.calendar.get | NIP-52 calendar collection with separate authored, baseline parse, and strict Radroots admission boundaries |
+| calendar_rsvp | 31925 | RadrootsAuthoredCalendarEventRsvp / RadrootsParsedNip52CalendarEventRsvp / RadrootsAdmittedCalendarEventRsvp | events.calendar_rsvp.publish, events.calendar_rsvp.list, events.calendar_rsvp.get | NIP-52 calendar RSVP with separate authored, baseline parse, and strict Radroots admission boundaries |
| farm | 30340 | RadrootsFarm | events.farm.publish, events.farm.list, events.farm.get | addressable; canonical JSON; `g` tag only when a geohash exists |
| plot | 30350 | RadrootsPlot | events.plot.publish, events.plot.list, events.plot.get | requires address and pubkey tags; preserve self-tag |
| coop | 30360 | RadrootsCoop | events.coop.publish, events.coop.list, events.coop.get | addressable; canonical JSON; `g` tag from geohash |
diff --git a/contracts/events/social-events.md b/contracts/events/social-events.md
@@ -17,7 +17,9 @@ deterministic fixtures live under `contracts/conformance`.
Calendar behavior is based on
[NIP-52](https://github.com/nostr-protocol/nips/blob/bdfa7e62ef87fcfcb992b1a27aee49d36b0b4f91/52.md)
-at NIPs commit `bdfa7e62ef87fcfcb992b1a27aee49d36b0b4f91`. Calendar media uses the public
+and its collection metadata uses
+[NIP-51](https://github.com/nostr-protocol/nips/blob/bdfa7e62ef87fcfcb992b1a27aee49d36b0b4f91/51.md),
+both at NIPs commit `bdfa7e62ef87fcfcb992b1a27aee49d36b0b4f91`. Calendar media uses the public
Blossom primitives governed by the protocol pin in
[`blossom-media.md`](blossom-media.md). The upstream NIP-52 rules and the stricter Radroots
authoring and admission profile are separate contract layers.
@@ -58,8 +60,12 @@ The production-v1 public social substrate includes:
- `RadrootsRepost` for NIP-18 kind `6`
- `RadrootsGenericRepost` for NIP-18 kind `16`
-- `RadrootsCalendar` for NIP-52 kind `31924`
-- `RadrootsCalendarEventRsvp` for NIP-52 kind `31925`
+- strict authored `RadrootsAuthoredCalendar`, tolerant
+ `RadrootsParsedNip52Calendar`, and strict admitted `RadrootsAdmittedCalendar`
+ models for NIP-52 kind `31924`
+- strict authored `RadrootsAuthoredCalendarEventRsvp`, tolerant
+ `RadrootsParsedNip52CalendarEventRsvp`, and strict admitted
+ `RadrootsAdmittedCalendarEventRsvp` models for NIP-52 kind `31925`
- `RadrootsReport` for NIP-56 kind `1984`
- stable listing kind `30402` validation through `RadrootsListing`
- relay-list kind `10002` validation through `RadrootsList`
@@ -90,28 +96,31 @@ summary, alt text, fallback, `magnet`, `i`, and `service`.
### Calendar Trust Layers
-Kinds `31922` and `31923` have three explicit, non-interchangeable layers:
+Kinds `31922`, `31923`, `31924`, and `31925` have three explicit,
+non-interchangeable layers:
| Layer | Public role | What success establishes |
| --- | --- | --- |
| bounded structural wire or envelope | preserves the complete NIP-01 event while validating wire shape, identifier syntax, and resource limits | structural data only; it does not establish a matching event id or valid Schnorr signature unless the caller invokes the separate verification operations |
-| tolerant NIP-52 parse | `RadrootsParsedNip52CalendarDateEvent` or `RadrootsParsedNip52CalendarTimeEvent` | the expected kind and the pinned baseline NIP-52 calendar semantics parse successfully; observed standard fields and tolerated wire spellings remain distinguishable from canonical authored data |
-| strict Radroots admission | `RadrootsAdmittedCalendarDateEvent` or `RadrootsAdmittedCalendarTimeEvent` | the parsed calendar event also satisfies the canonical Radroots metadata, media-reference, and date or UTC-day coverage profile |
+| tolerant NIP-52 parse | one of the `RadrootsParsedNip52*` date event, time event, calendar collection, or RSVP types | the expected kind and the pinned baseline NIP-52 semantics parse successfully; observed standard fields and tolerated wire spellings remain distinguishable from canonical authored data |
+| strict Radroots admission | one of the corresponding `RadrootsAdmitted*` calendar types | the parsed value also satisfies the canonical Radroots identifier, reference, metadata, media, date, or UTC-day profile applicable to that kind |
The calendar `*_parsed_from_event` helpers construct a structurally checked raw envelope alongside
the tolerant projection. Their names do not mean that the event id or signature has been verified.
Before a caller treats relay data as accepted, it must recompute and compare the NIP-01 event id,
verify the Schnorr signature against the event author, dispatch the event to the matching kind
-`31922` or `31923` parser, and then apply strict admission when the Radroots profile is required.
+`31922`, `31923`, `31924`, or `31925` parser, and then apply strict admission when the Radroots
+profile is required.
The kind-specific parsers reject the wrong kind, but neither baseline parsing nor strict admission
performs cryptographic verification. An admitted model is therefore valid only while it remains
bound to the already id- and signature-verified envelope from which it was parsed.
Strict authoring is the outbound counterpart, not a fourth inbound verification state.
-`RadrootsAuthoredCalendarDateEvent` and `RadrootsAuthoredCalendarTimeEvent` have private checked
-fields and encode deterministic `RadrootsNip01EventWireParts`. Wire parts contain only `kind`,
-`content`, and `tags`; the owning runtime still supplies `created_at` and author identity, computes
-the event id, signs the event, and publishes it.
+`RadrootsAuthoredCalendarDateEvent`, `RadrootsAuthoredCalendarTimeEvent`,
+`RadrootsAuthoredCalendar`, and `RadrootsAuthoredCalendarEventRsvp` have private checked fields and
+encode deterministic `RadrootsNip01EventWireParts`. Wire parts contain only `kind`, `content`, and
+`tags`; the owning runtime still supplies `created_at` and author identity, computes the event id,
+signs the event, and publishes it.
### Baseline NIP-52 Parse
@@ -154,10 +163,34 @@ When `end_tzid` is absent and `start_tzid` is present, the effective end time zo
An inbound baseline `image` is only a structurally valid absolute URI. It is not a Blossom claim,
an approved reference, or evidence about bytes or network state.
+Kind `31924` is a calendar collection, not a generic list or list-set publication surface. Its bounded
+plain-text `content` is the NIP-52 detailed description and is required on wire even when empty.
+The required singleton `d` and `title` tags identify and name the collection. Repeated `a` tags
+contain only kind-`31922` or kind-`31923` addressable coordinates and may each carry their own
+recommended relay URL. A collection with no `a` tags is valid. The optional singleton
+`description` and `image` tags are NIP-51 list metadata; `description` is distinct from NIP-52
+description content and the parser does not merge one into the other. The baseline parser accepts a
+structurally valid absolute `image` URI without making a Blossom or network claim. Singleton text
+tags have exactly two elements; collection `a` tags have exactly two elements plus an optional relay
+element.
+
+Kind `31925` is an RSVP with bounded optional free-form note content. It has exactly one required
+`d` identifier, one required `a` coordinate for a kind-`31922` or kind-`31923` event, and one
+required `status` value: `accepted`, `declined`, or `tentative`. Optional singleton `e`, `fb`, and
+`p` tags respectively identify one exact event revision, the `free` or `busy` availability state,
+and the event author. The `a`, `e`, and `p` references each carry an independent optional
+recommended relay URL; a relay hint on one reference is never copied to another. The `p` form is
+an author hint without participant-role semantics. The `d`, `status`, and `fb` tags have exactly
+two elements; the `a`, `e`, and `p` tags have exactly two elements plus an optional relay element.
+Baseline parsing preserves `fb` observed on a declined RSVP for diagnostics but treats its effective
+value as absent, as required by NIP-52. Parsing a syntactically valid `e` tag does not establish
+that it is a revision of the referenced addressable event.
+
### Strict Radroots Calendar Profile
Strict authored and admitted calendar metadata uses canonical nonempty text, canonical participant
-and relay values, and lowercase geohashes. The authored common surface supports repeated locations,
+values, validated lowercase-scheme `ws`/`wss` relay URLs, and lowercase geohashes. Relay URL host,
+port, path, and query spellings are preserved rather than normalized. The authored common surface supports repeated locations,
optional geohash and summary, repeated participants, categories, absolute-URI references, and
kind-`31924` calendar-inclusion requests. It intentionally does not author deprecated `name` tags.
@@ -167,6 +200,20 @@ index covered by the interval, where `D = floor(unix_seconds / 86400)` and `end`
Authoring derives this sequence rather than accepting it from callers. Strict authored and admitted
time events cover at most 366 UTC days.
+Strict kind-`31924` and kind-`31925` identifiers are syntax-valid 128-bit values encoded as exactly
+22 unpadded base64url characters. This shape does not prove uniqueness; an authoring runtime must
+generate a fresh identifier for every collection or RSVP identity. Strict collection authoring and
+admission require canonical title and optional NIP-51 description text, validated relay hints, and
+duplicate-free event coordinates while still permitting an empty collection. Collection event
+references remain limited to kinds `31922` and `31923`.
+
+Strict RSVP authoring and admission require canonical `a`, optional `e`, and optional `p`
+references. An admitted `p` author hint must match the public key in the `a` coordinate. Authored
+RSVPs never emit `fb` when status is `declined`; inbound baseline and admitted values retain such an
+observation only for diagnostics and return no effective free/busy state. Neither baseline parsing
+nor strict admission proves that a referenced revision exists, that it matches the addressable
+event, or that the RSVP author is authorized to answer for another party.
+
Calendar images have an explicit progression of trust. Tolerant inbound parsing accepts any
absolute image URI. Strict inbound admission requires a structural Blossom hash-path URL, but does
not prove approved-reference policy, byte agreement, upload completion, reachability, or image
@@ -180,9 +227,6 @@ succeeded. Consumers remain responsible for bounded retrieval, redirects, conten
format-safety policy. No calendar model upgrades an observed relay URL into either an upload receipt
or an availability guarantee.
-`RadrootsCalendar` continues to use NIP-52 description content; its kind-`31924` collection
-semantics are governed separately from the kind-`31922` and kind-`31923` authored/inbound split.
-
Product routing uses surface-specific kind classifiers rather than a broad public-social set. Home,
Events, Market, Map, and Profile public-content candidates are explicit. Active listing kind `30402`
can appear in public product surfaces. Report kind `1984` is a moderation/admin candidate, not
@@ -192,7 +236,9 @@ farm CRDT change envelope, farm file metadata, and the supported NIP-29 group ev
`RadrootsRelayList` is not a separate model type in the target contract. Stable listings are
represented through `RadrootsListing`, and NIP-51 standard and list-set entries, including NIP-65
-relay metadata kind `10002`, are represented through `RadrootsList`.
+relay metadata kind `10002`, are represented through `RadrootsList`. NIP-51 taxonomy may classify
+kind `31924` as a calendar list, but generic list and list-set decoding or authoring must reject that kind;
+only the calendar-specific model and codec may parse or publish it.
## Exclusions
diff --git a/contracts/operations.toml b/contracts/operations.toml
@@ -71,6 +71,7 @@ public = [
"RadrootsIanaTimeZoneId",
"RadrootsCalendarUri",
"RadrootsCalendarRequest",
+ "RadrootsCalendarParticipant",
"RadrootsCalendarEventError",
"RadrootsCalendarDate",
"RadrootsAuthoredCalendarDateEvent",
@@ -83,6 +84,20 @@ public = [
"RadrootsCalendarAdmissionError",
"RadrootsAdmittedCalendarDateEvent",
"RadrootsAdmittedCalendarTimeEvent",
+ "RadrootsCalendarUid",
+ "RadrootsCalendarEventReference",
+ "RadrootsCalendarEventRevisionReference",
+ "RadrootsCalendarEventAuthorReference",
+ "RadrootsAuthoredCalendar",
+ "RadrootsParsedNip52CalendarParts",
+ "RadrootsParsedNip52Calendar",
+ "RadrootsAdmittedCalendar",
+ "RadrootsCalendarEventRsvpStatus",
+ "RadrootsCalendarEventFreeBusy",
+ "RadrootsAuthoredCalendarEventRsvp",
+ "RadrootsParsedNip52CalendarEventRsvpParts",
+ "RadrootsParsedNip52CalendarEventRsvp",
+ "RadrootsAdmittedCalendarEventRsvp",
"RadrootsTradeId",
"RadrootsTradeCandidateId",
"RadrootsTradeMutationId",
@@ -742,6 +757,186 @@ rust_types = [
[operations.social_calendar_time_event_admit_radroots_profile.conformance]
vector = "contracts/conformance/vectors/calendar/radroots_profile.v1.json"
+[operations.social_calendar_build_authored_draft]
+domain = "social"
+id = "social.calendar.build_authored_draft"
+stability = "beta"
+inputs = ["RadrootsAuthoredCalendar"]
+outputs = ["RadrootsNip01EventWireParts"]
+error_class = "encode_error"
+deterministic = true
+signing = "none"
+transport = "none"
+
+[operations.social_calendar_build_authored_draft.implementation]
+rust_modules = [
+ "crates/event/src/calendar.rs",
+ "crates/event/src/media.rs",
+ "crates/event_codec/src/calendar/encode.rs",
+]
+rust_types = [
+ "radroots_event::calendar::RadrootsAuthoredCalendar",
+ "radroots_event::calendar::RadrootsCalendarEventError",
+ "radroots_event::calendar::RadrootsCalendarEventReference",
+ "radroots_event::calendar::RadrootsCalendarUid",
+ "radroots_event::media::RadrootsAuthoredImage",
+ "radroots_event::wire::RadrootsNip01EventWireParts",
+ "radroots_event_codec::error::EventEncodeError",
+]
+
+[operations.social_calendar_build_authored_draft.conformance]
+vector = "contracts/conformance/vectors/calendar/radroots_profile.v1.json"
+
+[operations.social_calendar_parse_nip52]
+domain = "social"
+id = "social.calendar.parse_nip52"
+stability = "beta"
+inputs = ["u32", "NostrTags", "String"]
+outputs = ["RadrootsParsedNip52Calendar"]
+error_class = "parse_error"
+deterministic = true
+signing = "none"
+transport = "none"
+
+[operations.social_calendar_parse_nip52.implementation]
+rust_modules = [
+ "crates/event/src/calendar.rs",
+ "crates/event_codec/src/calendar/decode.rs",
+]
+rust_types = [
+ "radroots_event::calendar::RadrootsCalendarEventError",
+ "radroots_event::calendar::RadrootsCalendarEventReference",
+ "radroots_event::calendar::RadrootsCalendarUri",
+ "radroots_event::calendar::RadrootsParsedNip52Calendar",
+ "radroots_event::calendar::RadrootsParsedNip52CalendarParts",
+ "radroots_event_codec::error::EventParseError",
+]
+
+[operations.social_calendar_parse_nip52.conformance]
+vector = "contracts/conformance/vectors/calendar/nip52_baseline.v1.json"
+
+[operations.social_calendar_admit_radroots_profile]
+domain = "social"
+id = "social.calendar.admit_radroots_profile"
+stability = "beta"
+inputs = ["RadrootsParsedNip52Calendar"]
+outputs = ["RadrootsAdmittedCalendar"]
+error_class = "admission_error"
+deterministic = true
+signing = "none"
+transport = "none"
+
+[operations.social_calendar_admit_radroots_profile.implementation]
+rust_modules = [
+ "crates/event/src/calendar.rs",
+ "crates/event_codec/src/calendar/decode.rs",
+]
+rust_types = [
+ "radroots_blossom::RadrootsBlossomBlobUrl",
+ "radroots_event::calendar::RadrootsAdmittedCalendar",
+ "radroots_event::calendar::RadrootsCalendarAdmissionError",
+ "radroots_event::calendar::RadrootsCalendarEventReference",
+ "radroots_event::calendar::RadrootsCalendarUid",
+ "radroots_event::calendar::RadrootsParsedNip52Calendar",
+]
+
+[operations.social_calendar_admit_radroots_profile.conformance]
+vector = "contracts/conformance/vectors/calendar/radroots_profile.v1.json"
+
+[operations.social_calendar_rsvp_build_authored_draft]
+domain = "social"
+id = "social.calendar_rsvp.build_authored_draft"
+stability = "beta"
+inputs = ["RadrootsAuthoredCalendarEventRsvp"]
+outputs = ["RadrootsNip01EventWireParts"]
+error_class = "encode_error"
+deterministic = true
+signing = "none"
+transport = "none"
+
+[operations.social_calendar_rsvp_build_authored_draft.implementation]
+rust_modules = [
+ "crates/event/src/calendar.rs",
+ "crates/event_codec/src/calendar/encode.rs",
+]
+rust_types = [
+ "radroots_event::calendar::RadrootsAuthoredCalendarEventRsvp",
+ "radroots_event::calendar::RadrootsCalendarEventAuthorReference",
+ "radroots_event::calendar::RadrootsCalendarEventError",
+ "radroots_event::calendar::RadrootsCalendarEventReference",
+ "radroots_event::calendar::RadrootsCalendarEventRevisionReference",
+ "radroots_event::calendar::RadrootsCalendarUid",
+ "radroots_event::calendar::RadrootsCalendarEventFreeBusy",
+ "radroots_event::calendar::RadrootsCalendarEventRsvpStatus",
+ "radroots_event::wire::RadrootsNip01EventWireParts",
+ "radroots_event_codec::error::EventEncodeError",
+]
+
+[operations.social_calendar_rsvp_build_authored_draft.conformance]
+vector = "contracts/conformance/vectors/calendar/radroots_profile.v1.json"
+
+[operations.social_calendar_rsvp_parse_nip52]
+domain = "social"
+id = "social.calendar_rsvp.parse_nip52"
+stability = "beta"
+inputs = ["u32", "NostrTags", "String"]
+outputs = ["RadrootsParsedNip52CalendarEventRsvp"]
+error_class = "parse_error"
+deterministic = true
+signing = "none"
+transport = "none"
+
+[operations.social_calendar_rsvp_parse_nip52.implementation]
+rust_modules = [
+ "crates/event/src/calendar.rs",
+ "crates/event_codec/src/calendar/decode.rs",
+]
+rust_types = [
+ "radroots_event::calendar::RadrootsCalendarEventAuthorReference",
+ "radroots_event::calendar::RadrootsCalendarEventError",
+ "radroots_event::calendar::RadrootsCalendarEventReference",
+ "radroots_event::calendar::RadrootsCalendarEventRevisionReference",
+ "radroots_event::calendar::RadrootsParsedNip52CalendarEventRsvp",
+ "radroots_event::calendar::RadrootsParsedNip52CalendarEventRsvpParts",
+ "radroots_event::calendar::RadrootsCalendarEventFreeBusy",
+ "radroots_event::calendar::RadrootsCalendarEventRsvpStatus",
+ "radroots_event_codec::error::EventParseError",
+]
+
+[operations.social_calendar_rsvp_parse_nip52.conformance]
+vector = "contracts/conformance/vectors/calendar/nip52_baseline.v1.json"
+
+[operations.social_calendar_rsvp_admit_radroots_profile]
+domain = "social"
+id = "social.calendar_rsvp.admit_radroots_profile"
+stability = "beta"
+inputs = ["RadrootsParsedNip52CalendarEventRsvp"]
+outputs = ["RadrootsAdmittedCalendarEventRsvp"]
+error_class = "admission_error"
+deterministic = true
+signing = "none"
+transport = "none"
+
+[operations.social_calendar_rsvp_admit_radroots_profile.implementation]
+rust_modules = [
+ "crates/event/src/calendar.rs",
+ "crates/event_codec/src/calendar/decode.rs",
+]
+rust_types = [
+ "radroots_event::calendar::RadrootsAdmittedCalendarEventRsvp",
+ "radroots_event::calendar::RadrootsCalendarAdmissionError",
+ "radroots_event::calendar::RadrootsCalendarEventAuthorReference",
+ "radroots_event::calendar::RadrootsCalendarEventReference",
+ "radroots_event::calendar::RadrootsCalendarEventRevisionReference",
+ "radroots_event::calendar::RadrootsCalendarUid",
+ "radroots_event::calendar::RadrootsParsedNip52CalendarEventRsvp",
+ "radroots_event::calendar::RadrootsCalendarEventFreeBusy",
+ "radroots_event::calendar::RadrootsCalendarEventRsvpStatus",
+]
+
+[operations.social_calendar_rsvp_admit_radroots_profile.conformance]
+vector = "contracts/conformance/vectors/calendar/radroots_profile.v1.json"
+
[operations.trade_canonicalize_mutation]
domain = "trade"
id = "trade.canonicalize_mutation"
diff --git a/crates/event/README b/crates/event/README
@@ -21,12 +21,15 @@ syntax without making a network identity claim. The legacy `RadrootsProfile`
model remains available for compatibility.
The calendar module keeps three different states explicit for NIP-52 kinds
-`31922` and `31923`: the complete structural event envelope, a tolerant
-baseline NIP-52 projection, and a strict Radroots-admitted projection. Envelope
-construction validates structure, not a matching event id or Schnorr
-signature. Callers must perform those cryptographic checks independently and
-keep any parsed or admitted value bound to the verified envelope and expected
-kind.
+`31922`, `31923`, `31924`, and `31925`: the complete structural event envelope,
+a tolerant baseline NIP-52 projection, and a strict Radroots-admitted
+projection. The authored types are `RadrootsAuthoredCalendarDateEvent`,
+`RadrootsAuthoredCalendarTimeEvent`, `RadrootsAuthoredCalendar`, and
+`RadrootsAuthoredCalendarEventRsvp`; their inbound counterparts use matching
+`RadrootsParsedNip52*` and `RadrootsAdmitted*` types. Envelope construction
+validates structure, not a matching event id or Schnorr signature. Callers must
+perform those cryptographic checks independently and keep any parsed or
+admitted value bound to the verified envelope and expected kind.
Baseline projections retain the pinned NIP-52 common fields: repeated
locations, participants, categories, absolute-URI references, kind-`31924`
@@ -45,12 +48,35 @@ not authored. Strict date events reject uppercase `D`, while strict time
events derive or admit only the complete, ascending sequence of UTC-day
indices and cover at most 366 days.
-Inbound images begin as unverified absolute URIs. Strict admission requires a
-structural Blossom hash-path URL but makes no byte or network claim. Authored
-calendar images require the shared `image/*`, byte-verified Blossom descriptor.
-That state is not an upload receipt: a runtime must require successful BUD-02
-upload completion and a bounded retrievability check before signing or
-publishing a media-bearing event.
+Kind `31924` has one calendar-specific contract. It is not decoded or authored
+through either generic NIP-51 list codec. Its NIP-52 detailed description is
+plain-text event content and remains distinct from the optional NIP-51
+`description` tag. It requires one `d` and one `title`, permits optional NIP-51
+`description` and `image` tags, and contains zero or more `a` references to
+kind `31922` or `31923` events. Each reference may have its own relay hint, and
+an empty calendar collection is valid.
+
+Kind `31925` has exactly one required `d`, one `a` event coordinate, and one
+`status`; `e`, `fb`, and `p` are optional singletons. The `a`, `e`, and `p`
+references preserve independent optional relay hints. The `p` tag is an event
+author hint without participant-role semantics, and strict admission requires
+it to match the author in the `a` coordinate. An inbound declined RSVP may
+retain an observed `fb` for diagnostics but exposes no effective free/busy
+state. Authored declined RSVPs cannot carry `fb`.
+
+Strict collection and RSVP identifiers use exactly 22 unpadded base64url
+characters representing 128 bits. The type proves only syntax; the runtime is
+responsible for generating a fresh value for each new identity. Parsing or
+admission does not prove reference existence, revision correspondence, RSVP
+authority, upload completion, or network availability.
+
+Inbound images begin as unverified absolute URIs. Strict admission, including
+kind-`31924` collection images, requires a structural Blossom hash-path URL but
+makes no byte or network claim. Authored calendar images require the shared
+`image/*`, byte-verified Blossom descriptor. That state is not an upload
+receipt: a runtime must require successful BUD-02 upload completion and a
+bounded retrievability check before signing or publishing a media-bearing
+event.
## Field Event Boundary
diff --git a/crates/event/src/calendar.rs b/crates/event/src/calendar.rs
@@ -8,13 +8,10 @@ use alloc::{
use core::{fmt, ops::RangeInclusive, str::FromStr};
use crate::ids::{
- RadrootsAddressableCoordinate, RadrootsDTag, RadrootsPublicKey, RadrootsRelayUrl,
+ RadrootsAddressableCoordinate, RadrootsAddressableCoordinateParts, RadrootsDTag,
+ RadrootsEventId, RadrootsPublicKey, RadrootsRelayUrl,
};
use crate::media::RadrootsAuthoredImage;
-use crate::social::{
- RadrootsCalendarEventFreeBusy, RadrootsCalendarEventRsvpStatus, RadrootsCalendarParticipant,
- RadrootsSocialTarget,
-};
use crate::wire::{
DEFAULT_CONTENT_MAX_BYTES, DEFAULT_TAG_ELEMENT_MAX_BYTES, DEFAULT_TAG_MAX_COUNT,
DEFAULT_TAG_TOTAL_MAX_BYTES,
@@ -30,32 +27,47 @@ pub const RADROOTS_CALENDAR_MAX_PARTICIPANTS: usize = DEFAULT_TAG_MAX_COUNT - 16
any(feature = "serde", test),
derive(serde::Serialize, serde::Deserialize)
)]
-#[derive(Clone, Debug)]
-pub struct RadrootsCalendar {
- pub d_tag: String,
- pub title: String,
- pub events: Vec<RadrootsSocialTarget>,
- #[cfg_attr(
- feature = "serde",
- serde(default, skip_serializing_if = "Option::is_none")
- )]
- pub description: Option<String>,
- #[cfg_attr(
- feature = "serde",
- serde(default, skip_serializing_if = "Option::is_none")
- )]
- pub summary: Option<String>,
- #[cfg_attr(
- feature = "serde",
- serde(default, skip_serializing_if = "Option::is_none")
- )]
- pub image: Option<String>,
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct RadrootsCalendarParticipant {
+ pub pubkey: String,
+ pub relay: Option<String>,
+ pub role: Option<String>,
+}
+
+#[cfg_attr(
+ any(feature = "serde", test),
+ derive(serde::Serialize, serde::Deserialize)
+)]
+#[cfg_attr(any(feature = "serde", test), serde(rename_all = "snake_case"))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsCalendarEventRsvpStatus {
+ Accepted,
+ Declined,
+ Tentative,
+}
+
+#[cfg_attr(
+ any(feature = "serde", test),
+ derive(serde::Serialize, serde::Deserialize)
+)]
+#[cfg_attr(any(feature = "serde", test), serde(rename_all = "snake_case"))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsCalendarEventFreeBusy {
+ Free,
+ Busy,
}
#[non_exhaustive]
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RadrootsCalendarEventError {
InvalidIdentifier,
+ InvalidCalendarUid,
+ InvalidEventReference,
+ InvalidRevisionReference,
+ InvalidAuthorReference,
+ DuplicateEventReference,
+ AuthorHintMismatch,
+ DeclinedFreeBusyForbidden,
InvalidTitle,
InvalidText(&'static str),
InvalidUrl(&'static str),
@@ -97,6 +109,13 @@ impl RadrootsCalendarEventError {
pub const fn code(&self) -> &'static str {
match self {
Self::InvalidIdentifier => "invalid_identifier",
+ Self::InvalidCalendarUid => "invalid_calendar_uid",
+ Self::InvalidEventReference => "invalid_event_reference",
+ Self::InvalidRevisionReference => "invalid_revision_reference",
+ Self::InvalidAuthorReference => "invalid_author_reference",
+ Self::DuplicateEventReference => "duplicate_event_reference",
+ Self::AuthorHintMismatch => "author_hint_mismatch",
+ Self::DeclinedFreeBusyForbidden => "declined_free_busy_forbidden",
Self::InvalidTitle => "invalid_title",
Self::InvalidText(_) => "invalid_text",
Self::InvalidUrl(_) => "invalid_url",
@@ -119,6 +138,25 @@ impl fmt::Display for RadrootsCalendarEventError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidIdentifier => f.write_str("calendar identifier is invalid"),
+ Self::InvalidCalendarUid => f.write_str(
+ "calendar UID must be an unpadded 22-character base64url encoding of 128 bits",
+ ),
+ Self::InvalidEventReference => f.write_str("calendar event reference is invalid"),
+ Self::InvalidRevisionReference => {
+ f.write_str("calendar event revision reference is invalid")
+ }
+ Self::InvalidAuthorReference => {
+ f.write_str("calendar event author reference is invalid")
+ }
+ Self::DuplicateEventReference => {
+ f.write_str("calendar event references must have unique coordinates")
+ }
+ Self::AuthorHintMismatch => {
+ f.write_str("calendar event author hint does not match the event coordinate")
+ }
+ Self::DeclinedFreeBusyForbidden => {
+ f.write_str("an authored declined RSVP cannot carry free/busy state")
+ }
Self::InvalidTitle => f.write_str("calendar title must be canonical visible text"),
Self::InvalidText(field) => write!(f, "calendar {field} must be visible text"),
Self::InvalidUrl(field) => write!(f, "calendar {field} is not a valid URI"),
@@ -159,6 +197,253 @@ impl fmt::Display for RadrootsCalendarEventError {
#[cfg(feature = "std")]
impl std::error::Error for RadrootsCalendarEventError {}
+/// A syntax-valid 128-bit calendar identifier encoded as unpadded base64url.
+///
+/// This type validates the identifier's entropy shape, not uniqueness. The
+/// authoring runtime must still generate a fresh value for every calendar or
+/// RSVP identity it creates.
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct RadrootsCalendarUid(String);
+
+impl RadrootsCalendarUid {
+ pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsCalendarEventError> {
+ let value = value.as_ref();
+ let valid_alphabet = value
+ .bytes()
+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'));
+ let valid_final_quantum = value
+ .as_bytes()
+ .last()
+ .is_some_and(|byte| matches!(byte, b'A' | b'Q' | b'g' | b'w'));
+ if value.len() != 22 || !valid_alphabet || !valid_final_quantum {
+ return Err(RadrootsCalendarEventError::InvalidCalendarUid);
+ }
+ Ok(Self(value.to_string()))
+ }
+
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+
+ pub fn into_string(self) -> String {
+ self.0
+ }
+}
+
+impl fmt::Display for RadrootsCalendarUid {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(self.as_str())
+ }
+}
+
+impl AsRef<str> for RadrootsCalendarUid {
+ fn as_ref(&self) -> &str {
+ self.as_str()
+ }
+}
+
+impl FromStr for RadrootsCalendarUid {
+ type Err = RadrootsCalendarEventError;
+
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
+ Self::parse(value)
+ }
+}
+
+impl TryFrom<&str> for RadrootsCalendarUid {
+ type Error = RadrootsCalendarEventError;
+
+ fn try_from(value: &str) -> Result<Self, Self::Error> {
+ Self::parse(value)
+ }
+}
+
+impl TryFrom<String> for RadrootsCalendarUid {
+ type Error = RadrootsCalendarEventError;
+
+ fn try_from(value: String) -> Result<Self, Self::Error> {
+ Self::parse(value)
+ }
+}
+
+#[cfg(any(feature = "serde", test))]
+impl serde::Serialize for RadrootsCalendarUid {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ serializer.serialize_str(self.as_str())
+ }
+}
+
+/// A raw-preserving NIP-52 address reference to a kind-31922 or kind-31923 event.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsCalendarEventReference {
+ coordinate: RadrootsAddressableCoordinate,
+ kind: u32,
+ author: RadrootsPublicKey,
+ d_tag: RadrootsDTag,
+ relay: Option<String>,
+}
+
+impl RadrootsCalendarEventReference {
+ pub fn parse(
+ coordinate: impl AsRef<str>,
+ relay: Option<&str>,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ let coordinate = RadrootsAddressableCoordinate::parse(coordinate.as_ref())
+ .map_err(|_| RadrootsCalendarEventError::InvalidEventReference)?;
+ let parts = RadrootsAddressableCoordinateParts::parse(coordinate.as_str())
+ .map_err(|_| RadrootsCalendarEventError::InvalidEventReference)?;
+ if !matches!(
+ parts.kind,
+ crate::kinds::KIND_CALENDAR_DATE_EVENT | crate::kinds::KIND_CALENDAR_TIME_EVENT
+ ) {
+ return Err(RadrootsCalendarEventError::InvalidEventReference);
+ }
+ let relay = parse_calendar_reference_relay(
+ relay,
+ RadrootsCalendarEventError::InvalidEventReference,
+ )?;
+ Ok(Self {
+ coordinate,
+ kind: parts.kind,
+ author: parts.pubkey,
+ d_tag: parts.d_tag,
+ relay,
+ })
+ }
+
+ pub fn coordinate(&self) -> &RadrootsAddressableCoordinate {
+ &self.coordinate
+ }
+
+ pub const fn kind(&self) -> u32 {
+ self.kind
+ }
+
+ pub fn author(&self) -> &RadrootsPublicKey {
+ &self.author
+ }
+
+ pub fn d_tag(&self) -> &RadrootsDTag {
+ &self.d_tag
+ }
+
+ pub fn relay(&self) -> Option<&str> {
+ self.relay.as_deref()
+ }
+
+ pub fn is_canonical(&self) -> bool {
+ // Nostr does not define relay URL normalization. Strict admission validates the
+ // lowercase ws/wss syntax while preserving the caller's host, port, path, and query.
+ self.coordinate.as_str() == format!("{}:{}:{}", self.kind, self.author, self.d_tag)
+ && self
+ .relay()
+ .is_none_or(|relay| RadrootsRelayUrl::parse(relay).is_ok())
+ }
+
+ fn has_same_coordinate(&self, other: &Self) -> bool {
+ self.kind == other.kind && self.author == other.author && self.d_tag == other.d_tag
+ }
+}
+
+/// A raw-preserving event-id reference to a specific NIP-52 event revision.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsCalendarEventRevisionReference {
+ raw_event_id: String,
+ event_id: RadrootsEventId,
+ relay: Option<String>,
+}
+
+impl RadrootsCalendarEventRevisionReference {
+ pub fn parse(
+ event_id: impl AsRef<str>,
+ relay: Option<&str>,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ let raw_event_id = event_id.as_ref();
+ let event_id = RadrootsEventId::parse(raw_event_id)
+ .map_err(|_| RadrootsCalendarEventError::InvalidRevisionReference)?;
+ let relay = parse_calendar_reference_relay(
+ relay,
+ RadrootsCalendarEventError::InvalidRevisionReference,
+ )?;
+ Ok(Self {
+ raw_event_id: raw_event_id.to_string(),
+ event_id,
+ relay,
+ })
+ }
+
+ pub fn raw_event_id(&self) -> &str {
+ &self.raw_event_id
+ }
+
+ pub fn event_id(&self) -> &RadrootsEventId {
+ &self.event_id
+ }
+
+ pub fn relay(&self) -> Option<&str> {
+ self.relay.as_deref()
+ }
+
+ pub fn is_canonical(&self) -> bool {
+ // Relay hints use strict Radroots syntax; their raw URL spelling is not normalized.
+ self.raw_event_id == self.event_id.as_str()
+ && self
+ .relay()
+ .is_none_or(|relay| RadrootsRelayUrl::parse(relay).is_ok())
+ }
+}
+
+/// A raw-preserving optional RSVP author hint. This reference has no role field.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsCalendarEventAuthorReference {
+ raw_pubkey: String,
+ pubkey: RadrootsPublicKey,
+ relay: Option<String>,
+}
+
+impl RadrootsCalendarEventAuthorReference {
+ pub fn parse(
+ pubkey: impl AsRef<str>,
+ relay: Option<&str>,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ let raw_pubkey = pubkey.as_ref();
+ let pubkey = RadrootsPublicKey::parse(raw_pubkey)
+ .map_err(|_| RadrootsCalendarEventError::InvalidAuthorReference)?;
+ let relay = parse_calendar_reference_relay(
+ relay,
+ RadrootsCalendarEventError::InvalidAuthorReference,
+ )?;
+ Ok(Self {
+ raw_pubkey: raw_pubkey.to_string(),
+ pubkey,
+ relay,
+ })
+ }
+
+ pub fn raw_pubkey(&self) -> &str {
+ &self.raw_pubkey
+ }
+
+ pub fn pubkey(&self) -> &RadrootsPublicKey {
+ &self.pubkey
+ }
+
+ pub fn relay(&self) -> Option<&str> {
+ self.relay.as_deref()
+ }
+
+ pub fn is_canonical(&self) -> bool {
+ // Relay hints use strict Radroots syntax; their raw URL spelling is not normalized.
+ self.raw_pubkey == self.pubkey.as_str()
+ && self
+ .relay()
+ .is_none_or(|relay| RadrootsRelayUrl::parse(relay).is_ok())
+ }
+}
+
/// A canonical IANA Time Zone Database identifier.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct RadrootsIanaTimeZoneId(String);
@@ -286,6 +571,253 @@ impl<'de> serde::Deserialize<'de> for RadrootsCalendarUri {
}
}
+/// Strict authored representation of a NIP-52 kind-31924 calendar collection.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsAuthoredCalendar {
+ uid: RadrootsCalendarUid,
+ title: String,
+ content: String,
+ event_references: Vec<RadrootsCalendarEventReference>,
+ list_description: Option<String>,
+ image: Option<RadrootsAuthoredImage>,
+}
+
+impl RadrootsAuthoredCalendar {
+ pub fn new(
+ uid: RadrootsCalendarUid,
+ title: impl Into<String>,
+ content: impl Into<String>,
+ event_references: Vec<RadrootsCalendarEventReference>,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ let title = validated_title(title.into())?;
+ let content = content.into();
+ validate_calendar_content(&content)?;
+ validate_strict_calendar_event_references(&event_references)?;
+ let authored = Self {
+ uid,
+ title,
+ content,
+ event_references,
+ list_description: None,
+ image: None,
+ };
+ authored.validate_budget()?;
+ Ok(authored)
+ }
+
+ pub fn with_list_description(
+ mut self,
+ value: impl Into<String>,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ let value = value.into();
+ validate_canonical_calendar_tag_text(&value, "description")?;
+ self.list_description = Some(value);
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn with_image(
+ mut self,
+ image: RadrootsAuthoredImage,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ self.image = Some(image);
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn uid(&self) -> &RadrootsCalendarUid {
+ &self.uid
+ }
+
+ pub fn title(&self) -> &str {
+ &self.title
+ }
+
+ pub fn content(&self) -> &str {
+ &self.content
+ }
+
+ pub fn event_references(&self) -> &[RadrootsCalendarEventReference] {
+ &self.event_references
+ }
+
+ pub fn list_description(&self) -> Option<&str> {
+ self.list_description.as_deref()
+ }
+
+ pub fn image(&self) -> Option<&RadrootsAuthoredImage> {
+ self.image.as_ref()
+ }
+
+ fn validate_budget(&self) -> Result<(), RadrootsCalendarEventError> {
+ validate_calendar_collection_budget(
+ self.uid.as_str(),
+ &self.title,
+ &self.content,
+ &self.event_references,
+ self.list_description.as_deref(),
+ self.image
+ .as_ref()
+ .map(|image| image.descriptor().url().as_str()),
+ )
+ }
+}
+
+/// Constructor input for the tolerant, raw-preserving kind-31924 parse layer.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsParsedNip52CalendarParts {
+ pub d_tag: String,
+ pub title: String,
+ pub content: String,
+ pub event_references: Vec<RadrootsCalendarEventReference>,
+ pub list_description: Option<String>,
+ pub image: Option<RadrootsCalendarUri>,
+}
+
+/// Structurally valid kind-31924 data. Canonical admission is a separate step.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsParsedNip52Calendar {
+ d_tag: String,
+ title: String,
+ content: String,
+ event_references: Vec<RadrootsCalendarEventReference>,
+ list_description: Option<String>,
+ image: Option<RadrootsCalendarUri>,
+}
+
+impl RadrootsParsedNip52Calendar {
+ pub fn try_new(
+ parts: RadrootsParsedNip52CalendarParts,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ validate_calendar_tag_text(&parts.d_tag, "d")?;
+ validate_calendar_tag_text(&parts.title, "title")?;
+ validate_calendar_content(&parts.content)?;
+ if let Some(description) = parts.list_description.as_deref() {
+ validate_calendar_tag_text(description, "description")?;
+ }
+ validate_calendar_collection_budget(
+ &parts.d_tag,
+ &parts.title,
+ &parts.content,
+ &parts.event_references,
+ parts.list_description.as_deref(),
+ parts.image.as_ref().map(RadrootsCalendarUri::as_str),
+ )?;
+ Ok(Self {
+ d_tag: parts.d_tag,
+ title: parts.title,
+ content: parts.content,
+ event_references: parts.event_references,
+ list_description: parts.list_description,
+ image: parts.image,
+ })
+ }
+
+ pub fn d_tag(&self) -> &str {
+ &self.d_tag
+ }
+
+ pub fn title(&self) -> &str {
+ &self.title
+ }
+
+ pub fn content(&self) -> &str {
+ &self.content
+ }
+
+ pub fn event_references(&self) -> &[RadrootsCalendarEventReference] {
+ &self.event_references
+ }
+
+ pub fn list_description(&self) -> Option<&str> {
+ self.list_description.as_deref()
+ }
+
+ pub fn image(&self) -> Option<&RadrootsCalendarUri> {
+ self.image.as_ref()
+ }
+}
+
+/// Canonical Radroots admission of a parsed kind-31924 calendar collection.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsAdmittedCalendar {
+ parsed: RadrootsParsedNip52Calendar,
+ uid: RadrootsCalendarUid,
+ blossom_image: Option<RadrootsBlossomBlobUrl>,
+}
+
+impl RadrootsAdmittedCalendar {
+ pub fn try_from_parsed(
+ parsed: RadrootsParsedNip52Calendar,
+ ) -> Result<Self, RadrootsCalendarAdmissionError> {
+ let uid = RadrootsCalendarUid::parse(parsed.d_tag())
+ .map_err(|_| RadrootsCalendarAdmissionError::NonCanonicalField("d"))?;
+ if !canonical_calendar_tag_text_is_valid(parsed.title()) {
+ return Err(RadrootsCalendarAdmissionError::NonCanonicalField("title"));
+ }
+ if parsed
+ .list_description()
+ .is_some_and(|description| !canonical_calendar_tag_text_is_valid(description))
+ {
+ return Err(RadrootsCalendarAdmissionError::NonCanonicalField(
+ "description",
+ ));
+ }
+ if parsed
+ .event_references()
+ .iter()
+ .any(|reference| !reference.is_canonical())
+ {
+ return Err(RadrootsCalendarAdmissionError::NonCanonicalField(
+ "event_reference",
+ ));
+ }
+ if calendar_event_references_have_duplicates(parsed.event_references()) {
+ return Err(RadrootsCalendarAdmissionError::DuplicateEventReference);
+ }
+ let blossom_image = parsed
+ .image()
+ .map(|image| {
+ RadrootsBlossomBlobUrl::parse(image.as_str())
+ .map_err(|_| RadrootsCalendarAdmissionError::NonBlossomImage)
+ })
+ .transpose()?;
+ Ok(Self {
+ parsed,
+ uid,
+ blossom_image,
+ })
+ }
+
+ pub fn parsed(&self) -> &RadrootsParsedNip52Calendar {
+ &self.parsed
+ }
+
+ pub fn uid(&self) -> &RadrootsCalendarUid {
+ &self.uid
+ }
+
+ pub fn title(&self) -> &str {
+ self.parsed.title()
+ }
+
+ pub fn content(&self) -> &str {
+ self.parsed.content()
+ }
+
+ pub fn event_references(&self) -> &[RadrootsCalendarEventReference] {
+ self.parsed.event_references()
+ }
+
+ pub fn list_description(&self) -> Option<&str> {
+ self.parsed.list_description()
+ }
+
+ pub fn blossom_image(&self) -> Option<&RadrootsBlossomBlobUrl> {
+ self.blossom_image.as_ref()
+ }
+}
+
#[cfg_attr(any(feature = "serde", test), derive(serde::Serialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RadrootsCalendarRequest {
@@ -1156,6 +1688,8 @@ impl RadrootsParsedNip52CalendarTimeEvent {
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RadrootsCalendarAdmissionError {
NonCanonicalField(&'static str),
+ DuplicateEventReference,
+ AuthorHintMismatch,
ForbiddenDateDayIndex,
IncompleteDayCoverage,
CoveredDayLimitExceeded { max: u64, actual: u64 },
@@ -1166,6 +1700,8 @@ impl RadrootsCalendarAdmissionError {
pub const fn code(&self) -> &'static str {
match self {
Self::NonCanonicalField(_) => "non_canonical_field",
+ Self::DuplicateEventReference => "duplicate_event_reference",
+ Self::AuthorHintMismatch => "author_hint_mismatch",
Self::ForbiddenDateDayIndex => "forbidden_date_day_index",
Self::IncompleteDayCoverage => "incomplete_day_coverage",
Self::CoveredDayLimitExceeded { .. } => "covered_day_limit_exceeded",
@@ -1178,6 +1714,12 @@ impl fmt::Display for RadrootsCalendarAdmissionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NonCanonicalField(field) => write!(f, "calendar {field} is not canonical"),
+ Self::DuplicateEventReference => {
+ f.write_str("calendar event references must have unique coordinates")
+ }
+ Self::AuthorHintMismatch => {
+ f.write_str("calendar event author hint does not match the event coordinate")
+ }
Self::ForbiddenDateDayIndex => {
f.write_str("calendar date event carries a forbidden uppercase-D extension")
}
@@ -1411,6 +1953,57 @@ pub fn calendar_relay_url_is_valid(value: &str) -> bool {
&& !authority.contains('@')
}
+fn parse_calendar_reference_relay(
+ relay: Option<&str>,
+ error: RadrootsCalendarEventError,
+) -> Result<Option<String>, RadrootsCalendarEventError> {
+ relay
+ .map(|relay| {
+ if calendar_relay_url_is_valid(relay) {
+ Ok(relay.to_string())
+ } else {
+ Err(error.clone())
+ }
+ })
+ .transpose()
+}
+
+fn calendar_event_references_have_duplicates(
+ references: &[RadrootsCalendarEventReference],
+) -> bool {
+ references.iter().enumerate().any(|(index, reference)| {
+ references
+ .iter()
+ .skip(index + 1)
+ .any(|candidate| reference.has_same_coordinate(candidate))
+ })
+}
+
+fn validate_strict_calendar_event_references(
+ references: &[RadrootsCalendarEventReference],
+) -> Result<(), RadrootsCalendarEventError> {
+ if references.iter().any(|reference| !reference.is_canonical()) {
+ return Err(RadrootsCalendarEventError::InvalidEventReference);
+ }
+ if calendar_event_references_have_duplicates(references) {
+ return Err(RadrootsCalendarEventError::DuplicateEventReference);
+ }
+ Ok(())
+}
+
+fn validate_strict_calendar_author_hint(
+ event_reference: &RadrootsCalendarEventReference,
+ author_hint: &RadrootsCalendarEventAuthorReference,
+) -> Result<(), RadrootsCalendarEventError> {
+ if !author_hint.is_canonical() {
+ return Err(RadrootsCalendarEventError::InvalidAuthorReference);
+ }
+ if author_hint.pubkey() != event_reference.author() {
+ return Err(RadrootsCalendarEventError::AuthorHintMismatch);
+ }
+ Ok(())
+}
+
fn validate_calendar_tag_text(
value: &str,
field: &'static str,
@@ -1683,23 +2276,102 @@ fn append_authored_common_tags_for_budget(
tags.push(tag);
}
}
- tags.extend(categories.iter().map(|category| budget_tag("t", category)));
- tags.extend(
- references
- .iter()
- .map(|reference| budget_tag("r", reference.as_str())),
- );
- for request in calendar_requests {
- let mut tag = budget_tag("a", request.calendar().as_str());
- if let Some(relay) = request.relay() {
+ tags.extend(categories.iter().map(|category| budget_tag("t", category)));
+ tags.extend(
+ references
+ .iter()
+ .map(|reference| budget_tag("r", reference.as_str())),
+ );
+ for request in calendar_requests {
+ let mut tag = budget_tag("a", request.calendar().as_str());
+ if let Some(relay) = request.relay() {
+ tag.push(relay.to_string());
+ }
+ tags.push(tag);
+ }
+}
+
+fn budget_tag(key: &str, value: &str) -> Vec<String> {
+ vec![key.to_string(), value.to_string()]
+}
+
+fn validate_calendar_collection_budget(
+ d_tag: &str,
+ title: &str,
+ content: &str,
+ event_references: &[RadrootsCalendarEventReference],
+ list_description: Option<&str>,
+ image: Option<&str>,
+) -> Result<(), RadrootsCalendarEventError> {
+ let mut tags = vec![budget_tag("d", d_tag), budget_tag("title", title)];
+ for reference in event_references {
+ let mut tag = budget_tag("a", reference.coordinate().as_str());
+ if let Some(relay) = reference.relay() {
+ tag.push(relay.to_string());
+ }
+ tags.push(tag);
+ }
+ if let Some(description) = list_description {
+ tags.push(budget_tag("description", description));
+ }
+ if let Some(image) = image {
+ tags.push(budget_tag("image", image));
+ }
+ validate_authored_calendar_budget(Some(content), tags)
+}
+
+#[allow(clippy::too_many_arguments)]
+fn validate_calendar_rsvp_budget(
+ d_tag: &str,
+ event_reference: &RadrootsCalendarEventReference,
+ revision_reference: Option<&RadrootsCalendarEventRevisionReference>,
+ status: &RadrootsCalendarEventRsvpStatus,
+ free_busy: Option<&RadrootsCalendarEventFreeBusy>,
+ author_hint: Option<&RadrootsCalendarEventAuthorReference>,
+ note: Option<&str>,
+) -> Result<(), RadrootsCalendarEventError> {
+ let mut event_tag = budget_tag("a", event_reference.coordinate().as_str());
+ if let Some(relay) = event_reference.relay() {
+ event_tag.push(relay.to_string());
+ }
+ let mut tags = vec![
+ budget_tag("d", d_tag),
+ event_tag,
+ budget_tag("status", calendar_rsvp_status_wire_value(status)),
+ ];
+ if let Some(reference) = revision_reference {
+ let mut tag = budget_tag("e", reference.raw_event_id());
+ if let Some(relay) = reference.relay() {
+ tag.push(relay.to_string());
+ }
+ tags.push(tag);
+ }
+ if let Some(free_busy) = free_busy {
+ tags.push(budget_tag("fb", calendar_free_busy_wire_value(free_busy)));
+ }
+ if let Some(author_hint) = author_hint {
+ let mut tag = budget_tag("p", author_hint.raw_pubkey());
+ if let Some(relay) = author_hint.relay() {
tag.push(relay.to_string());
}
tags.push(tag);
}
+ validate_authored_calendar_budget(note, tags)
}
-fn budget_tag(key: &str, value: &str) -> Vec<String> {
- vec![key.to_string(), value.to_string()]
+fn calendar_rsvp_status_wire_value(status: &RadrootsCalendarEventRsvpStatus) -> &'static str {
+ match status {
+ RadrootsCalendarEventRsvpStatus::Accepted => "accepted",
+ RadrootsCalendarEventRsvpStatus::Declined => "declined",
+ RadrootsCalendarEventRsvpStatus::Tentative => "tentative",
+ }
+}
+
+fn calendar_free_busy_wire_value(free_busy: &RadrootsCalendarEventFreeBusy) -> &'static str {
+ match free_busy {
+ RadrootsCalendarEventFreeBusy::Free => "free",
+ RadrootsCalendarEventFreeBusy::Busy => "busy",
+ }
}
fn validate_authored_calendar_budget(
@@ -1761,35 +2433,293 @@ const fn is_leap_year(year: u16) -> bool {
year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400))
}
-#[cfg_attr(
- any(feature = "serde", test),
- derive(serde::Serialize, serde::Deserialize)
-)]
-#[derive(Clone, Debug)]
-pub struct RadrootsCalendarEventRsvp {
+/// Strict authored representation of a NIP-52 kind-31925 RSVP.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsAuthoredCalendarEventRsvp {
+ uid: RadrootsCalendarUid,
+ event_reference: RadrootsCalendarEventReference,
+ revision_reference: Option<RadrootsCalendarEventRevisionReference>,
+ status: RadrootsCalendarEventRsvpStatus,
+ free_busy: Option<RadrootsCalendarEventFreeBusy>,
+ author_hint: Option<RadrootsCalendarEventAuthorReference>,
+ note: Option<String>,
+}
+
+impl RadrootsAuthoredCalendarEventRsvp {
+ pub fn new(
+ uid: RadrootsCalendarUid,
+ event_reference: RadrootsCalendarEventReference,
+ status: RadrootsCalendarEventRsvpStatus,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ if !event_reference.is_canonical() {
+ return Err(RadrootsCalendarEventError::InvalidEventReference);
+ }
+ let authored = Self {
+ uid,
+ event_reference,
+ revision_reference: None,
+ status,
+ free_busy: None,
+ author_hint: None,
+ note: None,
+ };
+ authored.validate_budget()?;
+ Ok(authored)
+ }
+
+ pub fn with_revision_reference(
+ mut self,
+ revision_reference: RadrootsCalendarEventRevisionReference,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ if !revision_reference.is_canonical() {
+ return Err(RadrootsCalendarEventError::InvalidRevisionReference);
+ }
+ self.revision_reference = Some(revision_reference);
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn with_free_busy(
+ mut self,
+ free_busy: RadrootsCalendarEventFreeBusy,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ if self.status == RadrootsCalendarEventRsvpStatus::Declined {
+ return Err(RadrootsCalendarEventError::DeclinedFreeBusyForbidden);
+ }
+ self.free_busy = Some(free_busy);
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn with_author_hint(
+ mut self,
+ author_hint: RadrootsCalendarEventAuthorReference,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ validate_strict_calendar_author_hint(&self.event_reference, &author_hint)?;
+ self.author_hint = Some(author_hint);
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn with_note(
+ mut self,
+ note: impl Into<String>,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ let note = note.into();
+ validate_calendar_content(¬e)?;
+ self.note = (!note.is_empty()).then_some(note);
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn uid(&self) -> &RadrootsCalendarUid {
+ &self.uid
+ }
+
+ pub fn event_reference(&self) -> &RadrootsCalendarEventReference {
+ &self.event_reference
+ }
+
+ pub fn revision_reference(&self) -> Option<&RadrootsCalendarEventRevisionReference> {
+ self.revision_reference.as_ref()
+ }
+
+ pub fn status(&self) -> &RadrootsCalendarEventRsvpStatus {
+ &self.status
+ }
+
+ pub fn observed_free_busy(&self) -> Option<&RadrootsCalendarEventFreeBusy> {
+ self.free_busy.as_ref()
+ }
+
+ pub fn effective_free_busy(&self) -> Option<&RadrootsCalendarEventFreeBusy> {
+ self.free_busy.as_ref()
+ }
+
+ pub fn author_hint(&self) -> Option<&RadrootsCalendarEventAuthorReference> {
+ self.author_hint.as_ref()
+ }
+
+ pub fn note(&self) -> Option<&str> {
+ self.note.as_deref()
+ }
+
+ fn validate_budget(&self) -> Result<(), RadrootsCalendarEventError> {
+ validate_calendar_rsvp_budget(
+ self.uid.as_str(),
+ &self.event_reference,
+ self.revision_reference.as_ref(),
+ &self.status,
+ self.free_busy.as_ref(),
+ self.author_hint.as_ref(),
+ self.note.as_deref(),
+ )
+ }
+}
+
+/// Constructor input for the tolerant, raw-preserving kind-31925 parse layer.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsParsedNip52CalendarEventRsvpParts {
pub d_tag: String,
- pub event: RadrootsSocialTarget,
- #[cfg_attr(
- feature = "serde",
- serde(default, skip_serializing_if = "Option::is_none")
- )]
- pub event_id: Option<String>,
+ pub event_reference: RadrootsCalendarEventReference,
+ pub revision_reference: Option<RadrootsCalendarEventRevisionReference>,
pub status: RadrootsCalendarEventRsvpStatus,
- #[cfg_attr(
- feature = "serde",
- serde(default, skip_serializing_if = "Option::is_none")
- )]
- pub free_busy: Option<RadrootsCalendarEventFreeBusy>,
- #[cfg_attr(
- feature = "serde",
- serde(default, skip_serializing_if = "Option::is_none")
- )]
+ pub observed_free_busy: Option<RadrootsCalendarEventFreeBusy>,
+ pub author_hint: Option<RadrootsCalendarEventAuthorReference>,
pub note: Option<String>,
- #[cfg_attr(
- feature = "serde",
- serde(default, skip_serializing_if = "Option::is_none")
- )]
- pub participants: Option<Vec<RadrootsCalendarParticipant>>,
+}
+
+/// Structurally valid kind-31925 data. Canonical admission is a separate step.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsParsedNip52CalendarEventRsvp {
+ d_tag: String,
+ event_reference: RadrootsCalendarEventReference,
+ revision_reference: Option<RadrootsCalendarEventRevisionReference>,
+ status: RadrootsCalendarEventRsvpStatus,
+ observed_free_busy: Option<RadrootsCalendarEventFreeBusy>,
+ author_hint: Option<RadrootsCalendarEventAuthorReference>,
+ note: Option<String>,
+}
+
+impl RadrootsParsedNip52CalendarEventRsvp {
+ pub fn try_new(
+ parts: RadrootsParsedNip52CalendarEventRsvpParts,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ validate_calendar_tag_text(&parts.d_tag, "d")?;
+ if let Some(note) = parts.note.as_deref() {
+ validate_calendar_content(note)?;
+ }
+ validate_calendar_rsvp_budget(
+ &parts.d_tag,
+ &parts.event_reference,
+ parts.revision_reference.as_ref(),
+ &parts.status,
+ parts.observed_free_busy.as_ref(),
+ parts.author_hint.as_ref(),
+ parts.note.as_deref(),
+ )?;
+ Ok(Self {
+ d_tag: parts.d_tag,
+ event_reference: parts.event_reference,
+ revision_reference: parts.revision_reference,
+ status: parts.status,
+ observed_free_busy: parts.observed_free_busy,
+ author_hint: parts.author_hint,
+ note: parts.note,
+ })
+ }
+
+ pub fn d_tag(&self) -> &str {
+ &self.d_tag
+ }
+
+ pub fn event_reference(&self) -> &RadrootsCalendarEventReference {
+ &self.event_reference
+ }
+
+ pub fn revision_reference(&self) -> Option<&RadrootsCalendarEventRevisionReference> {
+ self.revision_reference.as_ref()
+ }
+
+ pub fn status(&self) -> &RadrootsCalendarEventRsvpStatus {
+ &self.status
+ }
+
+ pub fn observed_free_busy(&self) -> Option<&RadrootsCalendarEventFreeBusy> {
+ self.observed_free_busy.as_ref()
+ }
+
+ pub fn effective_free_busy(&self) -> Option<&RadrootsCalendarEventFreeBusy> {
+ if self.status == RadrootsCalendarEventRsvpStatus::Declined {
+ None
+ } else {
+ self.observed_free_busy.as_ref()
+ }
+ }
+
+ pub fn author_hint(&self) -> Option<&RadrootsCalendarEventAuthorReference> {
+ self.author_hint.as_ref()
+ }
+
+ pub fn note(&self) -> Option<&str> {
+ self.note.as_deref()
+ }
+}
+
+/// Canonical Radroots admission of a parsed kind-31925 RSVP.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsAdmittedCalendarEventRsvp {
+ parsed: RadrootsParsedNip52CalendarEventRsvp,
+ uid: RadrootsCalendarUid,
+}
+
+impl RadrootsAdmittedCalendarEventRsvp {
+ pub fn try_from_parsed(
+ parsed: RadrootsParsedNip52CalendarEventRsvp,
+ ) -> Result<Self, RadrootsCalendarAdmissionError> {
+ let uid = RadrootsCalendarUid::parse(parsed.d_tag())
+ .map_err(|_| RadrootsCalendarAdmissionError::NonCanonicalField("d"))?;
+ if !parsed.event_reference().is_canonical() {
+ return Err(RadrootsCalendarAdmissionError::NonCanonicalField(
+ "event_reference",
+ ));
+ }
+ if parsed
+ .revision_reference()
+ .is_some_and(|reference| !reference.is_canonical())
+ {
+ return Err(RadrootsCalendarAdmissionError::NonCanonicalField(
+ "revision_reference",
+ ));
+ }
+ if let Some(author_hint) = parsed.author_hint() {
+ if !author_hint.is_canonical() {
+ return Err(RadrootsCalendarAdmissionError::NonCanonicalField(
+ "author_hint",
+ ));
+ }
+ if author_hint.pubkey() != parsed.event_reference().author() {
+ return Err(RadrootsCalendarAdmissionError::AuthorHintMismatch);
+ }
+ }
+ Ok(Self { parsed, uid })
+ }
+
+ pub fn parsed(&self) -> &RadrootsParsedNip52CalendarEventRsvp {
+ &self.parsed
+ }
+
+ pub fn uid(&self) -> &RadrootsCalendarUid {
+ &self.uid
+ }
+
+ pub fn event_reference(&self) -> &RadrootsCalendarEventReference {
+ self.parsed.event_reference()
+ }
+
+ pub fn revision_reference(&self) -> Option<&RadrootsCalendarEventRevisionReference> {
+ self.parsed.revision_reference()
+ }
+
+ pub fn status(&self) -> &RadrootsCalendarEventRsvpStatus {
+ self.parsed.status()
+ }
+
+ pub fn observed_free_busy(&self) -> Option<&RadrootsCalendarEventFreeBusy> {
+ self.parsed.observed_free_busy()
+ }
+
+ pub fn effective_free_busy(&self) -> Option<&RadrootsCalendarEventFreeBusy> {
+ self.parsed.effective_free_busy()
+ }
+
+ pub fn author_hint(&self) -> Option<&RadrootsCalendarEventAuthorReference> {
+ self.parsed.author_hint()
+ }
+
+ pub fn note(&self) -> Option<&str> {
+ self.parsed.note()
+ }
}
#[cfg(test)]
@@ -2008,51 +2938,313 @@ mod tests {
}
#[test]
- fn calendar_collection_represents_event_address_refs() {
- let calendar = RadrootsCalendar {
- d_tag: "farm-calendar".to_string(),
- title: "farm calendar".to_string(),
- events: vec![RadrootsSocialTarget::Address {
- address: "31923:pubkey:wash-pack".to_string(),
- author: None,
- event_kind: Some(31923),
- relays: None,
- }],
- description: Some("Shared farm operations schedule.".to_string()),
- summary: None,
+ fn calendar_uid_requires_exact_unpadded_128_bit_base64url_shape() {
+ for valid in [
+ "AAAAAAAAAAAAAAAAAAAAAA",
+ "AAAAAAAAAAAAAAAAAAAAAQ",
+ "_____________________w",
+ ] {
+ assert_eq!(RadrootsCalendarUid::parse(valid).unwrap().as_str(), valid);
+ }
+ for invalid in [
+ "AAAAAAAAAAAAAAAAAAAAA",
+ "AAAAAAAAAAAAAAAAAAAAAAA",
+ "AAAAAAAAAAAAAAAAAAAAA=",
+ "AAAAAAAAAAAAAAAAAAAAA+",
+ "AAAAAAAAAAAAAAAAAAAAAB",
+ ] {
+ assert_eq!(
+ RadrootsCalendarUid::parse(invalid),
+ Err(RadrootsCalendarEventError::InvalidCalendarUid),
+ "{invalid}"
+ );
+ }
+ }
+
+ #[test]
+ fn calendar_references_preserve_structural_wire_values_and_expose_canonicality() {
+ let uppercase_author = "A".repeat(64);
+ let event = RadrootsCalendarEventReference::parse(
+ format!("031923:{uppercase_author}:wash-pack"),
+ Some("WSS://Relay.Example/events"),
+ )
+ .unwrap();
+ assert_eq!(
+ event.coordinate().as_str(),
+ format!("031923:{uppercase_author}:wash-pack")
+ );
+ assert_eq!(event.author().as_str(), "a".repeat(64));
+ assert_eq!(event.relay(), Some("WSS://Relay.Example/events"));
+ assert!(!event.is_canonical());
+
+ let revision = RadrootsCalendarEventRevisionReference::parse(
+ "B".repeat(64),
+ Some("WSS://revision.example"),
+ )
+ .unwrap();
+ assert_eq!(revision.raw_event_id(), "B".repeat(64));
+ assert_eq!(revision.event_id().as_str(), "b".repeat(64));
+ assert!(!revision.is_canonical());
+
+ let author = RadrootsCalendarEventAuthorReference::parse(
+ uppercase_author.clone(),
+ Some("WSS://author.example"),
+ )
+ .unwrap();
+ assert_eq!(author.raw_pubkey(), uppercase_author);
+ assert_eq!(author.pubkey().as_str(), "a".repeat(64));
+ assert!(!author.is_canonical());
+
+ assert_eq!(
+ RadrootsCalendarEventReference::parse(
+ format!("31924:{}:not-an-event", "a".repeat(64)),
+ None,
+ ),
+ Err(RadrootsCalendarEventError::InvalidEventReference)
+ );
+ }
+
+ #[test]
+ fn calendar_collection_layers_keep_baseline_tolerance_out_of_strict_states() {
+ let uid = RadrootsCalendarUid::parse("AAAAAAAAAAAAAAAAAAAAAA").unwrap();
+ let event = canonical_event_reference("wash-pack");
+
+ let empty = RadrootsAuthoredCalendar::new(
+ uid.clone(),
+ "Farm calendar",
+ "Shared farm operations schedule.",
+ Vec::new(),
+ )
+ .unwrap();
+ assert!(empty.event_references().is_empty());
+
+ assert_eq!(
+ RadrootsAuthoredCalendar::new(
+ uid.clone(),
+ "Farm calendar",
+ "",
+ vec![event.clone(), event.clone()],
+ ),
+ Err(RadrootsCalendarEventError::DuplicateEventReference)
+ );
+
+ let noncanonical = RadrootsCalendarEventReference::parse(
+ format!("031923:{}:wash-pack", "A".repeat(64)),
+ None,
+ )
+ .unwrap();
+ assert_eq!(
+ RadrootsAuthoredCalendar::new(uid.clone(), "Farm calendar", "", vec![noncanonical],),
+ Err(RadrootsCalendarEventError::InvalidEventReference)
+ );
+
+ let parsed = RadrootsParsedNip52Calendar::try_new(RadrootsParsedNip52CalendarParts {
+ d_tag: uid.to_string(),
+ title: " Farm calendar ".to_string(),
+ content: "Shared farm operations schedule.".to_string(),
+ event_references: vec![event.clone(), event],
+ list_description: Some(" Local operations ".to_string()),
image: None,
- };
+ })
+ .unwrap();
+ assert_eq!(parsed.event_references().len(), 2);
+ assert_eq!(
+ RadrootsAdmittedCalendar::try_from_parsed(parsed),
+ Err(RadrootsCalendarAdmissionError::NonCanonicalField("title"))
+ );
- assert_eq!(calendar.d_tag, "farm-calendar");
- assert_eq!(calendar.events.len(), 1);
- assert!(matches!(
- calendar.events[0],
- RadrootsSocialTarget::Address { .. }
- ));
+ let parsed = RadrootsParsedNip52Calendar::try_new(RadrootsParsedNip52CalendarParts {
+ d_tag: uid.to_string(),
+ title: "Farm calendar".to_string(),
+ content: String::new(),
+ event_references: Vec::new(),
+ list_description: None,
+ image: None,
+ })
+ .unwrap();
+ assert!(
+ RadrootsAdmittedCalendar::try_from_parsed(parsed)
+ .unwrap()
+ .event_references()
+ .is_empty()
+ );
+ }
+
+ #[test]
+ fn calendar_admission_requires_unique_refs_and_structural_blossom_images() {
+ let uid = "AAAAAAAAAAAAAAAAAAAAAA";
+ let event = canonical_event_reference("market-day");
+ let duplicate = RadrootsParsedNip52Calendar::try_new(RadrootsParsedNip52CalendarParts {
+ d_tag: uid.to_string(),
+ title: "Market calendar".to_string(),
+ content: String::new(),
+ event_references: vec![event.clone(), event.clone()],
+ list_description: None,
+ image: None,
+ })
+ .unwrap();
+ let error = RadrootsAdmittedCalendar::try_from_parsed(duplicate).unwrap_err();
+ assert_eq!(
+ error,
+ RadrootsCalendarAdmissionError::DuplicateEventReference
+ );
+ assert_eq!(error.code(), "duplicate_event_reference");
+
+ let ordinary_image =
+ RadrootsParsedNip52Calendar::try_new(RadrootsParsedNip52CalendarParts {
+ d_tag: uid.to_string(),
+ title: "Market calendar".to_string(),
+ content: String::new(),
+ event_references: vec![event.clone()],
+ list_description: None,
+ image: Some(
+ RadrootsCalendarUri::parse("https://media.example/market.webp").unwrap(),
+ ),
+ })
+ .unwrap();
+ assert_eq!(
+ RadrootsAdmittedCalendar::try_from_parsed(ordinary_image),
+ Err(RadrootsCalendarAdmissionError::NonBlossomImage)
+ );
+
+ let blossom_url = format!("https://media.example/{}.webp", "c".repeat(64));
+ let blossom = RadrootsParsedNip52Calendar::try_new(RadrootsParsedNip52CalendarParts {
+ d_tag: uid.to_string(),
+ title: "Market calendar".to_string(),
+ content: String::new(),
+ event_references: vec![event],
+ list_description: Some("Victoria farm markets".to_string()),
+ image: Some(RadrootsCalendarUri::parse(&blossom_url).unwrap()),
+ })
+ .unwrap();
+ assert_eq!(
+ RadrootsAdmittedCalendar::try_from_parsed(blossom)
+ .unwrap()
+ .blossom_image()
+ .unwrap()
+ .as_str(),
+ blossom_url
+ );
+ }
+
+ #[test]
+ fn calendar_collection_enforces_wire_tag_budgets() {
+ let references = (0..DEFAULT_TAG_MAX_COUNT)
+ .map(|index| canonical_event_reference(&format!("event-{index}")))
+ .collect::<Vec<_>>();
+ assert_eq!(
+ RadrootsAuthoredCalendar::new(
+ RadrootsCalendarUid::parse("AAAAAAAAAAAAAAAAAAAAAA").unwrap(),
+ "Farm calendar",
+ "",
+ references,
+ ),
+ Err(RadrootsCalendarEventError::TagCountExceeded {
+ max: DEFAULT_TAG_MAX_COUNT,
+ actual: DEFAULT_TAG_MAX_COUNT + 2,
+ })
+ );
}
#[test]
- fn rsvp_represents_status_and_free_busy_state() {
- let rsvp = RadrootsCalendarEventRsvp {
- d_tag: "rsvp-1".to_string(),
- event: RadrootsSocialTarget::Address {
- address: "31923:pubkey:wash-pack".to_string(),
- author: Some("a".repeat(64)),
- event_kind: Some(31923),
- relays: None,
+ fn declined_rsvp_retains_observed_fb_only_in_parsed_and_admitted_layers() {
+ let uid = RadrootsCalendarUid::parse("AAAAAAAAAAAAAAAAAAAAAQ").unwrap();
+ let event = canonical_event_reference("wash-pack");
+ assert_eq!(
+ RadrootsAuthoredCalendarEventRsvp::new(
+ uid.clone(),
+ event.clone(),
+ RadrootsCalendarEventRsvpStatus::Declined,
+ )
+ .unwrap()
+ .with_free_busy(RadrootsCalendarEventFreeBusy::Busy),
+ Err(RadrootsCalendarEventError::DeclinedFreeBusyForbidden)
+ );
+
+ let parsed = RadrootsParsedNip52CalendarEventRsvp::try_new(
+ RadrootsParsedNip52CalendarEventRsvpParts {
+ d_tag: uid.to_string(),
+ event_reference: event,
+ revision_reference: Some(
+ RadrootsCalendarEventRevisionReference::parse(
+ "b".repeat(64),
+ Some("wss://revision.example"),
+ )
+ .unwrap(),
+ ),
+ status: RadrootsCalendarEventRsvpStatus::Declined,
+ observed_free_busy: Some(RadrootsCalendarEventFreeBusy::Busy),
+ author_hint: Some(
+ RadrootsCalendarEventAuthorReference::parse(
+ "a".repeat(64),
+ Some("wss://author.example"),
+ )
+ .unwrap(),
+ ),
+ note: Some("Unable to attend".to_string()),
},
- event_id: Some("b".repeat(64)),
- status: RadrootsCalendarEventRsvpStatus::Tentative,
- free_busy: Some(RadrootsCalendarEventFreeBusy::Busy),
- note: Some("depends on harvest".to_string()),
- participants: None,
- };
+ )
+ .unwrap();
+ assert_eq!(
+ parsed.observed_free_busy(),
+ Some(&RadrootsCalendarEventFreeBusy::Busy)
+ );
+ assert_eq!(parsed.effective_free_busy(), None);
+
+ let admitted = RadrootsAdmittedCalendarEventRsvp::try_from_parsed(parsed).unwrap();
+ assert_eq!(
+ admitted.observed_free_busy(),
+ Some(&RadrootsCalendarEventFreeBusy::Busy)
+ );
+ assert_eq!(admitted.effective_free_busy(), None);
+ assert_eq!(
+ admitted.revision_reference().unwrap().relay(),
+ Some("wss://revision.example")
+ );
+ assert_eq!(
+ admitted.author_hint().unwrap().relay(),
+ Some("wss://author.example")
+ );
+ }
- assert_eq!(rsvp.status, RadrootsCalendarEventRsvpStatus::Tentative);
+ #[test]
+ fn rsvp_author_hint_must_match_event_coordinate_in_strict_layers() {
+ let uid = RadrootsCalendarUid::parse("AAAAAAAAAAAAAAAAAAAAAQ").unwrap();
+ let event = canonical_event_reference("wash-pack");
+ let mismatched = RadrootsCalendarEventAuthorReference::parse("b".repeat(64), None).unwrap();
assert_eq!(
- rsvp.event_id.as_deref(),
- Some("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
+ RadrootsAuthoredCalendarEventRsvp::new(
+ uid.clone(),
+ event.clone(),
+ RadrootsCalendarEventRsvpStatus::Accepted,
+ )
+ .unwrap()
+ .with_author_hint(mismatched.clone()),
+ Err(RadrootsCalendarEventError::AuthorHintMismatch)
);
- assert_eq!(rsvp.free_busy, Some(RadrootsCalendarEventFreeBusy::Busy));
+
+ let parsed = RadrootsParsedNip52CalendarEventRsvp::try_new(
+ RadrootsParsedNip52CalendarEventRsvpParts {
+ d_tag: uid.to_string(),
+ event_reference: event,
+ revision_reference: None,
+ status: RadrootsCalendarEventRsvpStatus::Accepted,
+ observed_free_busy: None,
+ author_hint: Some(mismatched),
+ note: None,
+ },
+ )
+ .unwrap();
+ let error = RadrootsAdmittedCalendarEventRsvp::try_from_parsed(parsed).unwrap_err();
+ assert_eq!(error, RadrootsCalendarAdmissionError::AuthorHintMismatch);
+ assert_eq!(error.code(), "author_hint_mismatch");
+ }
+
+ fn canonical_event_reference(d_tag: &str) -> RadrootsCalendarEventReference {
+ RadrootsCalendarEventReference::parse(
+ format!("31923:{}:{d_tag}", "a".repeat(64)),
+ Some("wss://relay.example"),
+ )
+ .unwrap()
}
}
diff --git a/crates/event/src/contract.rs b/crates/event/src/contract.rs
@@ -6,8 +6,8 @@ use alloc::{borrow::ToOwned, string::String, vec::Vec};
use crate::{
RadrootsEventEnvelope,
calendar::{
- RADROOTS_CALENDAR_MAX_PARTICIPANTS, RadrootsCalendarDate, RadrootsCalendarUri,
- RadrootsIanaTimeZoneId, canonical_calendar_geohash_is_valid,
+ RADROOTS_CALENDAR_MAX_PARTICIPANTS, RadrootsCalendarDate, RadrootsCalendarUid,
+ RadrootsCalendarUri, RadrootsIanaTimeZoneId, canonical_calendar_geohash_is_valid,
canonical_calendar_tag_text_is_valid, covered_utc_days,
},
ids::{
@@ -119,6 +119,9 @@ pub enum RadrootsTagCardinality {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RadrootsTagSemantic {
AddressableCoordinate,
+ CalendarEventAuthor,
+ CalendarEventReference,
+ CalendarEventRevision,
CalendarInclusionRequest,
CalendarEnd,
CalendarStart,
@@ -128,6 +131,7 @@ pub enum RadrootsTagSemantic {
Counterparty,
Evidence,
EventPointer,
+ FreeBusy,
Geohash,
GroupId,
Identifier,
@@ -135,6 +139,7 @@ pub enum RadrootsTagSemantic {
Kind,
ListingAddress,
ListingSnapshot,
+ ListDescription,
Location,
Participant,
PreviousEvent,
@@ -160,6 +165,10 @@ pub enum RadrootsTagSemantic {
pub enum RadrootsTagValueType {
AddressableCoordinate,
CalendarDate,
+ CalendarEventCoordinate,
+ CalendarFreeBusy,
+ CalendarRsvpStatus,
+ CalendarUid,
ContractId,
DTag,
EventId,
@@ -549,6 +558,62 @@ const TAG_CALENDAR_LEGACY_NAME: RadrootsTagContract = tag(
RadrootsTagValueType::Text,
false,
);
+const TAG_CALENDAR_UID: RadrootsTagContract = tag(
+ "d",
+ RadrootsTagCardinality::RequiredOne,
+ RadrootsTagSemantic::Identifier,
+ RadrootsTagValueType::CalendarUid,
+ true,
+);
+const TAG_CALENDAR_COLLECTION_DESCRIPTION: RadrootsTagContract = tag(
+ "description",
+ RadrootsTagCardinality::OptionalOne,
+ RadrootsTagSemantic::ListDescription,
+ RadrootsTagValueType::Text,
+ false,
+);
+const TAG_CALENDAR_COLLECTION_EVENT: RadrootsTagContract = tag(
+ "a",
+ RadrootsTagCardinality::OptionalMany,
+ RadrootsTagSemantic::CalendarEventReference,
+ RadrootsTagValueType::CalendarEventCoordinate,
+ true,
+);
+const TAG_CALENDAR_RSVP_EVENT: RadrootsTagContract = tag(
+ "a",
+ RadrootsTagCardinality::RequiredOne,
+ RadrootsTagSemantic::CalendarEventReference,
+ RadrootsTagValueType::CalendarEventCoordinate,
+ true,
+);
+const TAG_CALENDAR_RSVP_REVISION: RadrootsTagContract = tag(
+ "e",
+ RadrootsTagCardinality::OptionalOne,
+ RadrootsTagSemantic::CalendarEventRevision,
+ RadrootsTagValueType::EventId,
+ true,
+);
+const TAG_CALENDAR_RSVP_STATUS: RadrootsTagContract = tag(
+ "status",
+ RadrootsTagCardinality::RequiredOne,
+ RadrootsTagSemantic::Status,
+ RadrootsTagValueType::CalendarRsvpStatus,
+ false,
+);
+const TAG_CALENDAR_RSVP_FREE_BUSY: RadrootsTagContract = tag(
+ "fb",
+ RadrootsTagCardinality::OptionalOne,
+ RadrootsTagSemantic::FreeBusy,
+ RadrootsTagValueType::CalendarFreeBusy,
+ false,
+);
+const TAG_CALENDAR_RSVP_AUTHOR: RadrootsTagContract = tag(
+ "p",
+ RadrootsTagCardinality::OptionalOne,
+ RadrootsTagSemantic::CalendarEventAuthor,
+ RadrootsTagValueType::PublicKey,
+ true,
+);
const TAG_CALENDAR_DATE_START: RadrootsTagContract = tag(
"start",
RadrootsTagCardinality::RequiredOne,
@@ -793,8 +858,21 @@ const CALENDAR_TIME_EVENT_TAGS: &[RadrootsTagContract] = &[
TAG_CALENDAR_REFERENCE,
TAG_CALENDAR_INCLUSION_REQUEST,
];
-const CALENDAR_EVENT_TAGS: &[RadrootsTagContract] =
- &[TAG_D, TAG_TITLE, TAG_LOCATION, TAG_PUBLISHED_AT];
+const CALENDAR_COLLECTION_TAGS: &[RadrootsTagContract] = &[
+ TAG_CALENDAR_UID,
+ TAG_CALENDAR_TITLE,
+ TAG_CALENDAR_COLLECTION_DESCRIPTION,
+ TAG_CALENDAR_IMAGE,
+ TAG_CALENDAR_COLLECTION_EVENT,
+];
+const CALENDAR_RSVP_TAGS: &[RadrootsTagContract] = &[
+ TAG_CALENDAR_UID,
+ TAG_CALENDAR_RSVP_EVENT,
+ TAG_CALENDAR_RSVP_REVISION,
+ TAG_CALENDAR_RSVP_STATUS,
+ TAG_CALENDAR_RSVP_FREE_BUSY,
+ TAG_CALENDAR_RSVP_AUTHOR,
+];
const FARM_TAGS: &[RadrootsTagContract] = &[TAG_D, TAG_TITLE, TAG_LOCATION, TAG_IMAGE];
const LISTING_TAGS: &[RadrootsTagContract] = &[
TAG_D,
@@ -2589,26 +2667,26 @@ static ALL_EVENT_CONTRACTS: &[RadrootsEventContract] = &[
"radroots.calendar.collection.v1",
KIND_CALENDAR,
"Calendar Collection",
- "RadrootsCalendar",
+ "RadrootsAdmittedCalendar",
RadrootsEventClass::Addressable,
RadrootsEventPrivacy::Public,
RadrootsActorRole::Any,
- RadrootsContentSchema::JsonObject,
+ RadrootsContentSchema::PlainText,
RadrootsEventDiscriminator::KindOnly,
- LIST_SET_TAGS,
+ CALENDAR_COLLECTION_TAGS,
CALENDAR_REDUCERS
),
event_contract!(
"radroots.calendar.rsvp.v1",
KIND_CALENDAR_EVENT_RSVP,
"Calendar RSVP",
- "RadrootsCalendarRsvp",
+ "RadrootsAdmittedCalendarEventRsvp",
RadrootsEventClass::Addressable,
RadrootsEventPrivacy::Public,
RadrootsActorRole::Any,
- RadrootsContentSchema::JsonObject,
+ RadrootsContentSchema::PlainText,
RadrootsEventDiscriminator::KindOnly,
- CALENDAR_EVENT_TAGS,
+ CALENDAR_RSVP_TAGS,
CALENDAR_REDUCERS
),
event_contract!(
@@ -3393,6 +3471,14 @@ fn tag_value_is_valid(tag: &[String], value_type: RadrootsTagValueType) -> bool
RadrootsAddressableCoordinate::parse(value).is_ok()
}
RadrootsTagValueType::CalendarDate => RadrootsCalendarDate::parse(value).is_ok(),
+ RadrootsTagValueType::CalendarEventCoordinate => {
+ canonical_calendar_event_coordinate_is_valid(value)
+ }
+ RadrootsTagValueType::CalendarFreeBusy => matches!(value, "free" | "busy"),
+ RadrootsTagValueType::CalendarRsvpStatus => {
+ matches!(value, "accepted" | "declined" | "tentative")
+ }
+ RadrootsTagValueType::CalendarUid => RadrootsCalendarUid::parse(value).is_ok(),
RadrootsTagValueType::ContractId => all_event_contracts()
.iter()
.any(|contract| contract.id == value),
@@ -3469,6 +3555,10 @@ fn tag_value_type_expectation(value_type: RadrootsTagValueType) -> &'static str
match value_type {
RadrootsTagValueType::AddressableCoordinate => "addressable_coordinate",
RadrootsTagValueType::CalendarDate => "calendar_date_yyyy_mm_dd",
+ RadrootsTagValueType::CalendarEventCoordinate => "canonical_kind_31922_or_31923_coordinate",
+ RadrootsTagValueType::CalendarFreeBusy => "calendar_free_or_busy",
+ RadrootsTagValueType::CalendarRsvpStatus => "calendar_rsvp_status",
+ RadrootsTagValueType::CalendarUid => "canonical_128_bit_base64url_calendar_uid",
RadrootsTagValueType::ContractId => "contract_id",
RadrootsTagValueType::DTag => "d_tag",
RadrootsTagValueType::EventId => "event_id",
@@ -3505,10 +3595,180 @@ fn validate_custom_calendar_contract_parts(
match contract.id {
"radroots.calendar.date_event.v1" => validate_calendar_date_contract(tags, contract),
"radroots.calendar.time_event.v1" => validate_calendar_time_contract(tags, contract),
+ "radroots.calendar.collection.v1" => validate_calendar_collection_contract(tags, contract),
+ "radroots.calendar.rsvp.v1" => validate_calendar_rsvp_contract(tags, contract),
_ => Ok(()),
}
}
+fn validate_calendar_collection_contract(
+ tags: &[Vec<String>],
+ contract: &RadrootsEventContract,
+) -> Result<(), RadrootsContractValidationError> {
+ validate_exact_calendar_tags(tags, contract, &["d", "title", "description", "image"])?;
+ validate_canonical_calendar_text_tags(tags, contract, &["title", "description"])?;
+ validate_calendar_event_reference_tags(tags, contract)?;
+ validate_calendar_blossom_image(tags, contract)?;
+
+ let event_references = tags
+ .iter()
+ .filter(|tag| tag.first().map(String::as_str) == Some("a"))
+ .collect::<Vec<_>>();
+ for (index, reference) in event_references.iter().enumerate() {
+ if event_references
+ .iter()
+ .skip(index + 1)
+ .any(|candidate| candidate.get(1) == reference.get(1))
+ {
+ return Err(calendar_tag_mismatch(
+ contract,
+ "a",
+ "duplicate_free_calendar_event_coordinates",
+ reference.get(1).cloned(),
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn validate_calendar_rsvp_contract(
+ tags: &[Vec<String>],
+ contract: &RadrootsEventContract,
+) -> Result<(), RadrootsContractValidationError> {
+ validate_exact_calendar_tags(tags, contract, &["d", "status", "fb"])?;
+ validate_calendar_event_reference_tags(tags, contract)?;
+ validate_calendar_rsvp_pointer_tag(tags, contract, "e", true)?;
+ validate_calendar_rsvp_pointer_tag(tags, contract, "p", false)?;
+
+ let event_author = tags
+ .iter()
+ .find(|tag| tag.first().map(String::as_str) == Some("a"))
+ .and_then(|tag| tag.get(1))
+ .and_then(|coordinate| {
+ crate::ids::RadrootsAddressableCoordinateParts::parse(coordinate).ok()
+ })
+ .map(|parts| parts.pubkey);
+ if let Some(author_hint) = tag_value(tags, "p") {
+ let hint = RadrootsPublicKey::parse(author_hint).ok();
+ if hint.as_ref() != event_author.as_ref()
+ || hint.as_ref().is_none_or(|key| key.as_str() != author_hint)
+ {
+ return Err(calendar_tag_mismatch(
+ contract,
+ "p",
+ "canonical_calendar_event_author_matching_a_coordinate",
+ Some(author_hint.to_owned()),
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn validate_calendar_event_reference_tags(
+ tags: &[Vec<String>],
+ contract: &RadrootsEventContract,
+) -> Result<(), RadrootsContractValidationError> {
+ for tag in tags
+ .iter()
+ .filter(|tag| tag.first().map(String::as_str) == Some("a"))
+ {
+ let coordinate_is_valid = tag
+ .get(1)
+ .is_some_and(|value| canonical_calendar_event_coordinate_is_valid(value));
+ let relay_is_valid = tag
+ .get(2)
+ .is_none_or(|relay| !relay.is_empty() && relay_url_is_valid(relay));
+ if !(2..=3).contains(&tag.len()) || !coordinate_is_valid || !relay_is_valid {
+ return Err(calendar_tag_mismatch(
+ contract,
+ "a",
+ "canonical_kind_31922_or_31923_coordinate_with_optional_relay",
+ tag.get(1).cloned(),
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn validate_calendar_rsvp_pointer_tag(
+ tags: &[Vec<String>],
+ contract: &RadrootsEventContract,
+ name: &'static str,
+ event_id: bool,
+) -> Result<(), RadrootsContractValidationError> {
+ for tag in tags
+ .iter()
+ .filter(|tag| tag.first().map(String::as_str) == Some(name))
+ {
+ let value_is_canonical = tag.get(1).is_some_and(|value| {
+ if event_id {
+ RadrootsEventId::parse(value).is_ok_and(|parsed| parsed.as_str() == value)
+ } else {
+ RadrootsPublicKey::parse(value).is_ok_and(|parsed| parsed.as_str() == value)
+ }
+ });
+ let relay_is_valid = tag
+ .get(2)
+ .is_none_or(|relay| !relay.is_empty() && relay_url_is_valid(relay));
+ if !(2..=3).contains(&tag.len()) || !value_is_canonical || !relay_is_valid {
+ return Err(calendar_tag_mismatch(
+ contract,
+ name,
+ if event_id {
+ "canonical_event_id_with_optional_relay"
+ } else {
+ "canonical_public_key_with_optional_relay"
+ },
+ tag.get(1).cloned(),
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn validate_canonical_calendar_text_tags(
+ tags: &[Vec<String>],
+ contract: &RadrootsEventContract,
+ names: &[&'static str],
+) -> Result<(), RadrootsContractValidationError> {
+ for name in names {
+ for tag in tags
+ .iter()
+ .filter(|tag| tag.first().map(String::as_str) == Some(*name))
+ {
+ if !tag
+ .get(1)
+ .is_some_and(|value| canonical_calendar_tag_text_is_valid(value))
+ {
+ return Err(calendar_tag_mismatch(
+ contract,
+ name,
+ "canonical_visible_calendar_text",
+ tag.get(1).cloned(),
+ ));
+ }
+ }
+ }
+ Ok(())
+}
+
+fn validate_calendar_blossom_image(
+ tags: &[Vec<String>],
+ contract: &RadrootsEventContract,
+) -> Result<(), RadrootsContractValidationError> {
+ if let Some(image) = tag_value(tags, "image")
+ && RadrootsBlossomBlobUrl::parse(image).is_err()
+ {
+ return Err(calendar_tag_mismatch(
+ contract,
+ "image",
+ "structural_blossom_hash_path_url",
+ Some(image.to_owned()),
+ ));
+ }
+ Ok(())
+}
+
fn validate_calendar_date_contract(
tags: &[Vec<String>],
contract: &RadrootsEventContract,
@@ -3726,6 +3986,24 @@ fn canonical_calendar_coordinate_is_valid(value: &str) -> bool {
kind == "31924" && pubkey == parts.pubkey.as_str() && d_tag == parts.d_tag.as_str()
}
+fn canonical_calendar_event_coordinate_is_valid(value: &str) -> bool {
+ let Some((kind, remainder)) = value.split_once(':') else {
+ return false;
+ };
+ let Some((pubkey, d_tag)) = remainder.split_once(':') else {
+ return false;
+ };
+ let Ok(parts) = crate::ids::RadrootsAddressableCoordinateParts::parse(value) else {
+ return false;
+ };
+ matches!(
+ parts.kind,
+ KIND_CALENDAR_DATE_EVENT | KIND_CALENDAR_TIME_EVENT
+ ) && matches!(kind, "31922" | "31923")
+ && pubkey == parts.pubkey.as_str()
+ && d_tag == parts.d_tag.as_str()
+}
+
fn validate_canonical_calendar_common_tags(
tags: &[Vec<String>],
contract: &RadrootsEventContract,
@@ -4268,6 +4546,124 @@ mod tests {
assert!(time.tags.iter().any(|tag| {
tag.name == "name" && tag.cardinality == RadrootsTagCardinality::OptionalOne
}));
+
+ let calendar = event_contract("radroots.calendar.collection.v1").expect("calendar");
+ assert_eq!(calendar.payload_type, "RadrootsAdmittedCalendar");
+ assert_eq!(calendar.content_schema, RadrootsContentSchema::PlainText);
+ assert!(calendar.tags.iter().any(|tag| {
+ tag.name == "a"
+ && tag.cardinality == RadrootsTagCardinality::OptionalMany
+ && tag.value_type == RadrootsTagValueType::CalendarEventCoordinate
+ }));
+
+ let rsvp = event_contract("radroots.calendar.rsvp.v1").expect("calendar RSVP");
+ assert_eq!(rsvp.payload_type, "RadrootsAdmittedCalendarEventRsvp");
+ assert_eq!(rsvp.content_schema, RadrootsContentSchema::PlainText);
+ assert!(rsvp.tags.iter().any(|tag| {
+ tag.name == "status"
+ && tag.cardinality == RadrootsTagCardinality::RequiredOne
+ && tag.value_type == RadrootsTagValueType::CalendarRsvpStatus
+ }));
+ }
+
+ #[test]
+ fn calendar_collection_and_rsvp_contracts_enforce_strict_nip52_shapes() {
+ let author = "a".repeat(64);
+ let event_coordinate = format!("31923:{author}:wash-pack");
+ let second_coordinate = format!("31922:{author}:market-day");
+ let event_id = "b".repeat(64);
+ let blossom_image = format!("https://media.example/{}.webp", "c".repeat(64));
+ let collection = vec![
+ owned_tag(&["d", "AAAAAAAAAAAAAAAAAAAAAA"]),
+ owned_tag(&["title", "Farm calendar"]),
+ owned_tag(&["description", "Upcoming farm work"]),
+ vec!["image".to_owned(), blossom_image],
+ vec![
+ "a".to_owned(),
+ event_coordinate.clone(),
+ "wss://relay.example/events".to_owned(),
+ ],
+ vec!["a".to_owned(), second_coordinate],
+ ];
+ assert_eq!(
+ validate_event_contract_parts(
+ KIND_CALENDAR,
+ &collection,
+ "Detailed shared schedule.",
+ "radroots.calendar.collection.v1",
+ ),
+ Ok(())
+ );
+
+ let mut duplicate_collection = collection.clone();
+ duplicate_collection.push(vec!["a".to_owned(), event_coordinate.clone()]);
+ assert!(matches!(
+ validate_event_contract_parts(
+ KIND_CALENDAR,
+ &duplicate_collection,
+ "",
+ "radroots.calendar.collection.v1",
+ ),
+ Err(RadrootsContractValidationError::TagValueMismatch { name: "a", .. })
+ ));
+
+ let rsvp = vec![
+ owned_tag(&["d", "AAAAAAAAAAAAAAAAAAAAAQ"]),
+ vec![
+ "a".to_owned(),
+ event_coordinate.clone(),
+ "wss://relay.example/a".to_owned(),
+ ],
+ vec!["e".to_owned(), event_id, "wss://relay.example/e".to_owned()],
+ owned_tag(&["status", "tentative"]),
+ owned_tag(&["fb", "busy"]),
+ vec![
+ "p".to_owned(),
+ author.clone(),
+ "wss://relay.example/p".to_owned(),
+ ],
+ ];
+ assert_eq!(
+ validate_event_contract_parts(
+ KIND_CALENDAR_EVENT_RSVP,
+ &rsvp,
+ "I expect to attend.",
+ "radroots.calendar.rsvp.v1",
+ ),
+ Ok(())
+ );
+
+ let mut mismatched_author = rsvp.clone();
+ let participant = mismatched_author
+ .iter_mut()
+ .find(|tag| tag.first().map(String::as_str) == Some("p"))
+ .unwrap();
+ participant[1] = "d".repeat(64);
+ assert!(matches!(
+ validate_event_contract_parts(
+ KIND_CALENDAR_EVENT_RSVP,
+ &mismatched_author,
+ "",
+ "radroots.calendar.rsvp.v1",
+ ),
+ Err(RadrootsContractValidationError::TagValueMismatch { name: "p", .. })
+ ));
+
+ let declined_with_observed_free_busy = vec![
+ owned_tag(&["d", "AAAAAAAAAAAAAAAAAAAAAg"]),
+ vec!["a".to_owned(), event_coordinate],
+ owned_tag(&["status", "declined"]),
+ owned_tag(&["fb", "free"]),
+ ];
+ assert_eq!(
+ validate_event_contract_parts(
+ KIND_CALENDAR_EVENT_RSVP,
+ &declined_with_observed_free_busy,
+ "",
+ "radroots.calendar.rsvp.v1",
+ ),
+ Ok(())
+ );
}
#[test]
diff --git a/crates/event/src/social.rs b/crates/event/src/social.rs
@@ -107,40 +107,6 @@ pub struct RadrootsSocialMediaMetadata {
any(feature = "serde", test),
derive(serde::Serialize, serde::Deserialize)
)]
-#[derive(Clone, Debug, Default, PartialEq, Eq)]
-pub struct RadrootsCalendarParticipant {
- pub pubkey: String,
- pub relay: Option<String>,
- pub role: Option<String>,
-}
-
-#[cfg_attr(
- any(feature = "serde", test),
- derive(serde::Serialize, serde::Deserialize)
-)]
-#[cfg_attr(any(feature = "serde", test), serde(rename_all = "snake_case"))]
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub enum RadrootsCalendarEventRsvpStatus {
- Accepted,
- Declined,
- Tentative,
-}
-
-#[cfg_attr(
- any(feature = "serde", test),
- derive(serde::Serialize, serde::Deserialize)
-)]
-#[cfg_attr(any(feature = "serde", test), serde(rename_all = "snake_case"))]
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub enum RadrootsCalendarEventFreeBusy {
- Free,
- Busy,
-}
-
-#[cfg_attr(
- any(feature = "serde", test),
- derive(serde::Serialize, serde::Deserialize)
-)]
#[cfg_attr(any(feature = "serde", test), serde(rename_all = "snake_case"))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RadrootsReportType {
@@ -218,15 +184,7 @@ mod tests {
}
#[test]
- fn exposes_calendar_and_report_enums() {
- assert_eq!(
- RadrootsCalendarEventRsvpStatus::Accepted,
- RadrootsCalendarEventRsvpStatus::Accepted
- );
- assert_eq!(
- RadrootsCalendarEventFreeBusy::Busy,
- RadrootsCalendarEventFreeBusy::Busy
- );
+ fn exposes_report_enums() {
assert_eq!(RadrootsReportType::Spam, RadrootsReportType::Spam);
}
}
diff --git a/crates/event_codec/README b/crates/event_codec/README
@@ -22,14 +22,14 @@ network verification. Its content parser accepts only bounded kind-`0` content
from an event whose identifier and signature the caller has already verified.
The NIP-52 calendar codecs expose a deliberate three-stage boundary for kinds
-`31922` and `31923`:
+`31922`, `31923`, `31924`, and `31925`:
1. bounded structural wire or envelope parsing preserves the complete event;
-2. `parse_nip52_calendar_date_event` or
- `parse_nip52_calendar_time_event` produces a tolerant baseline NIP-52
- projection and rejects a mismatched kind;
-3. `admit_radroots_calendar_date_event` or
- `admit_radroots_calendar_time_event` applies the stricter Radroots profile.
+2. the kind-specific date event, time event, calendar collection, or RSVP
+ parser produces a tolerant `RadrootsParsedNip52*` projection and rejects a
+ mismatched kind;
+3. the corresponding `admit_radroots_*` operation applies the stricter
+ Radroots profile and returns a non-interchangeable admitted type.
Structural envelope construction, the baseline parsers, and the admission
functions do not verify a Schnorr signature. Before treating inbound data as
@@ -58,12 +58,42 @@ days. Authored codecs derive that sequence and emit unsigned
`RadrootsNip01EventWireParts`; signing and transport stay at the owning runtime
boundary.
-Strict inbound admission upgrades an image only to a structural Blossom
-hash-path URL. It does not prove reference approval, bytes, upload completion,
-or retrievability. Strict authored image inputs are approved, byte-verified
-Blossom `image/*` descriptors, but are still not upload receipts. A publishing
-runtime must require successful BUD-02 completion and a bounded retrievability
-check before signing or publishing media-bearing wire parts.
+Kind `31924` is handled only by the calendar-specific codec. Generic NIP-51
+taxonomy may recognize it as a calendar list, but the generic list-set decoder
+and authoring path and the broader generic list codec reject it. Calendar collection content is the bounded
+plain-text NIP-52 detailed description; it is not substituted from or collapsed
+into the optional NIP-51 `description` tag. The parser requires exactly one
+`d` and `title`, accepts optional singleton `description` and `image` tags, and
+accepts zero or more exact `a` references to kind `31922` or `31923` events.
+An empty collection is valid, and each event reference preserves its own
+optional relay hint. Singleton collection tags have exactly two elements; an
+`a` tag has exactly two elements plus an optional relay element.
+
+Kind `31925` has bounded optional note content and exact singleton
+cardinality: required `d`, `a`, and `status`, with optional `e`, `fb`, and `p`.
+The `a`, `e`, and `p` values preserve independent optional relay hints. The
+`p` tag is parsed as an event-author hint without participant-role semantics;
+strict admission requires it to match the author in the `a` coordinate.
+`d`, `status`, and `fb` tags have exactly two elements, while `a`, `e`, and `p`
+have exactly two elements plus an optional relay element.
+Tolerant parsing preserves `fb` observed on a declined RSVP, but parsed and
+admitted values expose no effective free/busy state for it. Authored declined
+RSVPs never emit `fb`.
+
+Strict collection and RSVP authoring and admission require a canonical
+22-character unpadded base64url 128-bit identifier and canonical reference
+spellings. Identifier syntax does not establish runtime uniqueness. Parsing
+valid identifiers, event ids, author keys, coordinates, and relay hints does
+not establish reference existence, revision correspondence, RSVP authority,
+or relay availability.
+
+Strict inbound admission, including kind-`31924` collection images, upgrades
+an image only to a structural Blossom hash-path URL. It does not prove
+reference approval, bytes, upload completion, or retrievability. Strict
+authored image inputs are approved, byte-verified Blossom `image/*`
+descriptors, but are still not upload receipts. A publishing runtime must
+require successful BUD-02 completion and a bounded retrievability check before
+signing or publishing media-bearing wire parts.
## Field Event Codecs
diff --git a/crates/event_codec/src/calendar/decode.rs b/crates/event_codec/src/calendar/decode.rs
@@ -6,25 +6,26 @@ use alloc::{
use radroots_event::{
calendar::{
- RADROOTS_CALENDAR_SECONDS_PER_DAY, RadrootsAdmittedCalendarDateEvent,
- RadrootsAdmittedCalendarTimeEvent, RadrootsCalendar, RadrootsCalendarDate,
- RadrootsCalendarEventRsvp, RadrootsCalendarRequest, RadrootsCalendarUri,
- RadrootsIanaTimeZoneId, RadrootsObservedUtcDay, RadrootsParsedNip52CalendarCommon,
+ RADROOTS_CALENDAR_SECONDS_PER_DAY, RadrootsAdmittedCalendar,
+ RadrootsAdmittedCalendarDateEvent, RadrootsAdmittedCalendarEventRsvp,
+ RadrootsAdmittedCalendarTimeEvent, RadrootsCalendarDate,
+ RadrootsCalendarEventAuthorReference, RadrootsCalendarEventFreeBusy,
+ RadrootsCalendarEventReference, RadrootsCalendarEventRevisionReference,
+ RadrootsCalendarEventRsvpStatus, RadrootsCalendarParticipant, RadrootsCalendarRequest,
+ RadrootsCalendarUri, RadrootsIanaTimeZoneId, RadrootsObservedUtcDay,
+ RadrootsParsedNip52Calendar, RadrootsParsedNip52CalendarCommon,
RadrootsParsedNip52CalendarCommonParts, RadrootsParsedNip52CalendarDateEvent,
- RadrootsParsedNip52CalendarTimeEvent, calendar_geohash_is_valid,
- calendar_relay_url_is_valid, calendar_tag_text_is_valid,
+ RadrootsParsedNip52CalendarEventRsvp, RadrootsParsedNip52CalendarEventRsvpParts,
+ RadrootsParsedNip52CalendarParts, RadrootsParsedNip52CalendarTimeEvent,
+ calendar_geohash_is_valid, calendar_relay_url_is_valid, calendar_tag_text_is_valid,
},
kinds::{
KIND_CALENDAR, KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_EVENT_RSVP, KIND_CALENDAR_TIME_EVENT,
},
- social::{
- RadrootsCalendarEventFreeBusy, RadrootsCalendarEventRsvpStatus,
- RadrootsCalendarParticipant, RadrootsSocialTarget,
- },
tags::{
- TAG_A, TAG_D, TAG_D_DAY, TAG_E, TAG_END, TAG_END_TZID, TAG_FREE_BUSY, TAG_G, TAG_IMAGE,
- TAG_LOCATION, TAG_P, TAG_R, TAG_START, TAG_START_TZID, TAG_STATUS, TAG_SUMMARY, TAG_T,
- TAG_TITLE,
+ TAG_A, TAG_D, TAG_D_DAY, TAG_DESCRIPTION, TAG_E, TAG_END, TAG_END_TZID, TAG_FREE_BUSY,
+ TAG_G, TAG_IMAGE, TAG_LOCATION, TAG_P, TAG_R, TAG_START, TAG_START_TZID, TAG_STATUS,
+ TAG_SUMMARY, TAG_T, TAG_TITLE,
},
wire::{
DEFAULT_CONTENT_MAX_BYTES, DEFAULT_TAG_ELEMENT_MAX_BYTES, DEFAULT_TAG_MAX_COUNT,
@@ -32,13 +33,8 @@ use radroots_event::{
},
};
-use crate::d_tag::validate_d_tag_tag;
use crate::error::EventParseError;
-use crate::field_helpers::{
- optional_tag_value, parse_address_tag, required_tag_value, validate_lowercase_hex_64_tag,
-};
use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent};
-use crate::social_helpers::participants_from_tags;
const EXPECTED_DATE_KIND: &str = "31922";
const EXPECTED_TIME_KIND: &str = "31923";
@@ -146,69 +142,92 @@ pub fn admit_radroots_calendar_time_event(
RadrootsAdmittedCalendarTimeEvent::try_from_parsed(parsed)
}
-pub fn calendar_from_event(
+pub fn parse_nip52_calendar(
kind: u32,
tags: &[Vec<String>],
content: &str,
-) -> Result<RadrootsCalendar, EventParseError> {
+) -> Result<RadrootsParsedNip52Calendar, EventParseError> {
+ validate_bounded_calendar_parts(tags, content)?;
if kind != KIND_CALENDAR {
return Err(EventParseError::InvalidKind {
expected: EXPECTED_CALENDAR_KIND,
got: kind,
});
}
- let d_tag = required_tag_value(tags, TAG_D)?;
- validate_d_tag_tag(&d_tag, TAG_D)?;
- let title = required_tag_value(tags, TAG_TITLE)?;
- let events = calendar_event_targets_from_tags(tags)?;
- if events.is_empty() {
- return Err(EventParseError::MissingTag(TAG_A));
- }
- Ok(RadrootsCalendar {
+ let d_tag = required_exact_tag_value(tags, TAG_D)?;
+ validate_calendar_text_tag(&d_tag, TAG_D)?;
+ let title = required_exact_tag_value(tags, TAG_TITLE)?;
+ validate_calendar_text_tag(&title, TAG_TITLE)?;
+ let event_references = calendar_event_references_from_tags(tags)?;
+ let list_description = optional_calendar_text_tag(tags, TAG_DESCRIPTION)?;
+ let image = optional_calendar_uri_tag(tags, TAG_IMAGE)?;
+ RadrootsParsedNip52Calendar::try_new(RadrootsParsedNip52CalendarParts {
d_tag,
title,
- events,
- description: optional_content(content),
- summary: optional_tag_value(tags, TAG_SUMMARY)?,
- image: optional_tag_value(tags, TAG_IMAGE)?,
+ content: content.to_string(),
+ event_references,
+ list_description,
+ image,
})
+ .map_err(|_| EventParseError::InvalidTag("calendar"))
}
-pub fn rsvp_from_event(
+pub fn admit_radroots_calendar(
+ parsed: RadrootsParsedNip52Calendar,
+) -> Result<RadrootsAdmittedCalendar, radroots_event::calendar::RadrootsCalendarAdmissionError> {
+ RadrootsAdmittedCalendar::try_from_parsed(parsed)
+}
+
+pub fn parse_nip52_calendar_event_rsvp(
kind: u32,
tags: &[Vec<String>],
content: &str,
-) -> Result<RadrootsCalendarEventRsvp, EventParseError> {
+) -> Result<RadrootsParsedNip52CalendarEventRsvp, EventParseError> {
+ validate_bounded_calendar_parts(tags, content)?;
if kind != KIND_CALENDAR_EVENT_RSVP {
return Err(EventParseError::InvalidKind {
expected: EXPECTED_RSVP_KIND,
got: kind,
});
}
- let d_tag = required_tag_value(tags, TAG_D)?;
- validate_d_tag_tag(&d_tag, TAG_D)?;
- let event = calendar_event_target_from_required_tag(tags)?;
- let event_id = optional_tag_value(tags, TAG_E)?;
- if let Some(event_id) = event_id.as_deref() {
- validate_lowercase_hex_64_tag(event_id, TAG_E)?;
- }
- let status = parse_rsvp_status(&required_tag_value(tags, TAG_STATUS)?)?;
- let free_busy = optional_tag_value(tags, TAG_FREE_BUSY)?
+ let d_tag = required_exact_tag_value(tags, TAG_D)?;
+ validate_calendar_text_tag(&d_tag, TAG_D)?;
+ let event_reference = calendar_event_reference_from_required_tag(tags)?;
+ let revision_reference = optional_reference_tag(tags, TAG_E)?
+ .map(|tag| {
+ RadrootsCalendarEventRevisionReference::parse(&tag[1], tag.get(2).map(String::as_str))
+ .map_err(|_| EventParseError::InvalidTag(TAG_E))
+ })
+ .transpose()?;
+ let status = parse_rsvp_status(&required_exact_tag_value(tags, TAG_STATUS)?)?;
+ let observed_free_busy = optional_exact_tag_value(tags, TAG_FREE_BUSY)?
.map(|value| parse_free_busy(&value))
.transpose()?;
- Ok(RadrootsCalendarEventRsvp {
+ let author_hint = optional_reference_tag(tags, TAG_P)?
+ .map(|tag| {
+ RadrootsCalendarEventAuthorReference::parse(&tag[1], tag.get(2).map(String::as_str))
+ .map_err(|_| EventParseError::InvalidTag(TAG_P))
+ })
+ .transpose()?;
+ RadrootsParsedNip52CalendarEventRsvp::try_new(RadrootsParsedNip52CalendarEventRsvpParts {
d_tag,
- event,
- event_id,
+ event_reference,
+ revision_reference,
status,
- free_busy,
- note: if content.is_empty() {
- None
- } else {
- Some(content.to_string())
- },
- participants: participants_from_tags(tags),
+ observed_free_busy,
+ author_hint,
+ note: optional_content(content),
})
+ .map_err(|_| EventParseError::InvalidTag("calendar_rsvp"))
+}
+
+pub fn admit_radroots_calendar_event_rsvp(
+ parsed: RadrootsParsedNip52CalendarEventRsvp,
+) -> Result<
+ RadrootsAdmittedCalendarEventRsvp,
+ radroots_event::calendar::RadrootsCalendarAdmissionError,
+> {
+ RadrootsAdmittedCalendarEventRsvp::try_from_parsed(parsed)
}
pub fn nip52_date_data_from_event(
@@ -247,15 +266,15 @@ pub fn nip52_time_data_from_event(
))
}
-pub fn calendar_data_from_event(
+pub fn nip52_calendar_data_from_event(
id: String,
author: String,
published_at: u64,
kind: u32,
content: String,
tags: Vec<Vec<String>>,
-) -> Result<RadrootsParsedData<RadrootsCalendar>, EventParseError> {
- let calendar = calendar_from_event(kind, &tags, &content)?;
+) -> Result<RadrootsParsedData<RadrootsParsedNip52Calendar>, EventParseError> {
+ let calendar = parse_nip52_calendar(kind, &tags, &content)?;
Ok(RadrootsParsedData::new(
id,
author,
@@ -265,15 +284,15 @@ pub fn calendar_data_from_event(
))
}
-pub fn rsvp_data_from_event(
+pub fn nip52_calendar_event_rsvp_data_from_event(
id: String,
author: String,
published_at: u64,
kind: u32,
content: String,
tags: Vec<Vec<String>>,
-) -> Result<RadrootsParsedData<RadrootsCalendarEventRsvp>, EventParseError> {
- let rsvp = rsvp_from_event(kind, &tags, &content)?;
+) -> Result<RadrootsParsedData<RadrootsParsedNip52CalendarEventRsvp>, EventParseError> {
+ let rsvp = parse_nip52_calendar_event_rsvp(kind, &tags, &content)?;
Ok(RadrootsParsedData::new(
id,
author,
@@ -323,7 +342,7 @@ pub fn nip52_time_parsed_from_event(
RadrootsParsedEvent::from_event_parts(id, author, published_at, kind, content, tags, sig, data)
}
-pub fn calendar_parsed_from_event(
+pub fn nip52_calendar_parsed_from_event(
id: String,
author: String,
published_at: u64,
@@ -331,8 +350,8 @@ pub fn calendar_parsed_from_event(
content: String,
tags: Vec<Vec<String>>,
sig: String,
-) -> Result<RadrootsParsedEvent<RadrootsCalendar>, EventParseError> {
- let data = calendar_data_from_event(
+) -> Result<RadrootsParsedEvent<RadrootsParsedNip52Calendar>, EventParseError> {
+ let data = nip52_calendar_data_from_event(
id.clone(),
author.clone(),
published_at,
@@ -343,7 +362,7 @@ pub fn calendar_parsed_from_event(
RadrootsParsedEvent::from_event_parts(id, author, published_at, kind, content, tags, sig, data)
}
-pub fn rsvp_parsed_from_event(
+pub fn nip52_calendar_event_rsvp_parsed_from_event(
id: String,
author: String,
published_at: u64,
@@ -351,8 +370,8 @@ pub fn rsvp_parsed_from_event(
content: String,
tags: Vec<Vec<String>>,
sig: String,
-) -> Result<RadrootsParsedEvent<RadrootsCalendarEventRsvp>, EventParseError> {
- let data = rsvp_data_from_event(
+) -> Result<RadrootsParsedEvent<RadrootsParsedNip52CalendarEventRsvp>, EventParseError> {
+ let data = nip52_calendar_event_rsvp_data_from_event(
id.clone(),
author.clone(),
published_at,
@@ -615,58 +634,55 @@ fn optional_content(content: &str) -> Option<String> {
}
}
-fn calendar_event_targets_from_tags(
+fn calendar_event_references_from_tags(
tags: &[Vec<String>],
-) -> Result<Vec<RadrootsSocialTarget>, EventParseError> {
+) -> Result<Vec<RadrootsCalendarEventReference>, EventParseError> {
tags.iter()
- .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_A))
- .map(|tag| calendar_event_target_from_tag(tag))
+ .filter(|tag| tag.first().map(String::as_str) == Some(TAG_A))
+ .map(|tag| parse_calendar_event_reference_tag(tag))
.collect()
}
-fn calendar_event_target_from_required_tag(
+fn calendar_event_reference_from_required_tag(
tags: &[Vec<String>],
-) -> Result<RadrootsSocialTarget, EventParseError> {
- let tag = tags
- .iter()
- .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_A))
- .ok_or(EventParseError::MissingTag(TAG_A))?;
- calendar_event_target_from_tag(tag)
+) -> Result<RadrootsCalendarEventReference, EventParseError> {
+ let tag = optional_reference_tag(tags, TAG_A)?.ok_or(EventParseError::MissingTag(TAG_A))?;
+ parse_calendar_event_reference_tag(tag)
}
-fn calendar_event_target_from_tag(tag: &[String]) -> Result<RadrootsSocialTarget, EventParseError> {
- let value = tag
- .get(1)
- .cloned()
- .ok_or(EventParseError::InvalidTag(TAG_A))?;
- let address = parse_address_tag(&value, TAG_A)?;
- if !is_calendar_event_kind(address.kind) {
- return Err(EventParseError::InvalidTag(TAG_A));
- }
- Ok(RadrootsSocialTarget::Address {
- address: value,
- author: Some(address.pubkey),
- event_kind: Some(address.kind),
- relays: relays_from_tag(tag, 2),
- })
+fn parse_calendar_event_reference_tag(
+ tag: &[String],
+) -> Result<RadrootsCalendarEventReference, EventParseError> {
+ validate_reference_tag_shape(tag, TAG_A)?;
+ RadrootsCalendarEventReference::parse(&tag[1], tag.get(2).map(String::as_str))
+ .map_err(|_| EventParseError::InvalidTag(TAG_A))
}
-fn relays_from_tag(tag: &[String], start: usize) -> Option<Vec<String>> {
- let relays = tag
+fn optional_reference_tag<'a>(
+ tags: &'a [Vec<String>],
+ key: &'static str,
+) -> Result<Option<&'a Vec<String>>, EventParseError> {
+ let mut matching = tags
.iter()
- .skip(start)
- .filter(|value| !value.trim().is_empty())
- .cloned()
- .collect::<Vec<_>>();
- if relays.is_empty() {
- None
- } else {
- Some(relays)
+ .filter(|tag| tag.first().map(String::as_str) == Some(key));
+ let Some(tag) = matching.next() else {
+ return Ok(None);
+ };
+ if matching.next().is_some() {
+ return Err(EventParseError::DuplicateTag(key));
}
+ validate_reference_tag_shape(tag, key)?;
+ Ok(Some(tag))
}
-fn is_calendar_event_kind(kind: u32) -> bool {
- matches!(kind, KIND_CALENDAR_DATE_EVENT | KIND_CALENDAR_TIME_EVENT)
+fn validate_reference_tag_shape(tag: &[String], key: &'static str) -> Result<(), EventParseError> {
+ if !(2..=3).contains(&tag.len())
+ || tag[1].is_empty()
+ || tag.get(2).is_some_and(String::is_empty)
+ {
+ return Err(EventParseError::InvalidTag(key));
+ }
+ Ok(())
}
fn parse_rsvp_status(value: &str) -> Result<RadrootsCalendarEventRsvpStatus, EventParseError> {
diff --git a/crates/event_codec/src/calendar/encode.rs b/crates/event_codec/src/calendar/encode.rs
@@ -1,6 +1,5 @@
#[cfg(not(feature = "std"))]
use alloc::{
- format,
string::{String, ToString},
vec,
vec::Vec,
@@ -8,27 +7,25 @@ use alloc::{
use radroots_event::{
calendar::{
- RadrootsAuthoredCalendarDateEvent, RadrootsAuthoredCalendarTimeEvent, RadrootsCalendar,
- RadrootsCalendarEventRsvp, RadrootsCalendarRequest, RadrootsCalendarUri, covered_utc_days,
+ RadrootsAuthoredCalendar, RadrootsAuthoredCalendarDateEvent,
+ RadrootsAuthoredCalendarEventRsvp, RadrootsAuthoredCalendarTimeEvent,
+ RadrootsCalendarEventAuthorReference, RadrootsCalendarEventFreeBusy,
+ RadrootsCalendarEventReference, RadrootsCalendarEventRevisionReference,
+ RadrootsCalendarEventRsvpStatus, RadrootsCalendarParticipant, RadrootsCalendarRequest,
+ RadrootsCalendarUri, covered_utc_days,
},
kinds::{
KIND_CALENDAR, KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_EVENT_RSVP, KIND_CALENDAR_TIME_EVENT,
},
- social::{
- RadrootsCalendarEventFreeBusy, RadrootsCalendarEventRsvpStatus, RadrootsSocialTarget,
- },
tags::{
- TAG_A, TAG_D, TAG_D_DAY, TAG_E, TAG_END, TAG_END_TZID, TAG_FREE_BUSY, TAG_G, TAG_IMAGE,
- TAG_LOCATION, TAG_R, TAG_START, TAG_START_TZID, TAG_STATUS, TAG_SUMMARY, TAG_T, TAG_TITLE,
+ TAG_A, TAG_D, TAG_D_DAY, TAG_DESCRIPTION, TAG_E, TAG_END, TAG_END_TZID, TAG_FREE_BUSY,
+ TAG_G, TAG_IMAGE, TAG_LOCATION, TAG_P, TAG_R, TAG_START, TAG_START_TZID, TAG_STATUS,
+ TAG_SUMMARY, TAG_T, TAG_TITLE,
},
};
-use crate::d_tag::validate_d_tag;
use crate::error::EventEncodeError;
-use crate::field_helpers::{
- parse_address_tag, push_optional_tag, push_tag, push_tag_values, validate_lowercase_hex_64,
- validate_non_empty_field,
-};
+use crate::field_helpers::{push_optional_tag, push_tag};
use crate::social_helpers::push_participants;
use radroots_event::wire::RadrootsNip01EventWireParts;
@@ -96,44 +93,41 @@ pub fn calendar_time_event_build_tags(
}
pub fn calendar_collection_build_tags(
- calendar: &RadrootsCalendar,
+ calendar: &RadrootsAuthoredCalendar,
) -> Result<Vec<Vec<String>>, EventEncodeError> {
- validate_calendar_collection(calendar)?;
let mut tags = Vec::new();
- push_tag(&mut tags, TAG_D, calendar.d_tag.as_str());
- push_tag(&mut tags, TAG_TITLE, calendar.title.as_str());
- push_optional_tag(&mut tags, TAG_SUMMARY, calendar.summary.as_deref());
- push_optional_tag(&mut tags, TAG_IMAGE, calendar.image.as_deref());
- for event in &calendar.events {
- push_calendar_event_address(&mut tags, event, "events")?;
+ push_tag(&mut tags, TAG_D, calendar.uid().as_str());
+ push_tag(&mut tags, TAG_TITLE, calendar.title());
+ for event in calendar.event_references() {
+ push_calendar_event_reference(&mut tags, event);
}
+ push_optional_tag(&mut tags, TAG_DESCRIPTION, calendar.list_description());
+ push_optional_tag(
+ &mut tags,
+ TAG_IMAGE,
+ calendar
+ .image()
+ .map(|image| image.descriptor().url().as_str()),
+ );
Ok(tags)
}
pub fn rsvp_build_tags(
- rsvp: &RadrootsCalendarEventRsvp,
+ rsvp: &RadrootsAuthoredCalendarEventRsvp,
) -> Result<Vec<Vec<String>>, EventEncodeError> {
- validate_rsvp(rsvp)?;
let mut tags = Vec::new();
- push_tag(&mut tags, TAG_D, rsvp.d_tag.as_str());
- push_calendar_event_address(&mut tags, &rsvp.event, "event")?;
- if let Some(event_id) = rsvp.event_id.as_deref() {
- let mut tag = vec![TAG_E.to_string(), event_id.to_string()];
- if let Some(relays) = calendar_event_relays(&rsvp.event) {
- tag.extend(
- relays
- .iter()
- .filter(|relay| !relay.trim().is_empty())
- .cloned(),
- );
- }
- tags.push(tag);
+ push_tag(&mut tags, TAG_D, rsvp.uid().as_str());
+ push_calendar_event_reference(&mut tags, rsvp.event_reference());
+ push_tag(&mut tags, TAG_STATUS, rsvp_status_as_str(rsvp.status()));
+ if let Some(reference) = rsvp.revision_reference() {
+ push_calendar_revision_reference(&mut tags, reference);
}
- push_tag(&mut tags, TAG_STATUS, rsvp_status_as_str(&rsvp.status));
- if let Some(free_busy) = rsvp.free_busy.as_ref() {
+ if let Some(free_busy) = rsvp.observed_free_busy() {
push_tag(&mut tags, TAG_FREE_BUSY, free_busy_as_str(free_busy));
}
- push_participants(&mut tags, rsvp.participants.as_ref());
+ if let Some(author_hint) = rsvp.author_hint() {
+ push_calendar_author_reference(&mut tags, author_hint);
+ }
Ok(tags)
}
@@ -150,13 +144,13 @@ pub fn time_to_wire_parts(
}
pub fn calendar_to_wire_parts(
- calendar: &RadrootsCalendar,
+ calendar: &RadrootsAuthoredCalendar,
) -> Result<RadrootsNip01EventWireParts, EventEncodeError> {
calendar_to_wire_parts_with_kind(calendar, KIND_CALENDAR)
}
pub fn rsvp_to_wire_parts(
- rsvp: &RadrootsCalendarEventRsvp,
+ rsvp: &RadrootsAuthoredCalendarEventRsvp,
) -> Result<RadrootsNip01EventWireParts, EventEncodeError> {
rsvp_to_wire_parts_with_kind(rsvp, KIND_CALENDAR_EVENT_RSVP)
}
@@ -190,7 +184,7 @@ pub fn time_to_wire_parts_with_kind(
}
pub fn calendar_to_wire_parts_with_kind(
- calendar: &RadrootsCalendar,
+ calendar: &RadrootsAuthoredCalendar,
kind: u32,
) -> Result<RadrootsNip01EventWireParts, EventEncodeError> {
if kind != KIND_CALENDAR {
@@ -198,13 +192,13 @@ pub fn calendar_to_wire_parts_with_kind(
}
Ok(RadrootsNip01EventWireParts {
kind,
- content: calendar.description.clone().unwrap_or_default(),
+ content: calendar.content().to_string(),
tags: calendar_collection_build_tags(calendar)?,
})
}
pub fn rsvp_to_wire_parts_with_kind(
- rsvp: &RadrootsCalendarEventRsvp,
+ rsvp: &RadrootsAuthoredCalendarEventRsvp,
kind: u32,
) -> Result<RadrootsNip01EventWireParts, EventEncodeError> {
if kind != KIND_CALENDAR_EVENT_RSVP {
@@ -212,7 +206,7 @@ pub fn rsvp_to_wire_parts_with_kind(
}
Ok(RadrootsNip01EventWireParts {
kind,
- content: rsvp.note.clone().unwrap_or_default(),
+ content: rsvp.note().unwrap_or_default().to_string(),
tags: rsvp_build_tags(rsvp)?,
})
}
@@ -238,7 +232,7 @@ fn push_authored_calendar_common_tags(
geohash: Option<&str>,
summary: Option<&str>,
image: Option<&str>,
- participants: Option<&Vec<radroots_event::social::RadrootsCalendarParticipant>>,
+ participants: Option<&Vec<RadrootsCalendarParticipant>>,
categories: &[String],
references: &[RadrootsCalendarUri],
calendar_requests: &[RadrootsCalendarRequest],
@@ -265,85 +259,40 @@ fn push_authored_calendar_common_tags(
}
}
-fn validate_calendar_collection(calendar: &RadrootsCalendar) -> Result<(), EventEncodeError> {
- validate_d_tag(&calendar.d_tag, "d_tag")?;
- validate_non_empty_field(&calendar.title, "title")?;
- if calendar.events.is_empty() {
- return Err(EventEncodeError::EmptyRequiredField("events"));
- }
- Ok(())
-}
-
-fn validate_rsvp(rsvp: &RadrootsCalendarEventRsvp) -> Result<(), EventEncodeError> {
- validate_d_tag(&rsvp.d_tag, "d_tag")?;
- validate_calendar_event_address(&rsvp.event, "event")?;
- if let Some(event_id) = rsvp.event_id.as_deref() {
- validate_lowercase_hex_64(event_id, "event_id")?;
+fn push_calendar_event_reference(
+ tags: &mut Vec<Vec<String>>,
+ reference: &RadrootsCalendarEventReference,
+) {
+ let mut tag = vec![
+ TAG_A.to_string(),
+ reference.coordinate().as_str().to_string(),
+ ];
+ if let Some(relay) = reference.relay() {
+ tag.push(relay.to_string());
}
- Ok(())
+ tags.push(tag);
}
-fn push_calendar_event_address(
+fn push_calendar_revision_reference(
tags: &mut Vec<Vec<String>>,
- target: &RadrootsSocialTarget,
- field: &'static str,
-) -> Result<(), EventEncodeError> {
- let RadrootsSocialTarget::Address {
- address,
- event_kind,
- relays,
- ..
- } = target
- else {
- return Err(EventEncodeError::InvalidField(field));
- };
- let address =
- parse_address_tag(address, field).map_err(|_| EventEncodeError::InvalidField(field))?;
- if !is_calendar_event_kind(address.kind) {
- return Err(EventEncodeError::InvalidField(field));
- }
- if let Some(event_kind) = event_kind
- && *event_kind != address.kind
- {
- return Err(EventEncodeError::InvalidField(field));
- }
- let value = format!("{}:{}:{}", address.kind, address.pubkey, address.d_tag);
- if let Some(relays) = relays.as_ref() {
- let mut values = Vec::with_capacity(1 + relays.len());
- values.push(value);
- values.extend(
- relays
- .iter()
- .filter(|relay| !relay.trim().is_empty())
- .cloned(),
- );
- push_tag_values(tags, TAG_A, values);
- } else {
- push_tag(tags, TAG_A, value);
+ reference: &RadrootsCalendarEventRevisionReference,
+) {
+ let mut tag = vec![TAG_E.to_string(), reference.event_id().as_str().to_string()];
+ if let Some(relay) = reference.relay() {
+ tag.push(relay.to_string());
}
- Ok(())
-}
-
-fn validate_calendar_event_address(
- target: &RadrootsSocialTarget,
- field: &'static str,
-) -> Result<(), EventEncodeError> {
- let mut tags = Vec::new();
- push_calendar_event_address(&mut tags, target, field)
+ tags.push(tag);
}
-fn calendar_event_relays(target: &RadrootsSocialTarget) -> Option<&Vec<String>> {
- match target {
- RadrootsSocialTarget::Address {
- relays: Some(relays),
- ..
- } => Some(relays),
- _ => None,
+fn push_calendar_author_reference(
+ tags: &mut Vec<Vec<String>>,
+ reference: &RadrootsCalendarEventAuthorReference,
+) {
+ let mut tag = vec![TAG_P.to_string(), reference.pubkey().as_str().to_string()];
+ if let Some(relay) = reference.relay() {
+ tag.push(relay.to_string());
}
-}
-
-fn is_calendar_event_kind(kind: u32) -> bool {
- matches!(kind, KIND_CALENDAR_DATE_EVENT | KIND_CALENDAR_TIME_EVENT)
+ tags.push(tag);
}
fn rsvp_status_as_str(status: &RadrootsCalendarEventRsvpStatus) -> &'static str {
diff --git a/crates/event_codec/src/list/decode.rs b/crates/event_codec/src/list/decode.rs
@@ -2,11 +2,12 @@
use alloc::{string::String, vec::Vec};
use radroots_event::{
- kinds::{KIND_LIST_READ_WRITE_RELAYS, is_nip51_list_set_kind, is_nip51_standard_list_kind},
+ kinds::KIND_LIST_READ_WRITE_RELAYS,
list::{RadrootsList, RadrootsListEntry},
tags::TAG_R,
};
+use super::is_generic_list_codec_kind;
use crate::error::EventParseError;
use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent};
@@ -40,9 +41,9 @@ pub fn list_from_tags(
content: String,
tags: &[Vec<String>],
) -> Result<RadrootsList, EventParseError> {
- if !is_supported_list_kind(kind) {
+ if !is_generic_list_codec_kind(kind) {
return Err(EventParseError::InvalidKind {
- expected: "nip51 standard or list-set kind",
+ expected: "generic nip51 standard or non-calendar list-set kind",
got: kind,
});
}
@@ -53,10 +54,6 @@ pub fn list_from_tags(
Ok(RadrootsList { content, entries })
}
-fn is_supported_list_kind(kind: u32) -> bool {
- is_nip51_standard_list_kind(kind) || is_nip51_list_set_kind(kind)
-}
-
fn validate_relay_tags(tags: &[Vec<String>]) -> Result<(), EventParseError> {
if tags.is_empty() {
return Err(EventParseError::MissingTag(TAG_R));
diff --git a/crates/event_codec/src/list/encode.rs b/crates/event_codec/src/list/encode.rs
@@ -2,11 +2,12 @@
use alloc::{string::String, vec::Vec};
use radroots_event::{
- kinds::{KIND_LIST_READ_WRITE_RELAYS, is_nip51_list_set_kind, is_nip51_standard_list_kind},
+ kinds::KIND_LIST_READ_WRITE_RELAYS,
list::{RadrootsList, RadrootsListEntry},
tags::TAG_R,
};
+use super::is_generic_list_codec_kind;
use crate::error::EventEncodeError;
use radroots_event::wire::RadrootsNip01EventWireParts;
@@ -45,7 +46,7 @@ pub fn to_wire_parts_with_kind(
list: &RadrootsList,
kind: u32,
) -> Result<RadrootsNip01EventWireParts, EventEncodeError> {
- if !is_supported_list_kind(kind) {
+ if !is_generic_list_codec_kind(kind) {
return Err(EventEncodeError::InvalidKind(kind));
}
if kind == KIND_LIST_READ_WRITE_RELAYS {
@@ -59,10 +60,6 @@ pub fn to_wire_parts_with_kind(
})
}
-fn is_supported_list_kind(kind: u32) -> bool {
- is_nip51_standard_list_kind(kind) || is_nip51_list_set_kind(kind)
-}
-
fn validate_relay_entries(entries: &[RadrootsListEntry]) -> Result<(), EventEncodeError> {
if entries.is_empty() {
return Err(EventEncodeError::EmptyRequiredField("relay.entries"));
diff --git a/crates/event_codec/src/list/mod.rs b/crates/event_codec/src/list/mod.rs
@@ -1,11 +1,17 @@
pub mod decode;
pub mod encode;
+use radroots_event::kinds::{KIND_CALENDAR, is_nip51_list_set_kind, is_nip51_standard_list_kind};
+
+pub(crate) fn is_generic_list_codec_kind(kind: u32) -> bool {
+ is_nip51_standard_list_kind(kind) || (is_nip51_list_set_kind(kind) && kind != KIND_CALENDAR)
+}
+
#[cfg(test)]
mod tests {
- use super::{decode::list_from_tags, encode::list_build_tags};
+ use super::{decode::list_from_tags, encode::list_build_tags, is_generic_list_codec_kind};
use radroots_event::{
- kinds::KIND_LIST_MUTE,
+ kinds::{KIND_CALENDAR, KIND_LIST_MUTE, KIND_LIST_SET_FOLLOW, KIND_POST},
list::{RadrootsList, RadrootsListEntry},
};
@@ -34,4 +40,12 @@ mod tests {
assert_eq!(parsed.entries[1].tag, "t");
assert_eq!(parsed.entries[1].values[0], "radroots");
}
+
+ #[test]
+ fn generic_list_codec_kind_excludes_calendar() {
+ assert!(is_generic_list_codec_kind(KIND_LIST_MUTE));
+ assert!(is_generic_list_codec_kind(KIND_LIST_SET_FOLLOW));
+ assert!(!is_generic_list_codec_kind(KIND_CALENDAR));
+ assert!(!is_generic_list_codec_kind(KIND_POST));
+ }
}
diff --git a/crates/event_codec/src/list_set/decode.rs b/crates/event_codec/src/list_set/decode.rs
@@ -1,9 +1,7 @@
#[cfg(not(feature = "std"))]
use alloc::{string::String, vec::Vec};
-use radroots_event::{
- kinds::is_nip51_list_set_kind, list::RadrootsListEntry, list_set::RadrootsListSet,
-};
+use radroots_event::{list::RadrootsListEntry, list_set::RadrootsListSet};
use crate::error::EventParseError;
#[cfg(feature = "serde_json")]
@@ -36,9 +34,9 @@ pub fn list_set_from_tags(
content: String,
tags: &[Vec<String>],
) -> Result<RadrootsListSet, EventParseError> {
- if !is_nip51_list_set_kind(kind) {
+ if !super::is_generic_list_set_codec_kind(kind) {
return Err(EventParseError::InvalidKind {
- expected: "nip51 list set kind",
+ expected: "generic nip51 list-set kind",
got: kind,
});
}
diff --git a/crates/event_codec/src/list_set/encode.rs b/crates/event_codec/src/list_set/encode.rs
@@ -5,7 +5,7 @@ use alloc::{
vec::Vec,
};
-use radroots_event::{kinds::is_nip51_list_set_kind, list_set::RadrootsListSet};
+use radroots_event::list_set::RadrootsListSet;
use crate::error::EventEncodeError;
#[cfg(feature = "serde_json")]
@@ -62,7 +62,7 @@ pub fn to_wire_parts_with_kind(
list: &RadrootsListSet,
kind: u32,
) -> Result<RadrootsNip01EventWireParts, EventEncodeError> {
- if !is_nip51_list_set_kind(kind) {
+ if !super::is_generic_list_set_codec_kind(kind) {
return Err(EventEncodeError::InvalidKind(kind));
}
let tags = list_set_build_tags(list)?;
diff --git a/crates/event_codec/src/list_set/mod.rs b/crates/event_codec/src/list_set/mod.rs
@@ -2,6 +2,11 @@ pub mod decode;
pub mod encode;
use crate::d_tag::is_d_tag_base64url;
+use radroots_event::kinds::{KIND_CALENDAR, is_nip51_list_set_kind};
+
+pub(crate) const fn is_generic_list_set_codec_kind(kind: u32) -> bool {
+ is_nip51_list_set_kind(kind) && kind != KIND_CALENDAR
+}
fn list_set_requires_base64(d_tag: &str) -> bool {
d_tag.starts_with("farm:") || d_tag.starts_with("coop:") || d_tag.starts_with("resource:")
@@ -20,15 +25,25 @@ fn list_set_base64_id_is_valid(d_tag: &str) -> bool {
#[cfg(test)]
mod tests {
- use super::{decode::list_set_from_tags, encode::list_set_build_tags};
+ use super::{
+ decode::list_set_from_tags, encode::list_set_build_tags, is_generic_list_set_codec_kind,
+ };
use crate::error::{EventEncodeError, EventParseError};
use radroots_event::{
- kinds::{KIND_LIST_SET_FOLLOW, KIND_POST},
+ kinds::{KIND_CALENDAR, KIND_LIST_SET_FOLLOW, KIND_POST, is_nip51_list_set_kind},
list::RadrootsListEntry,
list_set::RadrootsListSet,
};
#[test]
+ fn generic_list_set_codec_kind_excludes_calendar() {
+ assert!(is_nip51_list_set_kind(KIND_CALENDAR));
+ assert!(!is_generic_list_set_codec_kind(KIND_CALENDAR));
+ assert!(is_generic_list_set_codec_kind(KIND_LIST_SET_FOLLOW));
+ assert!(!is_generic_list_set_codec_kind(KIND_POST));
+ }
+
+ #[test]
fn list_set_tags_round_trip() {
let list = RadrootsListSet {
d_tag: "members.owners".to_string(),
@@ -164,7 +179,7 @@ mod tests {
assert!(matches!(
err,
EventParseError::InvalidKind {
- expected: "nip51 list set kind",
+ expected: "generic nip51 list-set kind",
got: KIND_POST
}
));
diff --git a/crates/event_codec/src/manifest.rs b/crates/event_codec/src/manifest.rs
@@ -330,6 +330,9 @@ fn tag_cardinality_label(value: RadrootsTagCardinality) -> &'static str {
fn tag_semantic_label(value: RadrootsTagSemantic) -> &'static str {
match value {
RadrootsTagSemantic::AddressableCoordinate => "addressable_coordinate",
+ RadrootsTagSemantic::CalendarEventAuthor => "calendar_event_author",
+ RadrootsTagSemantic::CalendarEventReference => "calendar_event_reference",
+ RadrootsTagSemantic::CalendarEventRevision => "calendar_event_revision",
RadrootsTagSemantic::CalendarInclusionRequest => "calendar_inclusion_request",
RadrootsTagSemantic::CalendarEnd => "calendar_end",
RadrootsTagSemantic::CalendarStart => "calendar_start",
@@ -339,6 +342,7 @@ fn tag_semantic_label(value: RadrootsTagSemantic) -> &'static str {
RadrootsTagSemantic::Counterparty => "counterparty",
RadrootsTagSemantic::Evidence => "evidence",
RadrootsTagSemantic::EventPointer => "event_pointer",
+ RadrootsTagSemantic::FreeBusy => "free_busy",
RadrootsTagSemantic::Geohash => "geohash",
RadrootsTagSemantic::GroupId => "group_id",
RadrootsTagSemantic::Identifier => "identifier",
@@ -346,6 +350,7 @@ fn tag_semantic_label(value: RadrootsTagSemantic) -> &'static str {
RadrootsTagSemantic::Kind => "kind",
RadrootsTagSemantic::ListingAddress => "listing_address",
RadrootsTagSemantic::ListingSnapshot => "listing_snapshot",
+ RadrootsTagSemantic::ListDescription => "list_description",
RadrootsTagSemantic::Location => "location",
RadrootsTagSemantic::Participant => "participant",
RadrootsTagSemantic::PreviousEvent => "previous_event",
@@ -372,6 +377,10 @@ fn tag_value_type_label(value: RadrootsTagValueType) -> &'static str {
match value {
RadrootsTagValueType::AddressableCoordinate => "addressable_coordinate",
RadrootsTagValueType::CalendarDate => "calendar_date",
+ RadrootsTagValueType::CalendarEventCoordinate => "calendar_event_coordinate",
+ RadrootsTagValueType::CalendarFreeBusy => "calendar_free_busy",
+ RadrootsTagValueType::CalendarRsvpStatus => "calendar_rsvp_status",
+ RadrootsTagValueType::CalendarUid => "calendar_uid",
RadrootsTagValueType::ContractId => "contract_id",
RadrootsTagValueType::DTag => "d_tag",
RadrootsTagValueType::EventId => "event_id",
diff --git a/crates/event_codec/src/social_helpers.rs b/crates/event_codec/src/social_helpers.rs
@@ -6,9 +6,12 @@ use alloc::{
vec::Vec,
};
-use radroots_event::social::{
- RadrootsCalendarParticipant, RadrootsSocialFarmAnchor, RadrootsSocialLocation,
- RadrootsSocialMediaDimensions, RadrootsSocialMediaThumbnail,
+use radroots_event::{
+ calendar::RadrootsCalendarParticipant,
+ social::{
+ RadrootsSocialFarmAnchor, RadrootsSocialLocation, RadrootsSocialMediaDimensions,
+ RadrootsSocialMediaThumbnail,
+ },
};
use crate::error::{EventEncodeError, EventParseError};
@@ -57,31 +60,6 @@ pub(crate) fn push_farm_anchor(tags: &mut Vec<Vec<String>>, farm: &RadrootsSocia
push_tag(tags, "a", address);
}
-pub(crate) fn participants_from_tags(
- tags: &[Vec<String>],
-) -> Option<Vec<RadrootsCalendarParticipant>> {
- let participants = tags
- .iter()
- .filter(|tag| tag.first().map(|value| value.as_str()) == Some("p"))
- .filter_map(|tag| {
- let pubkey = tag.get(1)?.clone();
- if pubkey.trim().is_empty() {
- return None;
- }
- Some(RadrootsCalendarParticipant {
- pubkey,
- relay: tag.get(2).filter(|value| !value.trim().is_empty()).cloned(),
- role: tag.get(3).filter(|value| !value.trim().is_empty()).cloned(),
- })
- })
- .collect::<Vec<_>>();
- if participants.is_empty() {
- None
- } else {
- Some(participants)
- }
-}
-
pub(crate) fn push_participants(
tags: &mut Vec<Vec<String>>,
participants: Option<&Vec<RadrootsCalendarParticipant>>,
@@ -171,7 +149,7 @@ mod tests {
}
#[test]
- fn encodes_and_decodes_location_participant_and_dimensions_tags() {
+ fn encodes_location_participant_and_dimensions_tags() {
let mut tags = Vec::new();
push_location_tags(
&mut tags,
@@ -202,10 +180,6 @@ mod tests {
.expect("geohash location");
assert_eq!(geohash_location.name, None);
assert_eq!(geohash_location.geohash.as_deref(), Some("c23nb62w20st"));
- let participants = participants_from_tags(&tags).expect("participants");
- assert_eq!(participants[0].pubkey, "crew_pubkey");
- assert_eq!(participants[0].role.as_deref(), Some("participant"));
-
let mut empty_tags = Vec::new();
push_location_tags(
&mut empty_tags,
@@ -264,25 +238,6 @@ mod tests {
]]
);
- assert_eq!(participants_from_tags(&[]), None);
- let participants = participants_from_tags(&[
- vec!["p".to_string()],
- vec!["p".to_string(), " ".to_string()],
- vec![
- "p".to_string(),
- "crew_pubkey".to_string(),
- "wss://relay.example.test".to_string(),
- "host".to_string(),
- ],
- ])
- .expect("participants");
- assert_eq!(participants.len(), 1);
- assert_eq!(
- participants[0].relay.as_deref(),
- Some("wss://relay.example.test")
- );
- assert_eq!(participants[0].role.as_deref(), Some("host"));
-
let mut participant_tags = Vec::new();
push_participants(&mut participant_tags, None);
push_participants(
diff --git a/crates/event_codec/tests/calendar.rs b/crates/event_codec/tests/calendar.rs
@@ -6,12 +6,16 @@ use radroots_event::{
RadrootsAuthoredImage, RadrootsEventTags,
calendar::{
RADROOTS_CALENDAR_MAX_COVERED_UTC_DAYS, RADROOTS_CALENDAR_SECONDS_PER_DAY,
- RadrootsAuthoredCalendarDateEvent, RadrootsAuthoredCalendarTimeEvent,
+ RadrootsAuthoredCalendar, RadrootsAuthoredCalendarDateEvent,
+ RadrootsAuthoredCalendarEventRsvp, RadrootsAuthoredCalendarTimeEvent,
RadrootsCalendarAdmissionError, RadrootsCalendarDate, RadrootsCalendarEventError,
- RadrootsIanaTimeZoneId, covered_utc_days,
+ RadrootsCalendarEventReference, RadrootsCalendarEventRsvpStatus,
+ RadrootsCalendarParticipant, RadrootsCalendarUid, RadrootsIanaTimeZoneId, covered_utc_days,
+ },
+ kinds::{
+ KIND_CALENDAR, KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_EVENT_RSVP,
+ KIND_CALENDAR_TIME_EVENT, KIND_POST,
},
- kinds::{KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_TIME_EVENT, KIND_POST},
- social::RadrootsCalendarParticipant,
tags::{
TAG_A, TAG_D, TAG_D_DAY, TAG_END, TAG_END_TZID, TAG_G, TAG_IMAGE, TAG_LOCATION, TAG_P,
TAG_R, TAG_START, TAG_START_TZID, TAG_SUMMARY, TAG_T, TAG_TITLE,
@@ -22,12 +26,16 @@ use radroots_event_codec::{
calendar::{
decode::{
admit_radroots_calendar_date_event, admit_radroots_calendar_time_event,
+ nip52_calendar_data_from_event, nip52_calendar_event_rsvp_data_from_event,
+ nip52_calendar_event_rsvp_parsed_from_event, nip52_calendar_parsed_from_event,
nip52_date_data_from_event, nip52_date_parsed_from_event,
parse_nip52_calendar_date_event, parse_nip52_calendar_time_event,
},
encode::{
- calendar_date_event_build_tags, calendar_time_event_build_tags, date_to_wire_parts,
- date_to_wire_parts_with_kind, time_to_wire_parts, time_to_wire_parts_with_kind,
+ calendar_date_event_build_tags, calendar_time_event_build_tags, calendar_to_wire_parts,
+ calendar_to_wire_parts_with_kind, date_to_wire_parts, date_to_wire_parts_with_kind,
+ rsvp_to_wire_parts, rsvp_to_wire_parts_with_kind, time_to_wire_parts,
+ time_to_wire_parts_with_kind,
},
},
error::{EventEncodeError, EventParseError},
@@ -602,6 +610,88 @@ fn kind_specific_encoders_and_parsed_wrappers_preserve_envelopes() {
.unwrap();
assert_eq!(parsed.event.sig_str(), EVENT_SIG);
assert_eq!(parsed.data.data.common().d_tag(), DATE_D_TAG);
+
+ let event_reference = RadrootsCalendarEventReference::parse(
+ format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{TIME_D_TAG}"),
+ Some("wss://relay.example.test/events"),
+ )
+ .unwrap();
+ let calendar = RadrootsAuthoredCalendar::new(
+ RadrootsCalendarUid::parse("AAAAAAAAAAAAAAAAAAAAAA").unwrap(),
+ "Farm calendar",
+ "Shared farm schedule.",
+ vec![event_reference.clone()],
+ )
+ .unwrap();
+ assert!(matches!(
+ calendar_to_wire_parts_with_kind(&calendar, KIND_POST),
+ Err(EventEncodeError::InvalidKind(KIND_POST))
+ ));
+ let parts = calendar_to_wire_parts(&calendar).unwrap();
+ assert_eq!(parts.kind, KIND_CALENDAR);
+ let data = nip52_calendar_data_from_event(
+ EVENT_ID.to_string(),
+ EVENT_AUTHOR.to_string(),
+ 8,
+ parts.kind,
+ parts.content.clone(),
+ parts.tags.clone(),
+ )
+ .unwrap();
+ assert_eq!(
+ data.data.event_references(),
+ core::slice::from_ref(&event_reference)
+ );
+ let parsed = nip52_calendar_parsed_from_event(
+ EVENT_ID.to_string(),
+ EVENT_AUTHOR.to_string(),
+ 8,
+ parts.kind,
+ parts.content,
+ parts.tags,
+ EVENT_SIG.to_string(),
+ )
+ .unwrap();
+ assert_eq!(parsed.event.sig_str(), EVENT_SIG);
+ assert_eq!(parsed.data.data.title(), "Farm calendar");
+
+ let rsvp = RadrootsAuthoredCalendarEventRsvp::new(
+ RadrootsCalendarUid::parse("DDDDDDDDDDDDDDDDDDDDDw").unwrap(),
+ event_reference,
+ RadrootsCalendarEventRsvpStatus::Accepted,
+ )
+ .unwrap();
+ assert!(matches!(
+ rsvp_to_wire_parts_with_kind(&rsvp, KIND_POST),
+ Err(EventEncodeError::InvalidKind(KIND_POST))
+ ));
+ let parts = rsvp_to_wire_parts(&rsvp).unwrap();
+ assert_eq!(parts.kind, KIND_CALENDAR_EVENT_RSVP);
+ let data = nip52_calendar_event_rsvp_data_from_event(
+ EVENT_ID.to_string(),
+ EVENT_AUTHOR.to_string(),
+ 9,
+ parts.kind,
+ parts.content.clone(),
+ parts.tags.clone(),
+ )
+ .unwrap();
+ assert_eq!(
+ data.data.status(),
+ &RadrootsCalendarEventRsvpStatus::Accepted
+ );
+ let parsed = nip52_calendar_event_rsvp_parsed_from_event(
+ EVENT_ID.to_string(),
+ EVENT_AUTHOR.to_string(),
+ 9,
+ parts.kind,
+ parts.content,
+ parts.tags,
+ EVENT_SIG.to_string(),
+ )
+ .unwrap();
+ assert_eq!(parsed.event.sig_str(), EVENT_SIG);
+ assert_eq!(parsed.data.data.d_tag(), "DDDDDDDDDDDDDDDDDDDDDw");
}
#[test]
diff --git a/crates/event_codec/tests/calendar_conformance.rs b/crates/event_codec/tests/calendar_conformance.rs
@@ -1,26 +1,35 @@
use std::{borrow::Cow, fs, path::Path};
-use radroots_blossom::{RadrootsBlossomBlobDescriptor, RadrootsBlossomByteVerifiedDescriptor};
+use radroots_blossom::{
+ RadrootsBlossomBlobDescriptor, RadrootsBlossomByteVerifiedDescriptor, RadrootsBlossomError,
+};
use radroots_event::{
RadrootsAuthoredImage,
calendar::{
- RADROOTS_CALENDAR_MAX_COVERED_UTC_DAYS, RadrootsAuthoredCalendarDateEvent,
+ RADROOTS_CALENDAR_MAX_COVERED_UTC_DAYS, RadrootsAuthoredCalendar,
+ RadrootsAuthoredCalendarDateEvent, RadrootsAuthoredCalendarEventRsvp,
RadrootsAuthoredCalendarTimeEvent, RadrootsCalendarAdmissionError, RadrootsCalendarDate,
- RadrootsCalendarEventError, RadrootsCalendarRequest, RadrootsCalendarUri,
- RadrootsIanaTimeZoneId, RadrootsParsedNip52CalendarCommon,
+ RadrootsCalendarEventAuthorReference, RadrootsCalendarEventError,
+ RadrootsCalendarEventFreeBusy, RadrootsCalendarEventReference,
+ RadrootsCalendarEventRevisionReference, RadrootsCalendarEventRsvpStatus,
+ RadrootsCalendarParticipant, RadrootsCalendarRequest, RadrootsCalendarUid,
+ RadrootsCalendarUri, RadrootsIanaTimeZoneId, RadrootsParsedNip52Calendar,
+ RadrootsParsedNip52CalendarCommon, RadrootsParsedNip52CalendarEventRsvp,
},
contract::validate_event_contract_parts,
- social::RadrootsCalendarParticipant,
};
use radroots_event_codec::{
calendar::{
decode::{
- admit_radroots_calendar_date_event, admit_radroots_calendar_time_event,
- parse_nip52_calendar_date_event, parse_nip52_calendar_time_event,
+ admit_radroots_calendar, admit_radroots_calendar_date_event,
+ admit_radroots_calendar_event_rsvp, admit_radroots_calendar_time_event,
+ parse_nip52_calendar, parse_nip52_calendar_date_event, parse_nip52_calendar_event_rsvp,
+ parse_nip52_calendar_time_event,
},
encode::{
- calendar_date_event_build_tags, calendar_time_event_build_tags, date_to_wire_parts,
- time_to_wire_parts,
+ calendar_collection_build_tags, calendar_date_event_build_tags,
+ calendar_time_event_build_tags, calendar_to_wire_parts, date_to_wire_parts,
+ rsvp_build_tags, rsvp_to_wire_parts, time_to_wire_parts,
},
},
error::EventParseError,
@@ -34,6 +43,37 @@ const WORKSPACE_BASELINE_PATH: &str =
const WORKSPACE_PROFILE_PATH: &str =
"../../contracts/conformance/vectors/calendar/radroots_profile.v1.json";
const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml";
+const BASELINE_VECTOR_KIND_COUNTS: &[(&str, usize)] = &[
+ ("calendar.baseline.collection.invalid", 15),
+ ("calendar.baseline.collection.valid", 4),
+ ("calendar.baseline.date.invalid", 8),
+ ("calendar.baseline.date.valid", 3),
+ ("calendar.baseline.rsvp.invalid", 23),
+ ("calendar.baseline.rsvp.valid", 4),
+ ("calendar.baseline.time.invalid", 4),
+ ("calendar.baseline.time.valid", 4),
+];
+const PROFILE_VECTOR_KIND_COUNTS: &[(&str, usize)] = &[
+ ("calendar.profile.admit.collection.invalid", 6),
+ ("calendar.profile.admit.collection.valid", 2),
+ ("calendar.profile.admit.date.invalid", 4),
+ ("calendar.profile.admit.date.valid", 1),
+ ("calendar.profile.admit.rsvp.invalid", 9),
+ ("calendar.profile.admit.rsvp.valid", 3),
+ ("calendar.profile.admit.time.invalid", 4),
+ ("calendar.profile.admit.time.valid", 1),
+ ("calendar.profile.authored.collection.invalid", 6),
+ ("calendar.profile.authored.collection.valid", 3),
+ ("calendar.profile.authored.date.invalid", 3),
+ ("calendar.profile.authored.date.valid", 1),
+ ("calendar.profile.authored.rsvp.invalid", 6),
+ ("calendar.profile.authored.rsvp.valid", 2),
+ ("calendar.profile.authored.time.coverage.valid", 1),
+ ("calendar.profile.authored.time.invalid", 2),
+ ("calendar.profile.authored.time.valid", 1),
+ ("calendar.profile.date.parse.invalid", 2),
+ ("calendar.profile.date.parse.valid", 2),
+];
#[test]
fn checked_in_baseline_vectors_execute_against_tolerant_nip52_parsers() {
@@ -41,7 +81,7 @@ fn checked_in_baseline_vectors_execute_against_tolerant_nip52_parsers() {
assert_eq!(suite["suite"], "calendar_nip52_baseline");
assert_eq!(suite["contract_version"], "0.1.0");
let vectors = suite["vectors"].as_array().expect("baseline vectors");
- assert!(!vectors.is_empty());
+ assert_vector_kind_inventory(vectors, BASELINE_VECTOR_KIND_COUNTS);
for vector in vectors {
execute_baseline(vector);
}
@@ -53,12 +93,30 @@ fn checked_in_profile_vectors_execute_against_authored_and_admission_apis() {
assert_eq!(suite["suite"], "calendar_radroots_profile");
assert_eq!(suite["contract_version"], "0.1.0");
let vectors = suite["vectors"].as_array().expect("profile vectors");
- assert!(!vectors.is_empty());
+ assert_vector_kind_inventory(vectors, PROFILE_VECTOR_KIND_COUNTS);
for vector in vectors {
execute_profile(vector);
}
}
+fn assert_vector_kind_inventory(vectors: &[Value], expected: &[(&str, usize)]) {
+ assert_eq!(
+ vectors.len(),
+ expected.iter().map(|(_, count)| count).sum::<usize>(),
+ "Calendar vector inventory count drifted"
+ );
+ for (kind, expected_count) in expected {
+ let actual_count = vectors
+ .iter()
+ .filter(|vector| vector_kind(vector) == *kind)
+ .count();
+ assert_eq!(
+ actual_count, *expected_count,
+ "Calendar vector kind {kind} inventory drifted"
+ );
+ }
+}
+
fn conformance_suite(workspace_relative_path: &str, packaged: &'static str) -> Value {
let workspace_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(workspace_relative_path);
let vectors = match fs::read_to_string(&workspace_path) {
@@ -90,6 +148,10 @@ fn execute_baseline(vector: &Value) {
"calendar.baseline.date.invalid" => baseline_date_invalid(vector),
"calendar.baseline.time.valid" => baseline_time_valid(vector),
"calendar.baseline.time.invalid" => baseline_time_invalid(vector),
+ "calendar.baseline.collection.valid" => baseline_collection_valid(vector),
+ "calendar.baseline.collection.invalid" => baseline_collection_invalid(vector),
+ "calendar.baseline.rsvp.valid" => baseline_rsvp_valid(vector),
+ "calendar.baseline.rsvp.invalid" => baseline_rsvp_invalid(vector),
kind => panic!(
"{} uses unsupported baseline kind {kind}",
vector_id(vector)
@@ -108,10 +170,20 @@ fn execute_profile(vector: &Value) {
profile_authored_time_coverage_valid(vector)
}
"calendar.profile.authored.time.invalid" => profile_authored_time_invalid(vector),
+ "calendar.profile.authored.collection.valid" => profile_authored_collection_valid(vector),
+ "calendar.profile.authored.collection.invalid" => {
+ profile_authored_collection_invalid(vector)
+ }
+ "calendar.profile.authored.rsvp.valid" => profile_authored_rsvp_valid(vector),
+ "calendar.profile.authored.rsvp.invalid" => profile_authored_rsvp_invalid(vector),
"calendar.profile.admit.date.valid" => profile_admit_date_valid(vector),
"calendar.profile.admit.date.invalid" => profile_admit_date_invalid(vector),
"calendar.profile.admit.time.valid" => profile_admit_time_valid(vector),
"calendar.profile.admit.time.invalid" => profile_admit_time_invalid(vector),
+ "calendar.profile.admit.collection.valid" => profile_admit_collection_valid(vector),
+ "calendar.profile.admit.collection.invalid" => profile_admit_collection_invalid(vector),
+ "calendar.profile.admit.rsvp.valid" => profile_admit_rsvp_valid(vector),
+ "calendar.profile.admit.rsvp.invalid" => profile_admit_rsvp_invalid(vector),
kind => panic!("{} uses unsupported profile kind {kind}", vector_id(vector)),
}
}
@@ -205,6 +277,116 @@ fn baseline_time_invalid(vector: &Value) {
assert_parse_error(vector, &error);
}
+fn baseline_collection_valid(vector: &Value) {
+ let (kind, tags, content) = inbound_parts(vector);
+ let parsed = parse_nip52_calendar(kind, &tags, content)
+ .unwrap_or_else(|error| panic!("{} failed: {error}", vector_id(vector)));
+ assert_parsed_collection(&parsed, expected(vector), vector);
+}
+
+fn baseline_collection_invalid(vector: &Value) {
+ let (kind, tags, content) = inbound_parts(vector);
+ let error = parse_nip52_calendar(kind, &tags, content)
+ .expect_err("invalid baseline collection vector must fail");
+ assert_parse_error(vector, &error);
+}
+
+fn baseline_rsvp_valid(vector: &Value) {
+ let (kind, tags, content) = inbound_parts(vector);
+ let parsed = parse_nip52_calendar_event_rsvp(kind, &tags, content)
+ .unwrap_or_else(|error| panic!("{} failed: {error}", vector_id(vector)));
+ assert_parsed_rsvp(&parsed, expected(vector), vector);
+}
+
+fn baseline_rsvp_invalid(vector: &Value) {
+ let (kind, tags, content) = inbound_parts(vector);
+ let error = parse_nip52_calendar_event_rsvp(kind, &tags, content)
+ .expect_err("invalid baseline RSVP vector must fail");
+ assert_parse_error(vector, &error);
+}
+
+fn assert_parsed_collection(
+ parsed: &RadrootsParsedNip52Calendar,
+ expected: &Value,
+ vector: &Value,
+) {
+ assert_eq!(
+ parsed.d_tag(),
+ value_str(expected, "d"),
+ "{}",
+ vector_id(vector)
+ );
+ assert_eq!(
+ parsed.title(),
+ value_str(expected, "title"),
+ "{}",
+ vector_id(vector)
+ );
+ assert_eq!(
+ parsed.content(),
+ value_str(expected, "content"),
+ "{}",
+ vector_id(vector)
+ );
+ assert_optional_str(
+ parsed.list_description(),
+ expected,
+ "list_description",
+ vector,
+ );
+ assert_optional_str(
+ parsed.image().map(RadrootsCalendarUri::as_str),
+ expected,
+ "image",
+ vector,
+ );
+ assert_event_references(
+ parsed.event_references(),
+ expected,
+ "event_references",
+ vector,
+ );
+}
+
+fn assert_parsed_rsvp(
+ parsed: &RadrootsParsedNip52CalendarEventRsvp,
+ expected: &Value,
+ vector: &Value,
+) {
+ assert_eq!(
+ parsed.d_tag(),
+ value_str(expected, "d"),
+ "{}",
+ vector_id(vector)
+ );
+ assert_event_reference(
+ parsed.event_reference(),
+ &expected["event_reference"],
+ vector,
+ );
+ assert_revision_reference(parsed.revision_reference(), expected, vector);
+ assert_eq!(
+ rsvp_status_str(parsed.status()),
+ value_str(expected, "status"),
+ "{}",
+ vector_id(vector)
+ );
+ assert_optional_str(
+ parsed.observed_free_busy().map(free_busy_str),
+ expected,
+ "observed_free_busy",
+ vector,
+ );
+ assert_optional_str(
+ parsed.effective_free_busy().map(free_busy_str),
+ expected,
+ "effective_free_busy",
+ vector,
+ );
+ assert_author_reference(parsed.author_hint(), expected, vector);
+ assert_optional_str(parsed.note(), expected, "note", vector);
+}
+
fn assert_common(common: &RadrootsParsedNip52CalendarCommon, expected: &Value, vector: &Value) {
assert_eq!(
common.d_tag(),
@@ -324,6 +506,127 @@ fn assert_calendar_requests(
);
}
+fn assert_event_references(
+ actual: &[RadrootsCalendarEventReference],
+ expected: &Value,
+ key: &str,
+ vector: &Value,
+) {
+ let expected = expected[key]
+ .as_array()
+ .unwrap_or_else(|| panic!("{} expected.{key} must be an array", vector_id(vector)));
+ assert_eq!(actual.len(), expected.len(), "{}", vector_id(vector));
+ for (actual, expected) in actual.iter().zip(expected) {
+ assert_event_reference(actual, expected, vector);
+ }
+}
+
+fn assert_event_reference(
+ actual: &RadrootsCalendarEventReference,
+ expected: &Value,
+ vector: &Value,
+) {
+ let coordinate = value_str(expected, "coordinate");
+ let relay = expected.get("relay").and_then(Value::as_str);
+ assert_eq!(
+ actual.coordinate().as_str(),
+ coordinate,
+ "{}",
+ vector_id(vector)
+ );
+ assert_eq!(actual.relay(), relay, "{}", vector_id(vector));
+
+ let parts = radroots_event::ids::RadrootsAddressableCoordinateParts::parse(coordinate)
+ .expect("expected event reference coordinate");
+ let canonical_coordinate = format!("{}:{}:{}", parts.kind, parts.pubkey, parts.d_tag);
+ let canonical_relay =
+ relay.is_none_or(|relay| radroots_event::ids::RadrootsRelayUrl::parse(relay).is_ok());
+ assert_eq!(
+ actual.is_canonical(),
+ coordinate == canonical_coordinate && canonical_relay,
+ "{} reference canonicality",
+ vector_id(vector)
+ );
+}
+
+fn assert_revision_reference(
+ actual: Option<&RadrootsCalendarEventRevisionReference>,
+ expected: &Value,
+ vector: &Value,
+) {
+ let Some(expected) = expected
+ .get("revision_reference")
+ .and_then(Value::as_object)
+ else {
+ assert!(actual.is_none(), "{} revision reference", vector_id(vector));
+ return;
+ };
+ let actual = actual.unwrap_or_else(|| panic!("{} revision reference", vector_id(vector)));
+ let event_id = map_str(expected, "id");
+ let relay = expected.get("relay").and_then(Value::as_str);
+ assert_eq!(actual.raw_event_id(), event_id, "{}", vector_id(vector));
+ assert_eq!(
+ actual.event_id().as_str(),
+ event_id.to_ascii_lowercase(),
+ "{} normalized revision id",
+ vector_id(vector)
+ );
+ assert_eq!(actual.relay(), relay, "{}", vector_id(vector));
+ let canonical_relay =
+ relay.is_none_or(|relay| radroots_event::ids::RadrootsRelayUrl::parse(relay).is_ok());
+ assert_eq!(
+ actual.is_canonical(),
+ event_id == actual.event_id().as_str() && canonical_relay,
+ "{} revision canonicality",
+ vector_id(vector)
+ );
+}
+
+fn assert_author_reference(
+ actual: Option<&RadrootsCalendarEventAuthorReference>,
+ expected: &Value,
+ vector: &Value,
+) {
+ let Some(expected) = expected.get("author_hint").and_then(Value::as_object) else {
+ assert!(actual.is_none(), "{} author hint", vector_id(vector));
+ return;
+ };
+ let actual = actual.unwrap_or_else(|| panic!("{} author hint", vector_id(vector)));
+ let pubkey = map_str(expected, "pubkey");
+ let relay = expected.get("relay").and_then(Value::as_str);
+ assert_eq!(actual.raw_pubkey(), pubkey, "{}", vector_id(vector));
+ assert_eq!(
+ actual.pubkey().as_str(),
+ pubkey.to_ascii_lowercase(),
+ "{} normalized author key",
+ vector_id(vector)
+ );
+ assert_eq!(actual.relay(), relay, "{}", vector_id(vector));
+ let canonical_relay =
+ relay.is_none_or(|relay| radroots_event::ids::RadrootsRelayUrl::parse(relay).is_ok());
+ assert_eq!(
+ actual.is_canonical(),
+ pubkey == actual.pubkey().as_str() && canonical_relay,
+ "{} author canonicality",
+ vector_id(vector)
+ );
+}
+
+fn rsvp_status_str(status: &RadrootsCalendarEventRsvpStatus) -> &'static str {
+ match status {
+ RadrootsCalendarEventRsvpStatus::Accepted => "accepted",
+ RadrootsCalendarEventRsvpStatus::Declined => "declined",
+ RadrootsCalendarEventRsvpStatus::Tentative => "tentative",
+ }
+}
+
+fn free_busy_str(free_busy: &RadrootsCalendarEventFreeBusy) -> &'static str {
+ match free_busy {
+ RadrootsCalendarEventFreeBusy::Free => "free",
+ RadrootsCalendarEventFreeBusy::Busy => "busy",
+ }
+}
+
fn profile_date_parse_valid(vector: &Value) {
let parsed = RadrootsCalendarDate::parse(input_str(vector, "value"))
.unwrap_or_else(|error| panic!("{} failed: {error}", vector_id(vector)));
@@ -439,6 +742,173 @@ fn profile_authored_time_invalid(vector: &Value) {
assert_calendar_event_error(vector, &error);
}
+fn profile_authored_collection_valid(vector: &Value) {
+ let calendar = authored_collection(input(vector), vector_id(vector))
+ .unwrap_or_else(|error| panic!("{} failed: {error}", vector_id(vector)));
+ let wire = calendar_to_wire_parts(&calendar)
+ .unwrap_or_else(|error| panic!("{} failed: {error}", vector_id(vector)));
+ assert_eq!(
+ calendar_collection_build_tags(&calendar).unwrap(),
+ wire.tags,
+ "{}",
+ vector_id(vector)
+ );
+ assert_wire_parts(&wire, &expected(vector)["wire_parts"], vector_id(vector));
+ assert_authored_media_claims(vector, calendar.image().is_some());
+ assert_registry_accepts(
+ wire.kind,
+ &wire.tags,
+ &wire.content,
+ "radroots.calendar.collection.v1",
+ vector,
+ );
+
+ let parsed = parse_nip52_calendar(wire.kind, &wire.tags, &wire.content).unwrap();
+ assert_eq!(
+ parsed.d_tag(),
+ calendar.uid().as_str(),
+ "{}",
+ vector_id(vector)
+ );
+ assert_eq!(parsed.title(), calendar.title(), "{}", vector_id(vector));
+ assert_eq!(
+ parsed.content(),
+ calendar.content(),
+ "{}",
+ vector_id(vector)
+ );
+ assert_eq!(
+ parsed.event_references(),
+ calendar.event_references(),
+ "{}",
+ vector_id(vector)
+ );
+ assert_eq!(
+ parsed.list_description(),
+ calendar.list_description(),
+ "{}",
+ vector_id(vector)
+ );
+ assert_eq!(
+ parsed.image().map(RadrootsCalendarUri::as_str),
+ calendar
+ .image()
+ .map(|image| image.descriptor().url().as_str()),
+ "{}",
+ vector_id(vector)
+ );
+ assert!(
+ parsed
+ .event_references()
+ .iter()
+ .all(RadrootsCalendarEventReference::is_canonical),
+ "{}",
+ vector_id(vector)
+ );
+ let admitted = admit_radroots_calendar(parsed)
+ .unwrap_or_else(|error| panic!("{} admission failed: {error}", vector_id(vector)));
+ assert_eq!(admitted.uid(), calendar.uid(), "{}", vector_id(vector));
+ assert_eq!(
+ admitted.blossom_image().is_some(),
+ calendar.image().is_some(),
+ "{}",
+ vector_id(vector)
+ );
+}
+
+fn profile_authored_collection_invalid(vector: &Value) {
+ if expected_str(vector, "error") == "blob_hash_mismatch" {
+ let image = &input(vector)["image"];
+ let error = verified_descriptor_result(image).expect_err("invalid image must fail");
+ assert_eq!(
+ error.code(),
+ expected_str(vector, "error"),
+ "{}",
+ vector_id(vector)
+ );
+ return;
+ }
+ let error = authored_collection(input(vector), vector_id(vector))
+ .expect_err("invalid authored collection must fail");
+ assert_calendar_event_error(vector, &error);
+}
+
+fn profile_authored_rsvp_valid(vector: &Value) {
+ let rsvp = authored_rsvp(input(vector))
+ .unwrap_or_else(|error| panic!("{} failed: {error}", vector_id(vector)));
+ let wire = rsvp_to_wire_parts(&rsvp)
+ .unwrap_or_else(|error| panic!("{} failed: {error}", vector_id(vector)));
+ assert_eq!(
+ rsvp_build_tags(&rsvp).unwrap(),
+ wire.tags,
+ "{}",
+ vector_id(vector)
+ );
+ assert_wire_parts(&wire, &expected(vector)["wire_parts"], vector_id(vector));
+ assert_registry_accepts(
+ wire.kind,
+ &wire.tags,
+ &wire.content,
+ "radroots.calendar.rsvp.v1",
+ vector,
+ );
+ assert_optional_str(
+ rsvp.observed_free_busy().map(free_busy_str),
+ expected(vector),
+ "observed_free_busy",
+ vector,
+ );
+ assert_optional_str(
+ rsvp.effective_free_busy().map(free_busy_str),
+ expected(vector),
+ "effective_free_busy",
+ vector,
+ );
+
+ let parsed = parse_nip52_calendar_event_rsvp(wire.kind, &wire.tags, &wire.content).unwrap();
+ assert_eq!(parsed.d_tag(), rsvp.uid().as_str(), "{}", vector_id(vector));
+ assert_eq!(
+ parsed.event_reference(),
+ rsvp.event_reference(),
+ "{}",
+ vector_id(vector)
+ );
+ assert_eq!(
+ parsed.revision_reference(),
+ rsvp.revision_reference(),
+ "{}",
+ vector_id(vector)
+ );
+ assert_eq!(parsed.status(), rsvp.status(), "{}", vector_id(vector));
+ assert_eq!(
+ parsed.observed_free_busy(),
+ rsvp.observed_free_busy(),
+ "{}",
+ vector_id(vector)
+ );
+ assert_eq!(
+ parsed.effective_free_busy(),
+ rsvp.effective_free_busy(),
+ "{}",
+ vector_id(vector)
+ );
+ assert_eq!(
+ parsed.author_hint(),
+ rsvp.author_hint(),
+ "{}",
+ vector_id(vector)
+ );
+ assert_eq!(parsed.note(), rsvp.note(), "{}", vector_id(vector));
+ let admitted = admit_radroots_calendar_event_rsvp(parsed)
+ .unwrap_or_else(|error| panic!("{} admission failed: {error}", vector_id(vector)));
+ assert_eq!(admitted.uid(), rsvp.uid(), "{}", vector_id(vector));
+}
+
+fn profile_authored_rsvp_invalid(vector: &Value) {
+ let error = authored_rsvp(input(vector)).expect_err("invalid authored RSVP must fail");
+ assert_calendar_event_error(vector, &error);
+}
+
fn profile_admit_date_valid(vector: &Value) {
let (kind, tags, content) = inbound_parts(vector);
let parsed = parse_nip52_calendar_date_event(kind, &tags, content).unwrap();
@@ -464,6 +934,13 @@ fn profile_admit_date_invalid(vector: &Value) {
let error = admit_radroots_calendar_date_event(parsed)
.expect_err("invalid date admission vector must fail");
assert_admission_error(vector, &error);
+ assert_registry_rejects(
+ kind,
+ &tags,
+ content,
+ "radroots.calendar.date_event.v1",
+ vector,
+ );
}
fn profile_admit_time_valid(vector: &Value) {
@@ -500,12 +977,326 @@ fn assert_registry_accepts(
.unwrap_or_else(|error| panic!("{} registry drift: {error:?}", vector_id(vector)));
}
+fn assert_registry_rejects(
+ kind: u32,
+ tags: &[Vec<String>],
+ content: &str,
+ contract_id: &str,
+ vector: &Value,
+) {
+ assert!(
+ validate_event_contract_parts(kind, tags, content, contract_id).is_err(),
+ "{} registry accepted an admission-invalid event",
+ vector_id(vector)
+ );
+}
+
fn profile_admit_time_invalid(vector: &Value) {
let (kind, tags, content) = inbound_parts(vector);
let parsed = parse_nip52_calendar_time_event(kind, &tags, content).unwrap();
let error = admit_radroots_calendar_time_event(parsed)
.expect_err("invalid time admission vector must fail");
assert_admission_error(vector, &error);
+ assert_registry_rejects(
+ kind,
+ &tags,
+ content,
+ "radroots.calendar.time_event.v1",
+ vector,
+ );
+}
+
+fn profile_admit_collection_valid(vector: &Value) {
+ let (kind, tags, content) = inbound_parts(vector);
+ let parsed = parse_nip52_calendar(kind, &tags, content)
+ .unwrap_or_else(|error| panic!("{} parse failed: {error}", vector_id(vector)));
+ let admitted = admit_radroots_calendar(parsed)
+ .unwrap_or_else(|error| panic!("{} admission failed: {error}", vector_id(vector)));
+ let expected = expected(vector);
+ assert_eq!(
+ admitted.uid().as_str(),
+ value_str(expected, "d"),
+ "{}",
+ vector_id(vector)
+ );
+ let title = tags
+ .iter()
+ .find(|tag| tag.first().map(String::as_str) == Some("title"))
+ .and_then(|tag| tag.get(1))
+ .expect("admitted collection title tag");
+ assert_eq!(admitted.title(), title, "{}", vector_id(vector));
+ assert_eq!(admitted.content(), content, "{}", vector_id(vector));
+ assert_optional_str(
+ admitted.list_description(),
+ expected,
+ "list_description",
+ vector,
+ );
+ assert_event_references(
+ admitted.event_references(),
+ expected,
+ "event_references",
+ vector,
+ );
+ assert_eq!(
+ admitted.blossom_image().is_some(),
+ expected["blossom_image"].as_bool().unwrap(),
+ "{}",
+ vector_id(vector)
+ );
+ let image = tags
+ .iter()
+ .find(|tag| tag.first().map(String::as_str) == Some("image"))
+ .and_then(|tag| tag.get(1))
+ .map(String::as_str);
+ assert_eq!(
+ admitted.parsed().image().map(RadrootsCalendarUri::as_str),
+ image,
+ "{}",
+ vector_id(vector)
+ );
+ if let Some(level) = expected.get("media_verification") {
+ assert_eq!(level, "structural_only", "{}", vector_id(vector));
+ assert!(admitted.blossom_image().is_some(), "{}", vector_id(vector));
+ }
+ assert_registry_accepts(
+ kind,
+ &tags,
+ content,
+ "radroots.calendar.collection.v1",
+ vector,
+ );
+}
+
+fn profile_admit_collection_invalid(vector: &Value) {
+ let (kind, tags, content) = inbound_parts(vector);
+ let parsed = parse_nip52_calendar(kind, &tags, content)
+ .unwrap_or_else(|error| panic!("{} parse failed: {error}", vector_id(vector)));
+ let error =
+ admit_radroots_calendar(parsed).expect_err("invalid collection admission vector must fail");
+ assert_admission_error(vector, &error);
+ assert_registry_rejects(
+ kind,
+ &tags,
+ content,
+ "radroots.calendar.collection.v1",
+ vector,
+ );
+}
+
+fn profile_admit_rsvp_valid(vector: &Value) {
+ let (kind, tags, content) = inbound_parts(vector);
+ let parsed = parse_nip52_calendar_event_rsvp(kind, &tags, content)
+ .unwrap_or_else(|error| panic!("{} parse failed: {error}", vector_id(vector)));
+ let admitted = admit_radroots_calendar_event_rsvp(parsed)
+ .unwrap_or_else(|error| panic!("{} admission failed: {error}", vector_id(vector)));
+ let expected = expected(vector);
+ assert_eq!(
+ admitted.uid().as_str(),
+ value_str(expected, "d"),
+ "{}",
+ vector_id(vector)
+ );
+ assert_eq!(
+ rsvp_status_str(admitted.status()),
+ value_str(expected, "status"),
+ "{}",
+ vector_id(vector)
+ );
+ assert_optional_str(
+ admitted.observed_free_busy().map(free_busy_str),
+ expected,
+ "observed_free_busy",
+ vector,
+ );
+ assert_optional_str(
+ admitted.effective_free_busy().map(free_busy_str),
+ expected,
+ "effective_free_busy",
+ vector,
+ );
+ assert_admitted_rsvp_references_against_tags(&admitted, &tags, vector);
+ assert_eq!(
+ admitted.note(),
+ (!content.is_empty()).then_some(content),
+ "{}",
+ vector_id(vector)
+ );
+ if expected.get("event_reference").is_some() {
+ assert_event_reference(
+ admitted.event_reference(),
+ &expected["event_reference"],
+ vector,
+ );
+ }
+ if expected.get("revision_reference").is_some() {
+ assert_revision_reference(admitted.revision_reference(), expected, vector);
+ }
+ if expected.get("author_hint").is_some() {
+ assert_author_reference(admitted.author_hint(), expected, vector);
+ }
+ assert_registry_accepts(kind, &tags, content, "radroots.calendar.rsvp.v1", vector);
+}
+
+fn assert_admitted_rsvp_references_against_tags(
+ admitted: &radroots_event::calendar::RadrootsAdmittedCalendarEventRsvp,
+ tags: &[Vec<String>],
+ vector: &Value,
+) {
+ let event_tag = tags
+ .iter()
+ .find(|tag| tag.first().map(String::as_str) == Some("a"))
+ .expect("admitted RSVP a tag");
+ assert_eq!(
+ admitted.event_reference().coordinate().as_str(),
+ event_tag[1],
+ "{}",
+ vector_id(vector)
+ );
+ assert_eq!(
+ admitted.event_reference().relay(),
+ event_tag.get(2).map(String::as_str),
+ "{}",
+ vector_id(vector)
+ );
+ assert!(
+ admitted.event_reference().is_canonical(),
+ "{}",
+ vector_id(vector)
+ );
+
+ let revision_tag = tags
+ .iter()
+ .find(|tag| tag.first().map(String::as_str) == Some("e"));
+ match (admitted.revision_reference(), revision_tag) {
+ (Some(reference), Some(tag)) => {
+ assert_eq!(reference.raw_event_id(), tag[1], "{}", vector_id(vector));
+ assert_eq!(reference.relay(), tag.get(2).map(String::as_str));
+ assert!(reference.is_canonical(), "{}", vector_id(vector));
+ }
+ (None, None) => {}
+ _ => panic!("{} revision reference drift", vector_id(vector)),
+ }
+
+ let author_tag = tags
+ .iter()
+ .find(|tag| tag.first().map(String::as_str) == Some("p"));
+ match (admitted.author_hint(), author_tag) {
+ (Some(reference), Some(tag)) => {
+ assert_eq!(reference.raw_pubkey(), tag[1], "{}", vector_id(vector));
+ assert_eq!(reference.relay(), tag.get(2).map(String::as_str));
+ assert!(reference.is_canonical(), "{}", vector_id(vector));
+ }
+ (None, None) => {}
+ _ => panic!("{} author hint drift", vector_id(vector)),
+ }
+}
+
+fn profile_admit_rsvp_invalid(vector: &Value) {
+ let (kind, tags, content) = inbound_parts(vector);
+ let parsed = parse_nip52_calendar_event_rsvp(kind, &tags, content)
+ .unwrap_or_else(|error| panic!("{} parse failed: {error}", vector_id(vector)));
+ let error = admit_radroots_calendar_event_rsvp(parsed)
+ .expect_err("invalid RSVP admission vector must fail");
+ assert_admission_error(vector, &error);
+ assert_registry_rejects(kind, &tags, content, "radroots.calendar.rsvp.v1", vector);
+}
+
+fn authored_collection(
+ input: &Map<String, Value>,
+ vector_id: &str,
+) -> Result<RadrootsAuthoredCalendar, RadrootsCalendarEventError> {
+ let uid = RadrootsCalendarUid::parse(map_str(input, "d"))?;
+ let event_references = input["event_references"]
+ .as_array()
+ .expect("input.event_references")
+ .iter()
+ .map(calendar_event_reference)
+ .collect::<Result<Vec<_>, _>>()?;
+ let mut calendar = RadrootsAuthoredCalendar::new(
+ uid,
+ map_str(input, "title"),
+ map_optional_str(input, "content").unwrap_or_default(),
+ event_references,
+ )?;
+ if let Some(description) = map_optional_str(input, "list_description") {
+ calendar = calendar.with_list_description(description)?;
+ }
+ if let Some(image) = input.get("image") {
+ calendar = calendar.with_image(authored_image(image, vector_id))?;
+ }
+ Ok(calendar)
+}
+
+fn authored_rsvp(
+ input: &Map<String, Value>,
+) -> Result<RadrootsAuthoredCalendarEventRsvp, RadrootsCalendarEventError> {
+ let uid = RadrootsCalendarUid::parse(map_str(input, "d"))?;
+ let event_reference = calendar_event_reference(&input["event_reference"])?;
+ let mut rsvp = RadrootsAuthoredCalendarEventRsvp::new(
+ uid,
+ event_reference,
+ parse_authored_rsvp_status(map_str(input, "status")),
+ )?;
+ if let Some(reference) = input.get("revision_reference") {
+ rsvp = rsvp.with_revision_reference(calendar_revision_reference(reference)?)?;
+ }
+ if let Some(free_busy) = map_optional_str(input, "free_busy") {
+ rsvp = rsvp.with_free_busy(parse_authored_free_busy(free_busy))?;
+ }
+ if let Some(author_hint) = input.get("author_hint") {
+ rsvp = rsvp.with_author_hint(calendar_author_reference(author_hint)?)?;
+ }
+ if let Some(note) = map_optional_str(input, "note") {
+ rsvp = rsvp.with_note(note)?;
+ }
+ Ok(rsvp)
+}
+
+fn calendar_event_reference(
+ value: &Value,
+) -> Result<RadrootsCalendarEventReference, RadrootsCalendarEventError> {
+ RadrootsCalendarEventReference::parse(
+ value["coordinate"]
+ .as_str()
+ .expect("event_reference.coordinate"),
+ value.get("relay").and_then(Value::as_str),
+ )
+}
+
+fn calendar_revision_reference(
+ value: &Value,
+) -> Result<RadrootsCalendarEventRevisionReference, RadrootsCalendarEventError> {
+ RadrootsCalendarEventRevisionReference::parse(
+ value["id"].as_str().expect("revision_reference.id"),
+ value.get("relay").and_then(Value::as_str),
+ )
+}
+
+fn calendar_author_reference(
+ value: &Value,
+) -> Result<RadrootsCalendarEventAuthorReference, RadrootsCalendarEventError> {
+ RadrootsCalendarEventAuthorReference::parse(
+ value["pubkey"].as_str().expect("author_hint.pubkey"),
+ value.get("relay").and_then(Value::as_str),
+ )
+}
+
+fn parse_authored_rsvp_status(value: &str) -> RadrootsCalendarEventRsvpStatus {
+ match value {
+ "accepted" => RadrootsCalendarEventRsvpStatus::Accepted,
+ "declined" => RadrootsCalendarEventRsvpStatus::Declined,
+ "tentative" => RadrootsCalendarEventRsvpStatus::Tentative,
+ value => panic!("unsupported authored RSVP status {value}"),
+ }
+}
+
+fn parse_authored_free_busy(value: &str) -> RadrootsCalendarEventFreeBusy {
+ match value {
+ "free" => RadrootsCalendarEventFreeBusy::Free,
+ "busy" => RadrootsCalendarEventFreeBusy::Busy,
+ value => panic!("unsupported authored RSVP free/busy value {value}"),
+ }
}
fn authored_date_event(
@@ -624,21 +1415,23 @@ fn authored_image(input: &Value, vector_id: &str) -> RadrootsAuthoredImage {
}
fn verified_descriptor(input: &Value, vector_id: &str) -> RadrootsBlossomByteVerifiedDescriptor {
+ verified_descriptor_result(input)
+ .unwrap_or_else(|error| panic!("{vector_id} byte verification failed: {error}"))
+}
+
+fn verified_descriptor_result(
+ input: &Value,
+) -> Result<RadrootsBlossomByteVerifiedDescriptor, RadrootsBlossomError> {
let descriptor: RadrootsBlossomBlobDescriptor =
- serde_json::from_value(input["descriptor"].clone())
- .unwrap_or_else(|error| panic!("{vector_id} descriptor failed: {error}"));
+ serde_json::from_value(input["descriptor"].clone()).expect("image descriptor must parse");
let media_type = descriptor.media_type().clone();
- descriptor
- .approve_reference()
- .unwrap_or_else(|error| panic!("{vector_id} descriptor approval failed: {error}"))
- .verify_bytes(
- input["bytes_utf8"]
- .as_str()
- .expect("image.bytes_utf8")
- .as_bytes(),
- &media_type,
- )
- .unwrap_or_else(|error| panic!("{vector_id} byte verification failed: {error}"))
+ descriptor.approve_reference()?.verify_bytes(
+ input["bytes_utf8"]
+ .as_str()
+ .expect("image.bytes_utf8")
+ .as_bytes(),
+ &media_type,
+ )
}
fn participants(input: &Value, vector_id: &str) -> Vec<RadrootsCalendarParticipant> {
@@ -720,6 +1513,15 @@ fn assert_admission_error(vector: &Value, error: &RadrootsCalendarAdmissionError
vector_id(vector)
);
assert!(!error.to_string().is_empty());
+ if let Some(expected_field) = expected(vector).get("field").and_then(Value::as_str) {
+ let RadrootsCalendarAdmissionError::NonCanonicalField(actual_field) = error else {
+ panic!(
+ "{} expected a field-bearing admission error",
+ vector_id(vector)
+ );
+ };
+ assert_eq!(*actual_field, expected_field, "{}", vector_id(vector));
+ }
if let RadrootsCalendarAdmissionError::CoveredDayLimitExceeded { max, actual } = error {
assert_eq!(*max, expected_u64(vector, "max"));
assert_eq!(*actual, expected_u64(vector, "actual"));
diff --git a/crates/event_codec/tests/fixtures/calendar_nip52_baseline.v1.json b/crates/event_codec/tests/fixtures/calendar_nip52_baseline.v1.json
@@ -328,6 +328,658 @@
"tags": [["d", "wrong-kind"], ["title", "Wrong kind"], ["start", "2026-06-20"]]
},
"expected": { "error": "invalid_kind" }
+ },
+ {
+ "id": "baseline_collection_empty_001",
+ "kind": "calendar.baseline.collection.valid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [["d", "Victoria farm calendar"], ["title", "Victoria farm calendar"]]
+ },
+ "expected": {
+ "d": "Victoria farm calendar",
+ "title": "Victoria farm calendar",
+ "content": "",
+ "list_description": null,
+ "image": null,
+ "event_references": []
+ }
+ },
+ {
+ "id": "baseline_collection_ordered_mixed_nip51_metadata_001",
+ "kind": "calendar.baseline.collection.valid",
+ "input": {
+ "kind": 31924,
+ "content": "Shared schedule for Victoria growers.",
+ "tags": [
+ ["d", "regional-farm-calendar"],
+ ["title", "Regional farm calendar"],
+ ["description", "NIP-51 client-facing set description"],
+ ["image", "https://ordinary.example.test/calendars/victoria.jpg"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:moss-street-market", "wss://date-relay.example.test/events"],
+ ["a", "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:harvest-window", "WSS://Time-Relay.Example.Test/events"]
+ ]
+ },
+ "expected": {
+ "d": "regional-farm-calendar",
+ "title": "Regional farm calendar",
+ "content": "Shared schedule for Victoria growers.",
+ "list_description": "NIP-51 client-facing set description",
+ "image": "https://ordinary.example.test/calendars/victoria.jpg",
+ "event_references": [
+ {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:moss-street-market",
+ "relay": "wss://date-relay.example.test/events"
+ },
+ {
+ "coordinate": "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:harvest-window",
+ "relay": "WSS://Time-Relay.Example.Test/events"
+ }
+ ]
+ }
+ },
+ {
+ "id": "baseline_collection_uppercase_coordinate_and_relay_001",
+ "kind": "calendar.baseline.collection.valid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [
+ ["d", "uppercase-observed"],
+ ["title", "Uppercase observed"],
+ ["a", "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:farm-tour", "WSS://Relay.Example.Test/events"]
+ ]
+ },
+ "expected": {
+ "d": "uppercase-observed",
+ "title": "Uppercase observed",
+ "content": "",
+ "list_description": null,
+ "image": null,
+ "event_references": [
+ {
+ "coordinate": "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:farm-tour",
+ "relay": "WSS://Relay.Example.Test/events"
+ }
+ ]
+ }
+ },
+ {
+ "id": "baseline_collection_duplicate_coordinate_observed_001",
+ "kind": "calendar.baseline.collection.valid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [
+ ["d", "duplicates-observed"],
+ ["title", "Duplicates observed"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour", "wss://one.example.test"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour", "wss://two.example.test"]
+ ]
+ },
+ "expected": {
+ "d": "duplicates-observed",
+ "title": "Duplicates observed",
+ "content": "",
+ "list_description": null,
+ "image": null,
+ "event_references": [
+ {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour",
+ "relay": "wss://one.example.test"
+ },
+ {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour",
+ "relay": "wss://two.example.test"
+ }
+ ]
+ }
+ },
+ {
+ "id": "baseline_collection_missing_d_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {"kind": 31924, "content": "", "tags": [["title", "Missing identifier"]]},
+ "expected": {"error": "missing_tag", "tag": "d"}
+ },
+ {
+ "id": "baseline_collection_missing_title_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {"kind": 31924, "content": "", "tags": [["d", "missing-title"]]},
+ "expected": {"error": "missing_tag", "tag": "title"}
+ },
+ {
+ "id": "baseline_collection_duplicate_d_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [["d", "one"], ["d", "two"], ["title", "Duplicate identifier"]]
+ },
+ "expected": {"error": "duplicate_tag", "tag": "d"}
+ },
+ {
+ "id": "baseline_collection_duplicate_title_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [["d", "duplicate-title"], ["title", "One"], ["title", "Two"]]
+ },
+ "expected": {"error": "duplicate_tag", "tag": "title"}
+ },
+ {
+ "id": "baseline_collection_d_extra_element_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [["d", "extra", "unexpected"], ["title", "Bad identifier tag"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "d"}
+ },
+ {
+ "id": "baseline_collection_title_extra_element_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [["d", "bad-title-tag"], ["title", "Bad title tag", "unexpected"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "title"}
+ },
+ {
+ "id": "baseline_collection_a_extra_element_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [
+ ["d", "bad-a-arity"],
+ ["title", "Bad a arity"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour", "wss://relay.example.test", "unexpected"]
+ ]
+ },
+ "expected": {"error": "invalid_tag", "tag": "a"}
+ },
+ {
+ "id": "baseline_collection_malformed_coordinate_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [["d", "bad-coordinate"], ["title", "Bad coordinate"], ["a", "31922:not-a-pubkey:farm-tour"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "a"}
+ },
+ {
+ "id": "baseline_collection_wrong_coordinate_kind_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [["d", "wrong-coordinate-kind"], ["title", "Wrong coordinate kind"], ["a", "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:another-calendar"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "a"}
+ },
+ {
+ "id": "baseline_collection_invalid_relay_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [
+ ["d", "bad-relay"],
+ ["title", "Bad relay"],
+ ["a", "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:harvest-window", "https://relay.example.test"]
+ ]
+ },
+ "expected": {"error": "invalid_tag", "tag": "a"}
+ },
+ {
+ "id": "baseline_collection_tag_element_over_limit_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [["d", "oversized-title"], ["title", "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"]]
+ },
+ "expected": {"error": "invalid_envelope"}
+ },
+ {
+ "id": "baseline_rsvp_minimal_accepted_001",
+ "kind": "calendar.baseline.rsvp.valid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "farm-tour-rsvp"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"],
+ ["status", "accepted"]
+ ]
+ },
+ "expected": {
+ "d": "farm-tour-rsvp",
+ "event_reference": {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour",
+ "relay": null
+ },
+ "revision_reference": null,
+ "status": "accepted",
+ "observed_free_busy": null,
+ "effective_free_busy": null,
+ "author_hint": null,
+ "note": null
+ }
+ },
+ {
+ "id": "baseline_rsvp_complete_independent_hints_001",
+ "kind": "calendar.baseline.rsvp.valid",
+ "input": {
+ "kind": 31925,
+ "content": "I can help at the produce table.",
+ "tags": [
+ ["d", "market-rsvp"],
+ ["a", "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:market-shift", "wss://coordinate-relay.example.test/events"],
+ ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "wss://event-relay.example.test/events"],
+ ["status", "tentative"],
+ ["fb", "busy"],
+ ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://author-relay.example.test/events"]
+ ]
+ },
+ "expected": {
+ "d": "market-rsvp",
+ "event_reference": {
+ "coordinate": "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:market-shift",
+ "relay": "wss://coordinate-relay.example.test/events"
+ },
+ "revision_reference": {
+ "id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ "relay": "wss://event-relay.example.test/events"
+ },
+ "status": "tentative",
+ "observed_free_busy": "busy",
+ "effective_free_busy": "busy",
+ "author_hint": {
+ "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relay": "wss://author-relay.example.test/events"
+ },
+ "note": "I can help at the produce table."
+ }
+ },
+ {
+ "id": "baseline_rsvp_declined_observed_free_busy_001",
+ "kind": "calendar.baseline.rsvp.valid",
+ "input": {
+ "kind": 31925,
+ "content": "Unable to attend.",
+ "tags": [
+ ["d", "declined-rsvp"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:seed-swap"],
+ ["status", "declined"],
+ ["fb", "busy"]
+ ]
+ },
+ "expected": {
+ "d": "declined-rsvp",
+ "event_reference": {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:seed-swap",
+ "relay": null
+ },
+ "revision_reference": null,
+ "status": "declined",
+ "observed_free_busy": "busy",
+ "effective_free_busy": null,
+ "author_hint": null,
+ "note": "Unable to attend."
+ }
+ },
+ {
+ "id": "baseline_rsvp_uppercase_identifiers_and_relays_001",
+ "kind": "calendar.baseline.rsvp.valid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "uppercase-rsvp"],
+ ["a", "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:farm-tour", "WSS://Coordinate-Relay.Example.Test/events"],
+ ["e", "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", "WSS://Event-Relay.Example.Test/events"],
+ ["status", "accepted"],
+ ["fb", "free"],
+ ["p", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "WSS://Author-Relay.Example.Test/events"]
+ ]
+ },
+ "expected": {
+ "d": "uppercase-rsvp",
+ "event_reference": {
+ "coordinate": "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:farm-tour",
+ "relay": "WSS://Coordinate-Relay.Example.Test/events"
+ },
+ "revision_reference": {
+ "id": "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",
+ "relay": "WSS://Event-Relay.Example.Test/events"
+ },
+ "status": "accepted",
+ "observed_free_busy": "free",
+ "effective_free_busy": "free",
+ "author_hint": {
+ "pubkey": "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
+ "relay": "WSS://Author-Relay.Example.Test/events"
+ },
+ "note": null
+ }
+ },
+ {
+ "id": "baseline_rsvp_missing_d_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"]]
+ },
+ "expected": {"error": "missing_tag", "tag": "d"}
+ },
+ {
+ "id": "baseline_rsvp_missing_a_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {"kind": 31925, "content": "", "tags": [["d", "missing-a"], ["status", "accepted"]]},
+ "expected": {"error": "missing_tag", "tag": "a"}
+ },
+ {
+ "id": "baseline_rsvp_missing_status_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [["d", "missing-status"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"]]
+ },
+ "expected": {"error": "missing_tag", "tag": "status"}
+ },
+ {
+ "id": "baseline_rsvp_duplicate_d_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [["d", "one"], ["d", "two"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"]]
+ },
+ "expected": {"error": "duplicate_tag", "tag": "d"}
+ },
+ {
+ "id": "baseline_rsvp_duplicate_a_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "duplicate-a"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"],
+ ["a", "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-shift"],
+ ["status", "accepted"]
+ ]
+ },
+ "expected": {"error": "duplicate_tag", "tag": "a"}
+ },
+ {
+ "id": "baseline_rsvp_duplicate_status_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [["d", "duplicate-status"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"], ["status", "tentative"]]
+ },
+ "expected": {"error": "duplicate_tag", "tag": "status"}
+ },
+ {
+ "id": "baseline_rsvp_duplicate_e_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "duplicate-e"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"],
+ ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"],
+ ["e", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"],
+ ["status", "accepted"]
+ ]
+ },
+ "expected": {"error": "duplicate_tag", "tag": "e"}
+ },
+ {
+ "id": "baseline_rsvp_duplicate_fb_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [["d", "duplicate-fb"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"], ["fb", "free"], ["fb", "busy"]]
+ },
+ "expected": {"error": "duplicate_tag", "tag": "fb"}
+ },
+ {
+ "id": "baseline_rsvp_duplicate_p_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "duplicate-p"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"],
+ ["status", "accepted"],
+ ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"],
+ ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"]
+ ]
+ },
+ "expected": {"error": "duplicate_tag", "tag": "p"}
+ },
+ {
+ "id": "baseline_rsvp_a_extra_element_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "bad-a-arity"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour", "wss://relay.example.test", "unexpected"],
+ ["status", "accepted"]
+ ]
+ },
+ "expected": {"error": "invalid_tag", "tag": "a"}
+ },
+ {
+ "id": "baseline_rsvp_e_extra_element_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "bad-e-arity"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"],
+ ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "wss://relay.example.test", "unexpected"],
+ ["status", "accepted"]
+ ]
+ },
+ "expected": {"error": "invalid_tag", "tag": "e"}
+ },
+ {
+ "id": "baseline_rsvp_p_role_element_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "bad-p-role"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"],
+ ["status", "accepted"],
+ ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://relay.example.test", "host"]
+ ]
+ },
+ "expected": {"error": "invalid_tag", "tag": "p"}
+ },
+ {
+ "id": "baseline_rsvp_wrong_coordinate_kind_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [["d", "wrong-kind"], ["a", "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-calendar"], ["status", "accepted"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "a"}
+ },
+ {
+ "id": "baseline_rsvp_malformed_coordinate_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {"kind": 31925, "content": "", "tags": [["d", "bad-coordinate"], ["a", "31922:bad:farm-tour"], ["status", "accepted"]]},
+ "expected": {"error": "invalid_tag", "tag": "a"}
+ },
+ {
+ "id": "baseline_rsvp_malformed_event_id_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [["d", "bad-event-id"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["e", "not-an-event-id"], ["status", "accepted"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "e"}
+ },
+ {
+ "id": "baseline_rsvp_malformed_author_pubkey_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [["d", "bad-author"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"], ["p", "not-a-pubkey"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "p"}
+ },
+ {
+ "id": "baseline_rsvp_invalid_a_relay_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [["d", "bad-a-relay"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour", "https://relay.example.test"], ["status", "accepted"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "a"}
+ },
+ {
+ "id": "baseline_rsvp_invalid_e_relay_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [["d", "bad-e-relay"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "https://relay.example.test"], ["status", "accepted"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "e"}
+ },
+ {
+ "id": "baseline_rsvp_invalid_p_relay_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [["d", "bad-p-relay"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"], ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "https://relay.example.test"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "p"}
+ },
+ {
+ "id": "baseline_rsvp_invalid_status_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [["d", "bad-status"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "maybe"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "status"}
+ },
+ {
+ "id": "baseline_rsvp_invalid_free_busy_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [["d", "bad-fb"], ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"], ["status", "accepted"], ["fb", "unknown"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "fb"}
+ },
+ {
+ "id": "baseline_collection_duplicate_nip51_description_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "NIP-52 content description.",
+ "tags": [
+ ["d", "duplicate-description"],
+ ["title", "Duplicate description"],
+ ["description", "One"],
+ ["description", "Two"]
+ ]
+ },
+ "expected": {"error": "duplicate_tag", "tag": "description"}
+ },
+ {
+ "id": "baseline_collection_duplicate_image_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [
+ ["d", "duplicate-image"],
+ ["title", "Duplicate image"],
+ ["image", "https://one.example.test/calendar.jpg"],
+ ["image", "https://two.example.test/calendar.jpg"]
+ ]
+ },
+ "expected": {"error": "duplicate_tag", "tag": "image"}
+ },
+ {
+ "id": "baseline_collection_nip51_description_extra_element_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [["d", "bad-description-arity"], ["title", "Bad description arity"], ["description", "Description", "unexpected"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "description"}
+ },
+ {
+ "id": "baseline_collection_image_extra_element_001",
+ "kind": "calendar.baseline.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [["d", "bad-image-arity"], ["title", "Bad image arity"], ["image", "https://example.test/calendar.jpg", "unexpected"]]
+ },
+ "expected": {"error": "invalid_tag", "tag": "image"}
+ },
+ {
+ "id": "baseline_rsvp_status_extra_element_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "bad-status-arity"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"],
+ ["status", "accepted", "unexpected"]
+ ]
+ },
+ "expected": {"error": "invalid_tag", "tag": "status"}
+ },
+ {
+ "id": "baseline_rsvp_fb_extra_element_001",
+ "kind": "calendar.baseline.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "bad-fb-arity"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-tour"],
+ ["status", "accepted"],
+ ["fb", "free", "unexpected"]
+ ]
+ },
+ "expected": {"error": "invalid_tag", "tag": "fb"}
}
]
}
diff --git a/crates/event_codec/tests/fixtures/calendar_radroots_profile.v1.json b/crates/event_codec/tests/fixtures/calendar_radroots_profile.v1.json
@@ -251,6 +251,657 @@
"tags": [["d", "long-range"], ["title", "Long range"], ["start", "0"], ["end", "31708800"], ["D", "0"]]
},
"expected": {"error": "covered_day_limit_exceeded", "max": 366, "actual": 367}
+ },
+ {
+ "id": "profile_authored_collection_empty_001",
+ "kind": "calendar.profile.authored.collection.valid",
+ "input": {
+ "d": "AAAAAAAAAAAAAAAAAAAAAA",
+ "title": "Victoria farm calendar",
+ "event_references": []
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 31924,
+ "content": "",
+ "tags": [["d", "AAAAAAAAAAAAAAAAAAAAAA"], ["title", "Victoria farm calendar"]]
+ }
+ }
+ },
+ {
+ "id": "profile_authored_collection_ordered_mixed_001",
+ "kind": "calendar.profile.authored.collection.valid",
+ "input": {
+ "d": "AAAAAAAAAAAAAAAAAAAAAA",
+ "title": "South Island farm schedule",
+ "content": "Market dates and farm work windows for Victoria growers.",
+ "list_description": "NIP-51 set description for compatible clients.",
+ "event_references": [
+ {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": "wss://date-relay.example.test/events"
+ },
+ {
+ "coordinate": "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:CCCCCCCCCCCCCCCCCCCCCg",
+ "relay": "wss://time-relay.example.test/events"
+ }
+ ]
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 31924,
+ "content": "Market dates and farm work windows for Victoria growers.",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAA"],
+ ["title", "South Island farm schedule"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "wss://date-relay.example.test/events"],
+ ["a", "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:CCCCCCCCCCCCCCCCCCCCCg", "wss://time-relay.example.test/events"],
+ ["description", "NIP-51 set description for compatible clients."]
+ ]
+ }
+ }
+ },
+ {
+ "id": "profile_authored_collection_byte_verified_blossom_image_001",
+ "kind": "calendar.profile.authored.collection.valid",
+ "input": {
+ "d": "AAAAAAAAAAAAAAAAAAAAAA",
+ "title": "Victoria farm calendar",
+ "content": "A shared calendar with a verified cover image.",
+ "image": {
+ "bytes_utf8": "victoria-calendar",
+ "descriptor": {
+ "url": "https://media.example/aba72b48bb536e341ab81c5f86053981c443029766a4374ee86a4f56577ebc84.webp",
+ "sha256": "aba72b48bb536e341ab81c5f86053981c443029766a4374ee86a4f56577ebc84",
+ "size": 17,
+ "type": "image/webp",
+ "uploaded": 1784347200
+ }
+ },
+ "event_references": [
+ {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": null
+ }
+ ]
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 31924,
+ "content": "A shared calendar with a verified cover image.",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAA"],
+ ["title", "Victoria farm calendar"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"],
+ ["image", "https://media.example/aba72b48bb536e341ab81c5f86053981c443029766a4374ee86a4f56577ebc84.webp"]
+ ]
+ },
+ "media_verification": "byte_verified",
+ "upload_completion": "not_attested_by_codec"
+ }
+ },
+ {
+ "id": "profile_authored_collection_human_identifier_001",
+ "kind": "calendar.profile.authored.collection.invalid",
+ "input": {"d": "victoria-calendar", "title": "Victoria farm calendar", "event_references": []},
+ "expected": {"error": "invalid_calendar_uid"}
+ },
+ {
+ "id": "profile_authored_collection_noncanonical_title_001",
+ "kind": "calendar.profile.authored.collection.invalid",
+ "input": {"d": "AAAAAAAAAAAAAAAAAAAAAA", "title": " Victoria farm calendar ", "event_references": []},
+ "expected": {"error": "invalid_title"}
+ },
+ {
+ "id": "profile_authored_collection_duplicate_coordinate_001",
+ "kind": "calendar.profile.authored.collection.invalid",
+ "input": {
+ "d": "AAAAAAAAAAAAAAAAAAAAAA",
+ "title": "Duplicate event calendar",
+ "event_references": [
+ {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": "wss://one.example.test"
+ },
+ {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": "wss://two.example.test"
+ }
+ ]
+ },
+ "expected": {"error": "duplicate_event_reference"}
+ },
+ {
+ "id": "profile_authored_collection_uppercase_relay_001",
+ "kind": "calendar.profile.authored.collection.invalid",
+ "input": {
+ "d": "AAAAAAAAAAAAAAAAAAAAAA",
+ "title": "Uppercase relay",
+ "event_references": [
+ {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": "WSS://Relay.Example.Test/events"
+ }
+ ]
+ },
+ "expected": {"error": "invalid_event_reference"}
+ },
+ {
+ "id": "profile_authored_collection_wrong_coordinate_kind_001",
+ "kind": "calendar.profile.authored.collection.invalid",
+ "input": {
+ "d": "AAAAAAAAAAAAAAAAAAAAAA",
+ "title": "Wrong coordinate kind",
+ "event_references": [
+ {
+ "coordinate": "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": null
+ }
+ ]
+ },
+ "expected": {"error": "invalid_event_reference"}
+ },
+ {
+ "id": "profile_authored_collection_media_hash_mismatch_001",
+ "kind": "calendar.profile.authored.collection.invalid",
+ "input": {
+ "d": "AAAAAAAAAAAAAAAAAAAAAA",
+ "title": "Mismatched media",
+ "image": {
+ "bytes_utf8": "victoria-calendar",
+ "descriptor": {
+ "url": "https://media.example/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.webp",
+ "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "size": 17,
+ "type": "image/webp",
+ "uploaded": 1784347200
+ }
+ },
+ "event_references": []
+ },
+ "expected": {"error": "blob_hash_mismatch"}
+ },
+ {
+ "id": "profile_admit_collection_empty_001",
+ "kind": "calendar.profile.admit.collection.valid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [["d", "AAAAAAAAAAAAAAAAAAAAAA"], ["title", "Victoria farm calendar"]]
+ },
+ "expected": {
+ "d": "AAAAAAAAAAAAAAAAAAAAAA",
+ "event_references": [],
+ "blossom_image": false
+ }
+ },
+ {
+ "id": "profile_admit_collection_ordered_mixed_blossom_001",
+ "kind": "calendar.profile.admit.collection.valid",
+ "input": {
+ "kind": 31924,
+ "content": "Market dates and farm work windows for Victoria growers.",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAA"],
+ ["title", "South Island farm schedule"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "wss://date-relay.example.test/events"],
+ ["a", "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:CCCCCCCCCCCCCCCCCCCCCg", "wss://time-relay.example.test/events"],
+ ["description", "NIP-51 set description for compatible clients."],
+ ["image", "https://media.example/aba72b48bb536e341ab81c5f86053981c443029766a4374ee86a4f56577ebc84.webp"]
+ ]
+ },
+ "expected": {
+ "d": "AAAAAAAAAAAAAAAAAAAAAA",
+ "content": "Market dates and farm work windows for Victoria growers.",
+ "list_description": "NIP-51 set description for compatible clients.",
+ "event_references": [
+ {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": "wss://date-relay.example.test/events"
+ },
+ {
+ "coordinate": "31923:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:CCCCCCCCCCCCCCCCCCCCCg",
+ "relay": "wss://time-relay.example.test/events"
+ }
+ ],
+ "blossom_image": true,
+ "media_verification": "structural_only"
+ }
+ },
+ {
+ "id": "profile_admit_collection_uppercase_coordinate_and_relay_001",
+ "kind": "calendar.profile.admit.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAA"],
+ ["title", "Uppercase reference"],
+ ["a", "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:BBBBBBBBBBBBBBBBBBBBBQ", "WSS://Relay.Example.Test/events"]
+ ]
+ },
+ "expected": {"error": "non_canonical_field", "field": "event_reference"}
+ },
+ {
+ "id": "profile_admit_collection_duplicate_coordinate_001",
+ "kind": "calendar.profile.admit.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAA"],
+ ["title", "Duplicate reference"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "wss://one.example.test"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "wss://two.example.test"]
+ ]
+ },
+ "expected": {"error": "duplicate_event_reference"}
+ },
+ {
+ "id": "profile_admit_collection_human_identifier_001",
+ "kind": "calendar.profile.admit.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [["d", "victoria-calendar"], ["title", "Victoria farm calendar"]]
+ },
+ "expected": {"error": "non_canonical_field", "field": "d"}
+ },
+ {
+ "id": "profile_admit_collection_non_blossom_image_001",
+ "kind": "calendar.profile.admit.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAA"],
+ ["title", "Ordinary image"],
+ ["image", "https://ordinary.example.test/victoria-calendar.jpg"]
+ ]
+ },
+ "expected": {"error": "non_blossom_image"}
+ },
+ {
+ "id": "profile_authored_rsvp_minimal_accepted_001",
+ "kind": "calendar.profile.authored.rsvp.valid",
+ "input": {
+ "d": "DDDDDDDDDDDDDDDDDDDDDw",
+ "event_reference": {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": null
+ },
+ "status": "accepted"
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "DDDDDDDDDDDDDDDDDDDDDw"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"],
+ ["status", "accepted"]
+ ]
+ }
+ }
+ },
+ {
+ "id": "profile_authored_rsvp_complete_independent_hints_001",
+ "kind": "calendar.profile.authored.rsvp.valid",
+ "input": {
+ "d": "DDDDDDDDDDDDDDDDDDDDDw",
+ "event_reference": {
+ "coordinate": "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": "wss://coordinate-relay.example.test/events"
+ },
+ "revision_reference": {
+ "id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ "relay": "wss://event-relay.example.test/events"
+ },
+ "status": "tentative",
+ "free_busy": "busy",
+ "author_hint": {
+ "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relay": "wss://author-relay.example.test/events"
+ },
+ "note": "I can help with the wash-pack shift."
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 31925,
+ "content": "I can help with the wash-pack shift.",
+ "tags": [
+ ["d", "DDDDDDDDDDDDDDDDDDDDDw"],
+ ["a", "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "wss://coordinate-relay.example.test/events"],
+ ["status", "tentative"],
+ ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "wss://event-relay.example.test/events"],
+ ["fb", "busy"],
+ ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://author-relay.example.test/events"]
+ ]
+ },
+ "observed_free_busy": "busy",
+ "effective_free_busy": "busy"
+ }
+ },
+ {
+ "id": "profile_authored_rsvp_declined_with_free_busy_001",
+ "kind": "calendar.profile.authored.rsvp.invalid",
+ "input": {
+ "d": "DDDDDDDDDDDDDDDDDDDDDw",
+ "event_reference": {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": null
+ },
+ "status": "declined",
+ "free_busy": "busy"
+ },
+ "expected": {"error": "declined_free_busy_forbidden"}
+ },
+ {
+ "id": "profile_authored_rsvp_human_identifier_001",
+ "kind": "calendar.profile.authored.rsvp.invalid",
+ "input": {
+ "d": "farm-tour-rsvp",
+ "event_reference": {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": null
+ },
+ "status": "accepted"
+ },
+ "expected": {"error": "invalid_calendar_uid"}
+ },
+ {
+ "id": "profile_authored_rsvp_uppercase_event_id_001",
+ "kind": "calendar.profile.authored.rsvp.invalid",
+ "input": {
+ "d": "DDDDDDDDDDDDDDDDDDDDDw",
+ "event_reference": {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": null
+ },
+ "revision_reference": {
+ "id": "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",
+ "relay": null
+ },
+ "status": "accepted"
+ },
+ "expected": {"error": "invalid_revision_reference"}
+ },
+ {
+ "id": "profile_authored_rsvp_author_mismatch_001",
+ "kind": "calendar.profile.authored.rsvp.invalid",
+ "input": {
+ "d": "DDDDDDDDDDDDDDDDDDDDDw",
+ "event_reference": {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": null
+ },
+ "status": "accepted",
+ "author_hint": {
+ "pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ "relay": null
+ }
+ },
+ "expected": {"error": "author_hint_mismatch"}
+ },
+ {
+ "id": "profile_authored_rsvp_uppercase_event_relay_001",
+ "kind": "calendar.profile.authored.rsvp.invalid",
+ "input": {
+ "d": "DDDDDDDDDDDDDDDDDDDDDw",
+ "event_reference": {
+ "coordinate": "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": "WSS://Relay.Example.Test/events"
+ },
+ "status": "accepted"
+ },
+ "expected": {"error": "invalid_event_reference"}
+ },
+ {
+ "id": "profile_authored_rsvp_wrong_coordinate_kind_001",
+ "kind": "calendar.profile.authored.rsvp.invalid",
+ "input": {
+ "d": "DDDDDDDDDDDDDDDDDDDDDw",
+ "event_reference": {
+ "coordinate": "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": null
+ },
+ "status": "accepted"
+ },
+ "expected": {"error": "invalid_event_reference"}
+ },
+ {
+ "id": "profile_admit_rsvp_minimal_accepted_001",
+ "kind": "calendar.profile.admit.rsvp.valid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "DDDDDDDDDDDDDDDDDDDDDw"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"],
+ ["status", "accepted"]
+ ]
+ },
+ "expected": {
+ "d": "DDDDDDDDDDDDDDDDDDDDDw",
+ "status": "accepted",
+ "observed_free_busy": null,
+ "effective_free_busy": null
+ }
+ },
+ {
+ "id": "profile_admit_rsvp_complete_independent_hints_001",
+ "kind": "calendar.profile.admit.rsvp.valid",
+ "input": {
+ "kind": 31925,
+ "content": "I can help with the wash-pack shift.",
+ "tags": [
+ ["d", "DDDDDDDDDDDDDDDDDDDDDw"],
+ ["a", "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "wss://coordinate-relay.example.test/events"],
+ ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "wss://event-relay.example.test/events"],
+ ["status", "tentative"],
+ ["fb", "busy"],
+ ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://author-relay.example.test/events"]
+ ]
+ },
+ "expected": {
+ "d": "DDDDDDDDDDDDDDDDDDDDDw",
+ "event_reference": {
+ "coordinate": "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ",
+ "relay": "wss://coordinate-relay.example.test/events"
+ },
+ "revision_reference": {
+ "id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ "relay": "wss://event-relay.example.test/events"
+ },
+ "author_hint": {
+ "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ "relay": "wss://author-relay.example.test/events"
+ },
+ "status": "tentative",
+ "observed_free_busy": "busy",
+ "effective_free_busy": "busy"
+ }
+ },
+ {
+ "id": "profile_admit_rsvp_declined_observed_free_busy_001",
+ "kind": "calendar.profile.admit.rsvp.valid",
+ "input": {
+ "kind": 31925,
+ "content": "Unable to attend.",
+ "tags": [
+ ["d", "DDDDDDDDDDDDDDDDDDDDDw"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"],
+ ["status", "declined"],
+ ["fb", "busy"]
+ ]
+ },
+ "expected": {
+ "d": "DDDDDDDDDDDDDDDDDDDDDw",
+ "status": "declined",
+ "observed_free_busy": "busy",
+ "effective_free_busy": null
+ }
+ },
+ {
+ "id": "profile_admit_rsvp_uppercase_fields_001",
+ "kind": "calendar.profile.admit.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "DDDDDDDDDDDDDDDDDDDDDw"],
+ ["a", "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:BBBBBBBBBBBBBBBBBBBBBQ", "WSS://Coordinate-Relay.Example.Test/events"],
+ ["e", "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", "WSS://Event-Relay.Example.Test/events"],
+ ["status", "accepted"],
+ ["p", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "WSS://Author-Relay.Example.Test/events"]
+ ]
+ },
+ "expected": {"error": "non_canonical_field"}
+ },
+ {
+ "id": "profile_admit_rsvp_author_mismatch_001",
+ "kind": "calendar.profile.admit.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "DDDDDDDDDDDDDDDDDDDDDw"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"],
+ ["status", "accepted"],
+ ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"]
+ ]
+ },
+ "expected": {"error": "author_hint_mismatch"}
+ },
+ {
+ "id": "profile_admit_rsvp_human_identifier_001",
+ "kind": "calendar.profile.admit.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "farm-tour-rsvp"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"],
+ ["status", "accepted"]
+ ]
+ },
+ "expected": {"error": "non_canonical_field", "field": "d"}
+ },
+ {
+ "id": "profile_admit_collection_uppercase_coordinate_001",
+ "kind": "calendar.profile.admit.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAA"],
+ ["title", "Uppercase coordinate"],
+ ["a", "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:BBBBBBBBBBBBBBBBBBBBBQ"]
+ ]
+ },
+ "expected": {"error": "non_canonical_field", "field": "event_reference"}
+ },
+ {
+ "id": "profile_admit_collection_uppercase_relay_001",
+ "kind": "calendar.profile.admit.collection.invalid",
+ "input": {
+ "kind": 31924,
+ "content": "",
+ "tags": [
+ ["d", "AAAAAAAAAAAAAAAAAAAAAA"],
+ ["title", "Uppercase relay"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "WSS://Relay.Example.Test/events"]
+ ]
+ },
+ "expected": {"error": "non_canonical_field", "field": "event_reference"}
+ },
+ {
+ "id": "profile_admit_rsvp_uppercase_coordinate_001",
+ "kind": "calendar.profile.admit.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "DDDDDDDDDDDDDDDDDDDDDw"],
+ ["a", "31922:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:BBBBBBBBBBBBBBBBBBBBBQ"],
+ ["status", "accepted"]
+ ]
+ },
+ "expected": {"error": "non_canonical_field", "field": "event_reference"}
+ },
+ {
+ "id": "profile_admit_rsvp_uppercase_event_id_001",
+ "kind": "calendar.profile.admit.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "DDDDDDDDDDDDDDDDDDDDDw"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"],
+ ["e", "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"],
+ ["status", "accepted"]
+ ]
+ },
+ "expected": {"error": "non_canonical_field", "field": "revision_reference"}
+ },
+ {
+ "id": "profile_admit_rsvp_uppercase_author_pubkey_001",
+ "kind": "calendar.profile.admit.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "DDDDDDDDDDDDDDDDDDDDDw"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"],
+ ["status", "accepted"],
+ ["p", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"]
+ ]
+ },
+ "expected": {"error": "non_canonical_field", "field": "author_hint"}
+ },
+ {
+ "id": "profile_admit_rsvp_uppercase_a_relay_001",
+ "kind": "calendar.profile.admit.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "DDDDDDDDDDDDDDDDDDDDDw"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ", "WSS://Coordinate-Relay.Example.Test/events"],
+ ["status", "accepted"]
+ ]
+ },
+ "expected": {"error": "non_canonical_field", "field": "event_reference"}
+ },
+ {
+ "id": "profile_admit_rsvp_uppercase_e_relay_001",
+ "kind": "calendar.profile.admit.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "DDDDDDDDDDDDDDDDDDDDDw"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"],
+ ["e", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "WSS://Event-Relay.Example.Test/events"],
+ ["status", "accepted"]
+ ]
+ },
+ "expected": {"error": "non_canonical_field", "field": "revision_reference"}
+ },
+ {
+ "id": "profile_admit_rsvp_uppercase_p_relay_001",
+ "kind": "calendar.profile.admit.rsvp.invalid",
+ "input": {
+ "kind": 31925,
+ "content": "",
+ "tags": [
+ ["d", "DDDDDDDDDDDDDDDDDDDDDw"],
+ ["a", "31922:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:BBBBBBBBBBBBBBBBBBBBBQ"],
+ ["status", "accepted"],
+ ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "WSS://Author-Relay.Example.Test/events"]
+ ]
+ },
+ "expected": {"error": "non_canonical_field", "field": "author_hint"}
}
]
}
diff --git a/crates/event_codec/tests/list.rs b/crates/event_codec/tests/list.rs
@@ -2,7 +2,9 @@ mod common;
use common::{AUTHOR, EVENT_ID, EVENT_SIG};
use radroots_event::{
- kinds::{KIND_LIST_MUTE, KIND_LIST_READ_WRITE_RELAYS, KIND_LIST_SET_FOLLOW, KIND_POST},
+ kinds::{
+ KIND_CALENDAR, KIND_LIST_MUTE, KIND_LIST_READ_WRITE_RELAYS, KIND_LIST_SET_FOLLOW, KIND_POST,
+ },
list::{RadrootsList, RadrootsListEntry},
tags::TAG_R,
};
@@ -95,13 +97,33 @@ fn list_encode_and_decode_reject_invalid_inputs() {
assert!(matches!(
err,
EventParseError::InvalidKind {
- expected: "nip51 standard or list-set kind",
+ expected: "generic nip51 standard or non-calendar list-set kind",
got: KIND_POST
}
));
}
#[test]
+fn generic_list_encode_and_decode_reject_calendar_kind() {
+ let list = sample_list();
+ assert!(matches!(
+ to_wire_parts_with_kind(&list, KIND_CALENDAR),
+ Err(EventEncodeError::InvalidKind(KIND_CALENDAR))
+ ));
+ assert!(matches!(
+ list_from_tags(
+ KIND_CALENDAR,
+ list.content,
+ &[vec!["d".to_string(), "AAAAAAAAAAAAAAAAAAAAAA".to_string()]],
+ ),
+ Err(EventParseError::InvalidKind {
+ expected: "generic nip51 standard or non-calendar list-set kind",
+ got: KIND_CALENDAR,
+ })
+ ));
+}
+
+#[test]
fn list_set_kind_roundtrips_generic_entries() {
let list = sample_list();
let parts = to_wire_parts_with_kind(&list, KIND_LIST_SET_FOLLOW).unwrap();
@@ -182,7 +204,7 @@ fn list_index_from_event_propagates_parse_errors() {
assert!(matches!(
err,
EventParseError::InvalidKind {
- expected: "nip51 standard or list-set kind",
+ expected: "generic nip51 standard or non-calendar list-set kind",
got: KIND_POST
}
));
diff --git a/crates/event_codec/tests/list_set.rs b/crates/event_codec/tests/list_set.rs
@@ -4,7 +4,7 @@ mod test_fixtures;
use common::{AUTHOR, EVENT_ID, EVENT_SIG};
use radroots_event::{
- kinds::{KIND_LIST_SET_FOLLOW, KIND_POST},
+ kinds::{KIND_CALENDAR, KIND_LIST_SET_FOLLOW, KIND_POST, is_nip51_list_set_kind},
list::RadrootsListEntry,
list_set::RadrootsListSet,
};
@@ -57,6 +57,31 @@ fn list_set_build_tags_and_decode_roundtrip() {
}
#[test]
+fn generic_list_set_decode_rejects_calendar_kind() {
+ assert!(is_nip51_list_set_kind(KIND_CALENDAR));
+ let tags = list_set_build_tags(&sample_list_set()).unwrap();
+
+ let err = list_set_from_tags(KIND_CALENDAR, "private".to_string(), &tags).unwrap_err();
+
+ assert!(matches!(
+ err,
+ EventParseError::InvalidKind {
+ expected: "generic nip51 list-set kind",
+ got: KIND_CALENDAR
+ }
+ ));
+}
+
+#[test]
+fn generic_list_set_encode_rejects_calendar_kind() {
+ assert!(is_nip51_list_set_kind(KIND_CALENDAR));
+
+ let err = to_wire_parts_with_kind(&sample_list_set(), KIND_CALENDAR).unwrap_err();
+
+ assert!(matches!(err, EventEncodeError::InvalidKind(KIND_CALENDAR)));
+}
+
+#[test]
fn list_set_encode_and_decode_reject_invalid_inputs() {
let invalid = RadrootsListSet {
d_tag: " ".to_string(),
@@ -89,7 +114,7 @@ fn list_set_encode_and_decode_reject_invalid_inputs() {
assert!(matches!(
err,
EventParseError::InvalidKind {
- expected: "nip51 list set kind",
+ expected: "generic nip51 list-set kind",
got: KIND_POST
}
));
@@ -213,7 +238,7 @@ fn list_set_index_from_event_propagates_parse_errors() {
assert!(matches!(
err,
EventParseError::InvalidKind {
- expected: "nip51 list set kind",
+ expected: "generic nip51 list-set kind",
got: KIND_POST
}
));
diff --git a/crates/event_store/src/model.rs b/crates/event_store/src/model.rs
@@ -461,6 +461,9 @@ pub struct RadrootsTradeProjectionCheckpoint {
pub fn tag_semantic_name(value: RadrootsTagSemantic) -> &'static str {
match value {
RadrootsTagSemantic::AddressableCoordinate => "addressable_coordinate",
+ RadrootsTagSemantic::CalendarEventAuthor => "calendar_event_author",
+ RadrootsTagSemantic::CalendarEventReference => "calendar_event_reference",
+ RadrootsTagSemantic::CalendarEventRevision => "calendar_event_revision",
RadrootsTagSemantic::CalendarInclusionRequest => "calendar_inclusion_request",
RadrootsTagSemantic::CalendarEnd => "calendar_end",
RadrootsTagSemantic::CalendarStart => "calendar_start",
@@ -470,6 +473,7 @@ pub fn tag_semantic_name(value: RadrootsTagSemantic) -> &'static str {
RadrootsTagSemantic::Counterparty => "counterparty",
RadrootsTagSemantic::Evidence => "evidence",
RadrootsTagSemantic::EventPointer => "event_pointer",
+ RadrootsTagSemantic::FreeBusy => "free_busy",
RadrootsTagSemantic::Geohash => "geohash",
RadrootsTagSemantic::GroupId => "group_id",
RadrootsTagSemantic::Identifier => "identifier",
@@ -477,6 +481,7 @@ pub fn tag_semantic_name(value: RadrootsTagSemantic) -> &'static str {
RadrootsTagSemantic::Kind => "kind",
RadrootsTagSemantic::ListingAddress => "listing_address",
RadrootsTagSemantic::ListingSnapshot => "listing_snapshot",
+ RadrootsTagSemantic::ListDescription => "list_description",
RadrootsTagSemantic::Location => "location",
RadrootsTagSemantic::Participant => "participant",
RadrootsTagSemantic::PreviousEvent => "previous_event",
@@ -503,6 +508,10 @@ pub fn tag_value_type_name(value: RadrootsTagValueType) -> &'static str {
match value {
RadrootsTagValueType::AddressableCoordinate => "addressable_coordinate",
RadrootsTagValueType::CalendarDate => "calendar_date",
+ RadrootsTagValueType::CalendarEventCoordinate => "calendar_event_coordinate",
+ RadrootsTagValueType::CalendarFreeBusy => "calendar_free_busy",
+ RadrootsTagValueType::CalendarRsvpStatus => "calendar_rsvp_status",
+ RadrootsTagValueType::CalendarUid => "calendar_uid",
RadrootsTagValueType::ContractId => "contract_id",
RadrootsTagValueType::DTag => "d_tag",
RadrootsTagValueType::EventId => "event_id",
@@ -703,6 +712,18 @@ mod tests {
"addressable_coordinate",
),
(
+ RadrootsTagSemantic::CalendarEventAuthor,
+ "calendar_event_author",
+ ),
+ (
+ RadrootsTagSemantic::CalendarEventReference,
+ "calendar_event_reference",
+ ),
+ (
+ RadrootsTagSemantic::CalendarEventRevision,
+ "calendar_event_revision",
+ ),
+ (
RadrootsTagSemantic::CalendarInclusionRequest,
"calendar_inclusion_request",
),
@@ -714,6 +735,7 @@ mod tests {
(RadrootsTagSemantic::Counterparty, "counterparty"),
(RadrootsTagSemantic::Evidence, "evidence"),
(RadrootsTagSemantic::EventPointer, "event_pointer"),
+ (RadrootsTagSemantic::FreeBusy, "free_busy"),
(RadrootsTagSemantic::Geohash, "geohash"),
(RadrootsTagSemantic::GroupId, "group_id"),
(RadrootsTagSemantic::Identifier, "identifier"),
@@ -721,6 +743,7 @@ mod tests {
(RadrootsTagSemantic::Kind, "kind"),
(RadrootsTagSemantic::ListingAddress, "listing_address"),
(RadrootsTagSemantic::ListingSnapshot, "listing_snapshot"),
+ (RadrootsTagSemantic::ListDescription, "list_description"),
(RadrootsTagSemantic::Location, "location"),
(RadrootsTagSemantic::Participant, "participant"),
(RadrootsTagSemantic::PreviousEvent, "previous_event"),
@@ -750,6 +773,16 @@ mod tests {
"addressable_coordinate",
),
(RadrootsTagValueType::CalendarDate, "calendar_date"),
+ (
+ RadrootsTagValueType::CalendarEventCoordinate,
+ "calendar_event_coordinate",
+ ),
+ (RadrootsTagValueType::CalendarFreeBusy, "calendar_free_busy"),
+ (
+ RadrootsTagValueType::CalendarRsvpStatus,
+ "calendar_rsvp_status",
+ ),
+ (RadrootsTagValueType::CalendarUid, "calendar_uid"),
(RadrootsTagValueType::ContractId, "contract_id"),
(RadrootsTagValueType::DTag, "d_tag"),
(RadrootsTagValueType::EventId, "event_id"),
diff --git a/crates/replica_sync/src/ingest.rs b/crates/replica_sync/src/ingest.rs
@@ -22,7 +22,7 @@ use radroots_event::event_head::{
};
use radroots_event::ids::RadrootsEventId;
use radroots_event::kinds::{
- KIND_FARM, KIND_LISTING, KIND_PLOT, KIND_PROFILE, is_nip51_list_set_kind,
+ KIND_CALENDAR, KIND_FARM, KIND_LISTING, KIND_PLOT, KIND_PROFILE, is_nip51_list_set_kind,
};
use radroots_event::listing::{
RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, RadrootsListingStatus,
@@ -207,7 +207,9 @@ fn ingest_event_inner(
KIND_FARM => ingest_farm_event(exec, event, factory),
KIND_PLOT => ingest_plot_event(exec, event, factory),
KIND_LISTING => ingest_listing_event(exec, event),
- kind if is_nip51_list_set_kind(kind) => ingest_list_set_event(exec, event),
+ kind if is_nip51_list_set_kind(kind) && kind != KIND_CALENDAR => {
+ ingest_list_set_event(exec, event)
+ }
_ => Err(RadrootsReplicaEventsError::InvalidData(format!(
"unsupported kind {}",
event.kind_u32()
@@ -2256,6 +2258,35 @@ mod tests {
}
#[test]
+ fn calendar_kind_uses_unsupported_transaction_path() {
+ let rollback_count = Arc::new(AtomicUsize::new(0));
+ let executor = TxnExecutor {
+ inner: None,
+ begin_err: None,
+ commit_err: None,
+ rollback_count: rollback_count.clone(),
+ };
+ let event = test_event_envelope(
+ 3,
+ &"a".repeat(64),
+ 3,
+ KIND_CALENDAR,
+ Vec::new(),
+ String::new(),
+ );
+
+ let err = radroots_replica_ingest_event_with_factory(&executor, &event, &FixedFactory)
+ .expect_err("calendar is not supported by the replica projection");
+
+ assert!(matches!(
+ err,
+ RadrootsReplicaEventsError::InvalidData(ref message)
+ if message == "unsupported kind 31924"
+ ));
+ assert_eq!(rollback_count.load(Ordering::SeqCst), 1);
+ }
+
+ #[test]
fn ingest_core_paths_cover_helpers_and_decisions() {
let exec = SqlxSqliteExecutor::open_memory().expect("db");
migrations::run_all_up(&exec).expect("migrations");
diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs
@@ -46,7 +46,7 @@ const DTO_TOOLING_DEPENDENCIES: [&str; 4] = [
"dto_bindgen_core",
"dto_bindgen_macros",
];
-const RETIRED_OPERATION_EVENT_NAMES: [&str; 10] = [
+const RETIRED_OPERATION_EVENT_NAMES: [&str; 13] = [
"WireEventParts",
"RadrootsFrozenEventDraft",
"RadrootsNostrEvent",
@@ -57,14 +57,19 @@ const RETIRED_OPERATION_EVENT_NAMES: [&str; 10] = [
"RadrootsCalendarDateValue",
"RadrootsInboundCalendarDateEvent",
"RadrootsInboundCalendarTimeEvent",
+ "RadrootsCalendar",
+ "RadrootsCalendarEventRsvp",
+ "RadrootsCalendarRsvp",
];
-const REQUIRED_CALENDAR_PUBLIC_TYPES: [&str; 18] = [
+const REQUIRED_CALENDAR_PUBLIC_TYPES: [&str; 34] = [
"RadrootsNip01EventWireParts",
+ "RadrootsBlossomBlobUrl",
"RadrootsAuthoredImage",
"RadrootsAuthoredImageError",
"RadrootsIanaTimeZoneId",
"RadrootsCalendarUri",
"RadrootsCalendarRequest",
+ "RadrootsCalendarParticipant",
"RadrootsCalendarEventError",
"RadrootsCalendarDate",
"RadrootsAuthoredCalendarDateEvent",
@@ -77,8 +82,22 @@ const REQUIRED_CALENDAR_PUBLIC_TYPES: [&str; 18] = [
"RadrootsCalendarAdmissionError",
"RadrootsAdmittedCalendarDateEvent",
"RadrootsAdmittedCalendarTimeEvent",
+ "RadrootsCalendarUid",
+ "RadrootsCalendarEventReference",
+ "RadrootsCalendarEventRevisionReference",
+ "RadrootsCalendarEventAuthorReference",
+ "RadrootsAuthoredCalendar",
+ "RadrootsParsedNip52CalendarParts",
+ "RadrootsParsedNip52Calendar",
+ "RadrootsAdmittedCalendar",
+ "RadrootsCalendarEventRsvpStatus",
+ "RadrootsCalendarEventFreeBusy",
+ "RadrootsAuthoredCalendarEventRsvp",
+ "RadrootsParsedNip52CalendarEventRsvpParts",
+ "RadrootsParsedNip52CalendarEventRsvp",
+ "RadrootsAdmittedCalendarEventRsvp",
];
-const CALENDAR_OPERATION_EXPECTATIONS: [CalendarOperationExpectation; 6] = [
+const CALENDAR_OPERATION_EXPECTATIONS: [CalendarOperationExpectation; 12] = [
CalendarOperationExpectation {
key: "social_calendar_date_event_build_authored_draft",
id: "social.calendar_date_event.build_authored_draft",
@@ -198,6 +217,138 @@ const CALENDAR_OPERATION_EXPECTATIONS: [CalendarOperationExpectation; 6] = [
],
vector: "contracts/conformance/vectors/calendar/radroots_profile.v1.json",
},
+ CalendarOperationExpectation {
+ key: "social_calendar_build_authored_draft",
+ id: "social.calendar.build_authored_draft",
+ inputs: &["RadrootsAuthoredCalendar"],
+ outputs: &["RadrootsNip01EventWireParts"],
+ error_class: "encode_error",
+ rust_modules: &[
+ "crates/event/src/calendar.rs",
+ "crates/event/src/media.rs",
+ "crates/event_codec/src/calendar/encode.rs",
+ ],
+ rust_types: &[
+ "radroots_event::calendar::RadrootsAuthoredCalendar",
+ "radroots_event::calendar::RadrootsCalendarEventError",
+ "radroots_event::calendar::RadrootsCalendarEventReference",
+ "radroots_event::calendar::RadrootsCalendarUid",
+ "radroots_event::media::RadrootsAuthoredImage",
+ "radroots_event::wire::RadrootsNip01EventWireParts",
+ "radroots_event_codec::error::EventEncodeError",
+ ],
+ vector: "contracts/conformance/vectors/calendar/radroots_profile.v1.json",
+ },
+ CalendarOperationExpectation {
+ key: "social_calendar_parse_nip52",
+ id: "social.calendar.parse_nip52",
+ inputs: &["u32", "NostrTags", "String"],
+ outputs: &["RadrootsParsedNip52Calendar"],
+ error_class: "parse_error",
+ rust_modules: &[
+ "crates/event/src/calendar.rs",
+ "crates/event_codec/src/calendar/decode.rs",
+ ],
+ rust_types: &[
+ "radroots_event::calendar::RadrootsCalendarEventError",
+ "radroots_event::calendar::RadrootsCalendarEventReference",
+ "radroots_event::calendar::RadrootsCalendarUri",
+ "radroots_event::calendar::RadrootsParsedNip52Calendar",
+ "radroots_event::calendar::RadrootsParsedNip52CalendarParts",
+ "radroots_event_codec::error::EventParseError",
+ ],
+ vector: "contracts/conformance/vectors/calendar/nip52_baseline.v1.json",
+ },
+ CalendarOperationExpectation {
+ key: "social_calendar_admit_radroots_profile",
+ id: "social.calendar.admit_radroots_profile",
+ inputs: &["RadrootsParsedNip52Calendar"],
+ outputs: &["RadrootsAdmittedCalendar"],
+ error_class: "admission_error",
+ rust_modules: &[
+ "crates/event/src/calendar.rs",
+ "crates/event_codec/src/calendar/decode.rs",
+ ],
+ rust_types: &[
+ "radroots_blossom::RadrootsBlossomBlobUrl",
+ "radroots_event::calendar::RadrootsAdmittedCalendar",
+ "radroots_event::calendar::RadrootsCalendarAdmissionError",
+ "radroots_event::calendar::RadrootsCalendarEventReference",
+ "radroots_event::calendar::RadrootsCalendarUid",
+ "radroots_event::calendar::RadrootsParsedNip52Calendar",
+ ],
+ vector: "contracts/conformance/vectors/calendar/radroots_profile.v1.json",
+ },
+ CalendarOperationExpectation {
+ key: "social_calendar_rsvp_build_authored_draft",
+ id: "social.calendar_rsvp.build_authored_draft",
+ inputs: &["RadrootsAuthoredCalendarEventRsvp"],
+ outputs: &["RadrootsNip01EventWireParts"],
+ error_class: "encode_error",
+ rust_modules: &[
+ "crates/event/src/calendar.rs",
+ "crates/event_codec/src/calendar/encode.rs",
+ ],
+ rust_types: &[
+ "radroots_event::calendar::RadrootsAuthoredCalendarEventRsvp",
+ "radroots_event::calendar::RadrootsCalendarEventAuthorReference",
+ "radroots_event::calendar::RadrootsCalendarEventError",
+ "radroots_event::calendar::RadrootsCalendarEventReference",
+ "radroots_event::calendar::RadrootsCalendarEventRevisionReference",
+ "radroots_event::calendar::RadrootsCalendarUid",
+ "radroots_event::calendar::RadrootsCalendarEventFreeBusy",
+ "radroots_event::calendar::RadrootsCalendarEventRsvpStatus",
+ "radroots_event::wire::RadrootsNip01EventWireParts",
+ "radroots_event_codec::error::EventEncodeError",
+ ],
+ vector: "contracts/conformance/vectors/calendar/radroots_profile.v1.json",
+ },
+ CalendarOperationExpectation {
+ key: "social_calendar_rsvp_parse_nip52",
+ id: "social.calendar_rsvp.parse_nip52",
+ inputs: &["u32", "NostrTags", "String"],
+ outputs: &["RadrootsParsedNip52CalendarEventRsvp"],
+ error_class: "parse_error",
+ rust_modules: &[
+ "crates/event/src/calendar.rs",
+ "crates/event_codec/src/calendar/decode.rs",
+ ],
+ rust_types: &[
+ "radroots_event::calendar::RadrootsCalendarEventAuthorReference",
+ "radroots_event::calendar::RadrootsCalendarEventError",
+ "radroots_event::calendar::RadrootsCalendarEventReference",
+ "radroots_event::calendar::RadrootsCalendarEventRevisionReference",
+ "radroots_event::calendar::RadrootsParsedNip52CalendarEventRsvp",
+ "radroots_event::calendar::RadrootsParsedNip52CalendarEventRsvpParts",
+ "radroots_event::calendar::RadrootsCalendarEventFreeBusy",
+ "radroots_event::calendar::RadrootsCalendarEventRsvpStatus",
+ "radroots_event_codec::error::EventParseError",
+ ],
+ vector: "contracts/conformance/vectors/calendar/nip52_baseline.v1.json",
+ },
+ CalendarOperationExpectation {
+ key: "social_calendar_rsvp_admit_radroots_profile",
+ id: "social.calendar_rsvp.admit_radroots_profile",
+ inputs: &["RadrootsParsedNip52CalendarEventRsvp"],
+ outputs: &["RadrootsAdmittedCalendarEventRsvp"],
+ error_class: "admission_error",
+ rust_modules: &[
+ "crates/event/src/calendar.rs",
+ "crates/event_codec/src/calendar/decode.rs",
+ ],
+ rust_types: &[
+ "radroots_event::calendar::RadrootsAdmittedCalendarEventRsvp",
+ "radroots_event::calendar::RadrootsCalendarAdmissionError",
+ "radroots_event::calendar::RadrootsCalendarEventAuthorReference",
+ "radroots_event::calendar::RadrootsCalendarEventReference",
+ "radroots_event::calendar::RadrootsCalendarEventRevisionReference",
+ "radroots_event::calendar::RadrootsCalendarUid",
+ "radroots_event::calendar::RadrootsParsedNip52CalendarEventRsvp",
+ "radroots_event::calendar::RadrootsCalendarEventFreeBusy",
+ "radroots_event::calendar::RadrootsCalendarEventRsvpStatus",
+ ],
+ vector: "contracts/conformance/vectors/calendar/radroots_profile.v1.json",
+ },
];
const EVENT_BOUNDARY_MATRIX_RELATIVES: [&str; 2] = [
"contracts/event_boundary_matrix.md",
@@ -769,10 +920,25 @@ const CALENDAR_TIME_WITNESSES: [EventBoundarySourceWitness; 4] = [
},
];
-const CALENDAR_WITNESSES: [EventBoundarySourceWitness; 2] = [
+const CALENDAR_WITNESSES: [EventBoundarySourceWitness; 4] = [
EventBoundarySourceWitness {
relative_path: "crates/event/src/calendar.rs",
- required_fragments: &["pub struct RadrootsCalendar"],
+ required_fragments: &[
+ "pub struct RadrootsAuthoredCalendar {",
+ "pub struct RadrootsParsedNip52Calendar {",
+ "pub struct RadrootsAdmittedCalendar {",
+ ],
+ },
+ EventBoundarySourceWitness {
+ relative_path: "crates/event_codec/src/calendar/encode.rs",
+ required_fragments: &["pub fn calendar_to_wire_parts("],
+ },
+ EventBoundarySourceWitness {
+ relative_path: "crates/event_codec/src/calendar/decode.rs",
+ required_fragments: &[
+ "pub fn parse_nip52_calendar(",
+ "pub fn admit_radroots_calendar(",
+ ],
},
EventBoundarySourceWitness {
relative_path: "crates/event/src/kinds.rs",
@@ -780,10 +946,25 @@ const CALENDAR_WITNESSES: [EventBoundarySourceWitness; 2] = [
},
];
-const CALENDAR_RSVP_WITNESSES: [EventBoundarySourceWitness; 2] = [
+const CALENDAR_RSVP_WITNESSES: [EventBoundarySourceWitness; 4] = [
EventBoundarySourceWitness {
relative_path: "crates/event/src/calendar.rs",
- required_fragments: &["pub struct RadrootsCalendarEventRsvp"],
+ required_fragments: &[
+ "pub struct RadrootsAuthoredCalendarEventRsvp {",
+ "pub struct RadrootsParsedNip52CalendarEventRsvp {",
+ "pub struct RadrootsAdmittedCalendarEventRsvp {",
+ ],
+ },
+ EventBoundarySourceWitness {
+ relative_path: "crates/event_codec/src/calendar/encode.rs",
+ required_fragments: &["pub fn rsvp_to_wire_parts("],
+ },
+ EventBoundarySourceWitness {
+ relative_path: "crates/event_codec/src/calendar/decode.rs",
+ required_fragments: &[
+ "pub fn parse_nip52_calendar_event_rsvp(",
+ "pub fn admit_radroots_calendar_event_rsvp(",
+ ],
},
EventBoundarySourceWitness {
relative_path: "crates/event/src/kinds.rs",
@@ -1192,7 +1373,7 @@ const CANONICAL_EVENT_BOUNDARY_EXPECTATIONS: [EventBoundaryExpectation; 41] = [
},
EventBoundaryExpectation {
domain: "list_set",
- kind: "30000..39092",
+ kind: "30000..30007, 30015, 30030, 30063, 30267, 39089, 39092",
radroots_type: "RadrootsListSet",
rpc_methods: &[
"events.list_set.publish",
@@ -1270,7 +1451,7 @@ const CANONICAL_EVENT_BOUNDARY_EXPECTATIONS: [EventBoundaryExpectation; 41] = [
EventBoundaryExpectation {
domain: "calendar",
kind: "31924",
- radroots_type: "RadrootsCalendar",
+ radroots_type: "RadrootsAuthoredCalendar / RadrootsParsedNip52Calendar / RadrootsAdmittedCalendar",
rpc_methods: &[
"events.calendar.publish",
"events.calendar.list",
@@ -1281,7 +1462,7 @@ const CANONICAL_EVENT_BOUNDARY_EXPECTATIONS: [EventBoundaryExpectation; 41] = [
EventBoundaryExpectation {
domain: "calendar_rsvp",
kind: "31925",
- radroots_type: "RadrootsCalendarEventRsvp",
+ radroots_type: "RadrootsAuthoredCalendarEventRsvp / RadrootsParsedNip52CalendarEventRsvp / RadrootsAdmittedCalendarEventRsvp",
rpc_methods: &[
"events.calendar_rsvp.publish",
"events.calendar_rsvp.list",
@@ -3109,10 +3290,7 @@ fn validate_calendar_operation_authority(
.operations
.iter()
.filter(|(key, operation)| {
- key.starts_with("social_calendar_date_event_")
- || key.starts_with("social_calendar_time_event_")
- || operation.id.starts_with("social.calendar_date_event.")
- || operation.id.starts_with("social.calendar_time_event.")
+ key.starts_with("social_calendar_") || operation.id.starts_with("social.calendar")
})
.map(|(key, _)| key.clone())
.collect::<BTreeSet<_>>();
@@ -4914,6 +5092,31 @@ crates = ["radroots_a", "radroots_b", "radroots_c", "radroots_d", "radroots_e"]
}
#[test]
+ fn calendar_operation_authority_reports_obsolete_rsvp_operation_drift() {
+ let root = workspace_root();
+ let mut bundle = load_contract_bundle(&root).expect("load contract");
+ let manifest = bundle
+ .operations_manifest
+ .as_mut()
+ .expect("operations manifest");
+ let shared_types =
+ collect_non_empty_set(&manifest.shared_types.public, "shared_types.public")
+ .expect("shared public types");
+ let obsolete = manifest
+ .operations
+ .remove("social_calendar_rsvp_build_authored_draft")
+ .expect("calendar RSVP authored operation");
+ manifest
+ .operations
+ .insert("social_calendar_rsvp_build_tags".to_string(), obsolete);
+
+ let error = validate_calendar_operation_authority(manifest, &shared_types)
+ .expect_err("obsolete calendar RSVP operation drift");
+ assert!(error.contains("calendar operation authority drift"));
+ assert!(error.contains("social_calendar_rsvp_build_tags"));
+ }
+
+ #[test]
fn validate_current_canonical_event_boundary() {
let root = workspace_root();
validate_canonical_event_boundary(&root).expect("validate canonical event boundary");
@@ -6377,6 +6580,42 @@ rust_package = "radroots_sdk"
},
);
assert_bundle_error(
+ "shared_types.public uses retired event type RadrootsCalendar",
+ |bundle| {
+ bundle
+ .operations_manifest
+ .as_mut()
+ .expect("operations manifest")
+ .shared_types
+ .public
+ .push("RadrootsCalendar".to_string());
+ },
+ );
+ assert_bundle_error(
+ "shared_types.public uses retired event type RadrootsCalendarEventRsvp",
+ |bundle| {
+ bundle
+ .operations_manifest
+ .as_mut()
+ .expect("operations manifest")
+ .shared_types
+ .public
+ .push("RadrootsCalendarEventRsvp".to_string());
+ },
+ );
+ assert_bundle_error(
+ "shared_types.public uses retired event type RadrootsCalendarRsvp",
+ |bundle| {
+ bundle
+ .operations_manifest
+ .as_mut()
+ .expect("operations manifest")
+ .shared_types
+ .public
+ .push("RadrootsCalendarRsvp".to_string());
+ },
+ );
+ assert_bundle_error(
"operation profile.build_draft inputs uses retired event type RadrootsNostrEvent",
|bundle| {
bundle