commit bc545e69fba28b4dee35ad6749768b3862482c67
parent 8329770143ddd719d67f4902026de6b691f6da37
Author: triesap <tyson@radroots.org>
Date: Sat, 18 Jul 2026 08:23:36 +0000
calendar: correct NIP-52 date and time boundaries
- separate tolerant baseline parsing from strict Radroots admission
- derive bounded UTC-day coverage and validate pinned IANA time zones
- require byte-verified Blossom images for strict authored events
- add executable baseline/profile vectors and contract drift checks
Diffstat:
27 files changed, 6577 insertions(+), 1338 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -2711,6 +2711,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
+name = "jiff-tzdb"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
+
+[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4309,12 +4315,14 @@ version = "0.1.0-alpha.2"
dependencies = [
"dto_bindgen",
"hex",
+ "jiff-tzdb",
"radroots_blossom",
"radroots_core",
"secp256k1",
"serde",
"serde_json",
"sha2",
+ "url",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
@@ -140,6 +140,7 @@ flate2 = { version = "1" }
getrandom = { version = "0.2", default-features = false }
hkdf = { version = "0.12", default-features = false }
hex = { version = "0.4" }
+jiff-tzdb = { version = "=0.1.8", default-features = false }
js-sys = { version = "0.3" }
mediatype = { version = "0.21", default-features = false }
keyring = { version = "3.6.3", default-features = false, features = [
diff --git a/contracts/conformance/vectors/calendar/nip52_baseline.v1.json b/contracts/conformance/vectors/calendar/nip52_baseline.v1.json
@@ -0,0 +1,333 @@
+{
+ "suite": "calendar_nip52_baseline",
+ "contract_version": "0.1.0",
+ "vectors": [
+ {
+ "id": "baseline_date_minimal_001",
+ "kind": "calendar.baseline.date.valid",
+ "input": {
+ "kind": 31922,
+ "content": "",
+ "tags": [
+ ["d", "moss-street-market"],
+ ["title", "Moss Street Market"],
+ ["start", "2026-06-20"]
+ ]
+ },
+ "expected": {
+ "d": "moss-street-market",
+ "title": "Moss Street Market",
+ "start": "2026-06-20",
+ "locations": [],
+ "participants": 0,
+ "categories": [],
+ "references": [],
+ "calendar_requests": 0,
+ "date_day_extensions": []
+ }
+ },
+ {
+ "id": "baseline_date_complete_nip52_metadata_001",
+ "kind": "calendar.baseline.date.valid",
+ "input": {
+ "kind": 31922,
+ "content": "Bring reusable bags.",
+ "tags": [
+ ["d", "fernwood-csa-pickup"],
+ ["title", " CSA pickup "],
+ ["start", "2026-06-20"],
+ ["end", "2026-06-22"],
+ ["location", "Farm stand"],
+ ["location", "Moss Street Market"],
+ ["g", "C23NB62W20ST"],
+ ["summary", " Weekly pickup "],
+ ["image", "https://ordinary.example.test/images/market.jpg?size=large"],
+ ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"],
+ ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "wss://relay.example.test/events"],
+ ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "", " host "],
+ ["t", "vegetables"],
+ ["t", "CSA"],
+ ["r", "ipfs://bafybeigdyrzt/reference"],
+ ["a", "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-calendar", "wss://relay.example.test"],
+ ["name", "Deprecated display name"],
+ ["D", "extension", "preserved"]
+ ]
+ },
+ "expected": {
+ "d": "fernwood-csa-pickup",
+ "title": " CSA pickup ",
+ "description": "Bring reusable bags.",
+ "start": "2026-06-20",
+ "end": "2026-06-22",
+ "locations": ["Farm stand", "Moss Street Market"],
+ "geohash": "C23NB62W20ST",
+ "summary": " Weekly pickup ",
+ "image": "https://ordinary.example.test/images/market.jpg?size=large",
+ "participants": [
+ {"pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "relay": null, "role": null},
+ {"pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "relay": "wss://relay.example.test/events", "role": null},
+ {"pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "relay": null, "role": " host "}
+ ],
+ "categories": ["vegetables", "CSA"],
+ "references": ["ipfs://bafybeigdyrzt/reference"],
+ "calendar_requests": [
+ {"calendar": "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-calendar", "relay": "wss://relay.example.test"}
+ ],
+ "legacy_name": "Deprecated display name",
+ "date_day_extensions": [["D", "extension", "preserved"]]
+ }
+ },
+ {
+ "id": "baseline_date_exclusive_end_001",
+ "kind": "calendar.baseline.date.valid",
+ "input": {
+ "kind": 31922,
+ "content": "Two all-day sale dates.",
+ "tags": [
+ ["d", "seedling-sale"],
+ ["title", "Victoria Seedling Sale"],
+ ["start", "2024-02-29"],
+ ["end", "2024-03-02"]
+ ]
+ },
+ "expected": {
+ "d": "seedling-sale",
+ "title": "Victoria Seedling Sale",
+ "description": "Two all-day sale dates.",
+ "start": "2024-02-29",
+ "end": "2024-03-02",
+ "locations": [],
+ "participants": 0,
+ "categories": [],
+ "references": [],
+ "calendar_requests": 0,
+ "date_day_extensions": []
+ }
+ },
+ {
+ "id": "baseline_date_impossible_start_001",
+ "kind": "calendar.baseline.date.invalid",
+ "input": {
+ "kind": 31922,
+ "content": "",
+ "tags": [["d", "bad-date"], ["title", "Bad date"], ["start", "2026-02-30"]]
+ },
+ "expected": { "error": "invalid_tag", "tag": "start" }
+ },
+ {
+ "id": "baseline_date_equal_end_001",
+ "kind": "calendar.baseline.date.invalid",
+ "input": {
+ "kind": 31922,
+ "content": "",
+ "tags": [["d", "equal-date"], ["title", "Equal date"], ["start", "2026-06-20"], ["end", "2026-06-20"]]
+ },
+ "expected": { "error": "invalid_tag", "tag": "end" }
+ },
+ {
+ "id": "baseline_date_duplicate_title_001",
+ "kind": "calendar.baseline.date.invalid",
+ "input": {
+ "kind": 31922,
+ "content": "",
+ "tags": [["d", "duplicate"], ["title", "First"], ["title", "Second"], ["start", "2026-06-20"]]
+ },
+ "expected": { "error": "duplicate_tag", "tag": "title" }
+ },
+ {
+ "id": "baseline_date_relative_image_001",
+ "kind": "calendar.baseline.date.invalid",
+ "input": {
+ "kind": 31922,
+ "content": "",
+ "tags": [["d", "relative-image"], ["title", "Relative image"], ["start", "2026-06-20"], ["image", "/market.jpg"]]
+ },
+ "expected": { "error": "invalid_tag", "tag": "image" }
+ },
+ {
+ "id": "baseline_date_participant_empty_relay_without_role_001",
+ "kind": "calendar.baseline.date.invalid",
+ "input": {
+ "kind": 31922,
+ "content": "",
+ "tags": [["d", "bad-participant"], ["title", "Bad participant"], ["start", "2026-06-20"], ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", ""]]
+ },
+ "expected": { "error": "invalid_tag", "tag": "p" }
+ },
+ {
+ "id": "baseline_date_calendar_request_wrong_kind_001",
+ "kind": "calendar.baseline.date.invalid",
+ "input": {
+ "kind": 31922,
+ "content": "",
+ "tags": [["d", "bad-request"], ["title", "Bad request"], ["start", "2026-06-20"], ["a", "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:not-a-calendar"]]
+ },
+ "expected": { "error": "invalid_tag", "tag": "a" }
+ },
+ {
+ "id": "baseline_time_should_level_day_variance_001",
+ "kind": "calendar.baseline.time.valid",
+ "input": {
+ "kind": 31923,
+ "content": "Harvest across UTC day boundaries.",
+ "tags": [
+ ["d", "harvest-window"],
+ ["title", "Harvest window"],
+ ["start", "086399"],
+ ["end", "172801"],
+ ["D", "000"],
+ ["D", "2"],
+ ["start_tzid", "America/Vancouver"],
+ ["t", "harvest"]
+ ]
+ },
+ "expected": {
+ "d": "harvest-window",
+ "title": "Harvest window",
+ "description": "Harvest across UTC day boundaries.",
+ "start_wire": "086399",
+ "start": 86399,
+ "end_wire": "172801",
+ "end": 172801,
+ "observed_days": [
+ {"wire": "000", "index": 0, "canonical": false},
+ {"wire": "2", "index": 2, "canonical": true}
+ ],
+ "start_tzid": "America/Vancouver",
+ "effective_end_tzid": "America/Vancouver",
+ "locations": [],
+ "participants": 0,
+ "categories": ["harvest"],
+ "references": [],
+ "calendar_requests": 0
+ }
+ },
+ {
+ "id": "baseline_time_single_start_day_001",
+ "kind": "calendar.baseline.time.valid",
+ "input": {
+ "kind": 31923,
+ "content": "",
+ "tags": [["d", "instant"], ["title", "Instant"], ["start", "86400"], ["D", "1"]]
+ },
+ "expected": {
+ "d": "instant",
+ "title": "Instant",
+ "start_wire": "86400",
+ "start": 86400,
+ "observed_days": [{"wire": "1", "index": 1, "canonical": true}],
+ "locations": [],
+ "participants": 0,
+ "categories": [],
+ "references": [],
+ "calendar_requests": 0
+ }
+ },
+ {
+ "id": "baseline_time_over_366_days_001",
+ "kind": "calendar.baseline.time.valid",
+ "input": {
+ "kind": 31923,
+ "content": "",
+ "tags": [["d", "long-range"], ["title", "Long range"], ["start", "0"], ["end", "31708800"], ["D", "0"]]
+ },
+ "expected": {
+ "d": "long-range",
+ "title": "Long range",
+ "start_wire": "0",
+ "start": 0,
+ "end_wire": "31708800",
+ "end": 31708800,
+ "observed_days": [{"wire": "0", "index": 0, "canonical": true}],
+ "locations": [],
+ "participants": 0,
+ "categories": [],
+ "references": [],
+ "calendar_requests": 0
+ }
+ },
+ {
+ "id": "baseline_time_non_start_in_range_day_001",
+ "kind": "calendar.baseline.time.valid",
+ "input": {
+ "kind": 31923,
+ "content": "",
+ "tags": [["d", "non-start-day"], ["title", "Non-start in-range day"], ["start", "86399"], ["end", "172801"], ["D", "1"]]
+ },
+ "expected": {
+ "d": "non-start-day",
+ "title": "Non-start in-range day",
+ "start_wire": "86399",
+ "start": 86399,
+ "end_wire": "172801",
+ "end": 172801,
+ "observed_days": [{"wire": "1", "index": 1, "canonical": true}],
+ "locations": [],
+ "participants": 0,
+ "categories": [],
+ "references": [],
+ "calendar_requests": 0
+ }
+ },
+ {
+ "id": "baseline_time_invalid_day_decimal_001",
+ "kind": "calendar.baseline.time.invalid",
+ "input": {
+ "kind": 31923,
+ "content": "",
+ "tags": [["d", "bad-day"], ["title", "Bad day"], ["start", "0"], ["D", "+0"]]
+ },
+ "expected": { "error": "invalid_tag", "tag": "D" }
+ },
+ {
+ "id": "baseline_time_missing_day_001",
+ "kind": "calendar.baseline.time.invalid",
+ "input": {
+ "kind": 31923,
+ "content": "",
+ "tags": [["d", "missing-day"], ["title", "Missing day"], ["start", "0"]]
+ },
+ "expected": { "error": "missing_tag", "tag": "D" }
+ },
+ {
+ "id": "baseline_time_out_of_range_day_001",
+ "kind": "calendar.baseline.time.invalid",
+ "input": {
+ "kind": 31923,
+ "content": "",
+ "tags": [["d", "out-of-range-day"], ["title", "Out-of-range day"], ["start", "0"], ["D", "1"]]
+ },
+ "expected": { "error": "invalid_tag", "tag": "D" }
+ },
+ {
+ "id": "baseline_time_invalid_iana_timezone_001",
+ "kind": "calendar.baseline.time.invalid",
+ "input": {
+ "kind": 31923,
+ "content": "",
+ "tags": [["d", "bad-zone"], ["title", "Bad zone"], ["start", "0"], ["D", "0"], ["start_tzid", "america/vancouver"]]
+ },
+ "expected": { "error": "invalid_tag", "tag": "start_tzid" }
+ },
+ {
+ "id": "baseline_date_duplicate_legacy_name_001",
+ "kind": "calendar.baseline.date.invalid",
+ "input": {
+ "kind": 31922,
+ "content": "",
+ "tags": [["d", "duplicate-name"], ["title", "Canonical title"], ["name", "Legacy one"], ["name", "Legacy two"], ["start", "2026-06-20"]]
+ },
+ "expected": { "error": "duplicate_tag", "tag": "name" }
+ },
+ {
+ "id": "baseline_wrong_event_kind_001",
+ "kind": "calendar.baseline.date.invalid",
+ "input": {
+ "kind": 1,
+ "content": "",
+ "tags": [["d", "wrong-kind"], ["title", "Wrong kind"], ["start", "2026-06-20"]]
+ },
+ "expected": { "error": "invalid_kind" }
+ }
+ ]
+}
diff --git a/contracts/conformance/vectors/calendar/radroots_profile.v1.json b/contracts/conformance/vectors/calendar/radroots_profile.v1.json
@@ -0,0 +1,256 @@
+{
+ "suite": "calendar_radroots_profile",
+ "contract_version": "0.1.0",
+ "vectors": [
+ {
+ "id": "profile_date_parse_minimum_gregorian_001",
+ "kind": "calendar.profile.date.parse.valid",
+ "input": { "value": "0001-01-01" },
+ "expected": { "value": "0001-01-01" }
+ },
+ {
+ "id": "profile_date_parse_leap_day_001",
+ "kind": "calendar.profile.date.parse.valid",
+ "input": { "value": "2000-02-29" },
+ "expected": { "value": "2000-02-29" }
+ },
+ {
+ "id": "profile_date_parse_year_zero_001",
+ "kind": "calendar.profile.date.parse.invalid",
+ "input": { "value": "0000-01-01" },
+ "expected": { "error": "invalid_date" }
+ },
+ {
+ "id": "profile_date_parse_noncanonical_width_001",
+ "kind": "calendar.profile.date.parse.invalid",
+ "input": { "value": "2026-6-20" },
+ "expected": { "error": "invalid_date" }
+ },
+ {
+ "id": "profile_authored_date_complete_001",
+ "kind": "calendar.profile.authored.date.valid",
+ "input": {
+ "d": "fernwood-csa-pickup",
+ "title": "CSA pickup",
+ "start": "2026-06-20",
+ "end": "2026-06-21",
+ "description": "Bring clean bins to the farm stand.",
+ "locations": ["Farm stand", "Moss Street Market"],
+ "geohash": "c23nb62w20st",
+ "summary": "Weekly pickup",
+ "image": {
+ "bytes_utf8": "victoria-calendar",
+ "descriptor": {
+ "url": "https://media.example/aba72b48bb536e341ab81c5f86053981c443029766a4374ee86a4f56577ebc84.webp",
+ "sha256": "aba72b48bb536e341ab81c5f86053981c443029766a4374ee86a4f56577ebc84",
+ "size": 17,
+ "type": "image/webp",
+ "uploaded": 1784347200
+ }
+ },
+ "participants": [
+ {"pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "relay": "wss://relay.example.test", "role": "host"}
+ ],
+ "categories": ["csa", "vegetables"],
+ "references": ["https://example.test/pickup-details"],
+ "calendar_requests": [
+ {"calendar": "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-calendar", "relay": "wss://relay.example.test"}
+ ]
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 31922,
+ "content": "Bring clean bins to the farm stand.",
+ "tags": [
+ ["d", "fernwood-csa-pickup"],
+ ["title", "CSA pickup"],
+ ["start", "2026-06-20"],
+ ["end", "2026-06-21"],
+ ["location", "Farm stand"],
+ ["location", "Moss Street Market"],
+ ["g", "c23nb62w20st"],
+ ["summary", "Weekly pickup"],
+ ["image", "https://media.example/aba72b48bb536e341ab81c5f86053981c443029766a4374ee86a4f56577ebc84.webp"],
+ ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://relay.example.test", "host"],
+ ["t", "csa"],
+ ["t", "vegetables"],
+ ["r", "https://example.test/pickup-details"],
+ ["a", "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-calendar", "wss://relay.example.test"]
+ ]
+ },
+ "media_verification": "byte_verified",
+ "upload_completion": "not_attested_by_codec"
+ }
+ },
+ {
+ "id": "profile_authored_date_equal_end_001",
+ "kind": "calendar.profile.authored.date.invalid",
+ "input": {"d": "equal-date", "title": "Equal date", "start": "2026-06-20", "end": "2026-06-20"},
+ "expected": { "error": "invalid_range" }
+ },
+ {
+ "id": "profile_authored_date_noncanonical_title_001",
+ "kind": "calendar.profile.authored.date.invalid",
+ "input": {"d": "bad-title", "title": " Bad title ", "start": "2026-06-20"},
+ "expected": { "error": "invalid_title" }
+ },
+ {
+ "id": "profile_authored_date_uppercase_geohash_001",
+ "kind": "calendar.profile.authored.date.invalid",
+ "input": {"d": "bad-geohash", "title": "Bad geohash", "start": "2026-06-20", "geohash": "C23NB62W20ST"},
+ "expected": { "error": "invalid_geohash" }
+ },
+ {
+ "id": "profile_authored_time_canonical_001",
+ "kind": "calendar.profile.authored.time.valid",
+ "input": {
+ "d": "wash-pack-shift",
+ "title": "Wash pack shift",
+ "start": 86399,
+ "end": 172801,
+ "start_tzid": "America/Vancouver",
+ "locations": ["Pack shed"],
+ "geohash": "c23nb62w20st",
+ "categories": ["farm-work"]
+ },
+ "expected": {
+ "effective_end_tzid": "America/Vancouver",
+ "wire_parts": {
+ "kind": 31923,
+ "content": "",
+ "tags": [
+ ["d", "wash-pack-shift"],
+ ["title", "Wash pack shift"],
+ ["start", "86399"],
+ ["end", "172801"],
+ ["D", "0"],
+ ["D", "1"],
+ ["D", "2"],
+ ["start_tzid", "America/Vancouver"],
+ ["location", "Pack shed"],
+ ["g", "c23nb62w20st"],
+ ["t", "farm-work"]
+ ]
+ }
+ }
+ },
+ {
+ "id": "profile_authored_time_maximum_coverage_001",
+ "kind": "calendar.profile.authored.time.coverage.valid",
+ "input": {"d": "annual-farm-plan", "title": "Annual farm plan", "start": 0, "end": 31622400},
+ "expected": {"count": 366, "first": "0", "last": "365"}
+ },
+ {
+ "id": "profile_authored_time_over_coverage_limit_001",
+ "kind": "calendar.profile.authored.time.invalid",
+ "input": {"d": "overlong-plan", "title": "Overlong plan", "start": 0, "end": 31708800},
+ "expected": {"error": "covered_day_limit_exceeded", "max": 366, "actual": 367}
+ },
+ {
+ "id": "profile_authored_time_invalid_iana_timezone_001",
+ "kind": "calendar.profile.authored.time.invalid",
+ "input": {"d": "bad-zone", "title": "Bad zone", "start": 0, "start_tzid": "america/vancouver"},
+ "expected": {"error": "invalid_time_zone"}
+ },
+ {
+ "id": "profile_admit_date_canonical_001",
+ "kind": "calendar.profile.admit.date.valid",
+ "input": {
+ "kind": 31922,
+ "content": "",
+ "tags": [["d", "farm-tour"], ["title", "Urban farm tour"], ["start", "2026-07-18"], ["image", "https://media.example/aba72b48bb536e341ab81c5f86053981c443029766a4374ee86a4f56577ebc84.webp"]]
+ },
+ "expected": {"d": "farm-tour", "blossom_image": true}
+ },
+ {
+ "id": "profile_admit_date_forbidden_day_extension_001",
+ "kind": "calendar.profile.admit.date.invalid",
+ "input": {
+ "kind": 31922,
+ "content": "",
+ "tags": [["d", "farm-tour"], ["title", "Urban farm tour"], ["start", "2026-07-18"], ["D", "20620"]]
+ },
+ "expected": {"error": "forbidden_date_day_index"}
+ },
+ {
+ "id": "profile_admit_date_noncanonical_metadata_001",
+ "kind": "calendar.profile.admit.date.invalid",
+ "input": {
+ "kind": 31922,
+ "content": "",
+ "tags": [["d", "farm-tour"], ["title", " Urban farm tour "], ["start", "2026-07-18"]]
+ },
+ "expected": {"error": "non_canonical_field"}
+ },
+ {
+ "id": "profile_admit_date_uppercase_relay_hint_001",
+ "kind": "calendar.profile.admit.date.invalid",
+ "input": {
+ "kind": 31922,
+ "content": "",
+ "tags": [["d", "farm-tour"], ["title", "Urban farm tour"], ["start", "2026-07-18"], ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "WSS://relay.example.test"]]
+ },
+ "expected": {"error": "non_canonical_field"}
+ },
+ {
+ "id": "profile_admit_date_non_blossom_image_001",
+ "kind": "calendar.profile.admit.date.invalid",
+ "input": {
+ "kind": 31922,
+ "content": "",
+ "tags": [["d", "farm-tour"], ["title", "Urban farm tour"], ["start", "2026-07-18"], ["image", "https://ordinary.example.test/farm-tour.jpg"]]
+ },
+ "expected": {"error": "non_blossom_image"}
+ },
+ {
+ "id": "profile_admit_time_canonical_001",
+ "kind": "calendar.profile.admit.time.valid",
+ "input": {
+ "kind": 31923,
+ "content": "",
+ "tags": [["d", "harvest-window"], ["title", "Harvest window"], ["start", "86399"], ["end", "172801"], ["D", "0"], ["D", "1"], ["D", "2"]]
+ },
+ "expected": {"d": "harvest-window", "covered_days": [0, 1, 2], "blossom_image": false}
+ },
+ {
+ "id": "profile_admit_time_incomplete_coverage_001",
+ "kind": "calendar.profile.admit.time.invalid",
+ "input": {
+ "kind": 31923,
+ "content": "",
+ "tags": [["d", "harvest-window"], ["title", "Harvest window"], ["start", "86399"], ["end", "172801"], ["D", "0"], ["D", "2"]]
+ },
+ "expected": {"error": "incomplete_day_coverage"}
+ },
+ {
+ "id": "profile_admit_time_noncanonical_timestamp_001",
+ "kind": "calendar.profile.admit.time.invalid",
+ "input": {
+ "kind": 31923,
+ "content": "",
+ "tags": [["d", "harvest-window"], ["title", "Harvest window"], ["start", "086399"], ["end", "172801"], ["D", "0"], ["D", "1"], ["D", "2"]]
+ },
+ "expected": {"error": "non_canonical_field"}
+ },
+ {
+ "id": "profile_admit_time_noncanonical_day_001",
+ "kind": "calendar.profile.admit.time.invalid",
+ "input": {
+ "kind": 31923,
+ "content": "",
+ "tags": [["d", "harvest-window"], ["title", "Harvest window"], ["start", "86399"], ["end", "172801"], ["D", "00"], ["D", "1"], ["D", "2"]]
+ },
+ "expected": {"error": "incomplete_day_coverage"}
+ },
+ {
+ "id": "profile_admit_time_over_coverage_limit_001",
+ "kind": "calendar.profile.admit.time.invalid",
+ "input": {
+ "kind": 31923,
+ "content": "",
+ "tags": [["d", "long-range"], ["title", "Long range"], ["start", "0"], ["end", "31708800"], ["D", "0"]]
+ },
+ "expected": {"error": "covered_day_limit_exceeded", "max": 366, "actual": 367}
+ }
+ ]
+}
diff --git a/contracts/conformance/vectors/social/mvp.v1.json b/contracts/conformance/vectors/social/mvp.v1.json
@@ -328,120 +328,6 @@
"error_class": "encode_error",
"field": "sha256"
}
- },
- {
- "id": "social_calendar_date_tags_valid_014",
- "kind": "social.calendar_date_event.build_tags.valid",
- "input": {
- "event": {
- "d_tag": "AAAAAAAAAAAAAAAAAAAAAw",
- "title": "market day",
- "start": "2026-06-20",
- "description": "Farm stand pickup window.",
- "end": "2026-06-21",
- "days": [
- {
- "value": "2026-06-20"
- }
- ]
- }
- },
- "expected": {
- "result": "ok",
- "required_tags": [
- "d",
- "title",
- "start",
- "end",
- "D"
- ],
- "content": "Farm stand pickup window."
- }
- },
- {
- "id": "social_calendar_date_bad_date_invalid_015",
- "kind": "social.calendar_date_event.build_tags.invalid",
- "input": {
- "event": {
- "d_tag": "AAAAAAAAAAAAAAAAAAAAAw",
- "title": "market day",
- "start": "June 20"
- }
- },
- "expected": {
- "result": "error",
- "error_class": "encode_error",
- "field": "start"
- }
- },
- {
- "id": "social_calendar_time_tags_valid_016",
- "kind": "social.calendar_time_event.build_tags.valid",
- "input": {
- "event": {
- "d_tag": "AAAAAAAAAAAAAAAAAAAA-A",
- "title": "wash pack shift",
- "start": 1781895600,
- "dates": [
- {
- "value": "2026-06-20"
- }
- ],
- "description": "Prepare CSA bins before pickup.",
- "end": 1781899200,
- "start_tzid": "America/Vancouver"
- }
- },
- "expected": {
- "result": "ok",
- "required_tags": [
- "d",
- "title",
- "start",
- "D",
- "end",
- "start_tzid"
- ],
- "content": "Prepare CSA bins before pickup."
- }
- },
- {
- "id": "social_calendar_time_end_before_start_invalid_017",
- "kind": "social.calendar_time_event.build_tags.invalid",
- "input": {
- "event": {
- "d_tag": "AAAAAAAAAAAAAAAAAAAA-A",
- "title": "wash pack shift",
- "start": 1781899200,
- "dates": [
- {
- "value": "2026-06-20"
- }
- ],
- "end": 1781895600
- }
- },
- "expected": {
- "result": "error",
- "error_class": "encode_error",
- "field": "end"
- }
- },
- {
- "id": "social_calendar_time_missing_date_invalid_018",
- "kind": "social.calendar_time_event.build_tags.invalid",
- "input": {
- "event": {
- "d_tag": "AAAAAAAAAAAAAAAAAAAA-A",
- "title": "wash pack shift",
- "start": 1781895600
- }
- },
- "expected": {
- "result": "error",
- "error_class": "encode_error",
- "field": "dates"
- }
}
]
}
diff --git a/contracts/event_boundary_matrix.md b/contracts/event_boundary_matrix.md
@@ -20,6 +20,15 @@ contract package.
- `domains.<domain>.<subdomain>.<action>`
- standard actions: `publish`, `get`, `list`, `validate`, `encode`, `decode`
+## 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.
+
## Coverage matrix
| Domain | Kind | Radroots Type | RPC Methods | Notes |
@@ -44,8 +53,8 @@ contract package.
| 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 | RadrootsCalendarDateEvent | events.calendar_date.publish, events.calendar_date.list, events.calendar_date.get | NIP-52 all-day calendar event |
-| calendar_time | 31923 | RadrootsCalendarTimeEvent | events.calendar_time.publish, events.calendar_time.list, events.calendar_time.get | NIP-52 timestamped calendar event |
+| 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 |
| farm | 30340 | RadrootsFarm | events.farm.publish, events.farm.list, events.farm.get | addressable; canonical JSON; `g` tag only when a geohash exists |
diff --git a/contracts/events/social-events.md b/contracts/events/social-events.md
@@ -15,6 +15,13 @@ The target implementation is standards-first and Radroots-named. Event models li
`radroots_event`, canonical encode/decode behavior lives in `radroots_event_codec`, and
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
+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.
+
## Implementation Inventory
The repository implements public social support for kind `1` `RadrootsPost`, kind `1111`
@@ -40,8 +47,12 @@ The MVP public social substrate includes:
- `RadrootsPost` for ordinary NIP-01 kind `1` notes plus optional Radroots social metadata
- `RadrootsArticle` for NIP-23 kind `30023` long-form content
- generic public `RadrootsFileMetadata` for NIP-94 kind `1063`
-- `RadrootsCalendarDateEvent` for NIP-52 kind `31922`
-- `RadrootsCalendarTimeEvent` for NIP-52 kind `31923`
+- strict authored `RadrootsAuthoredCalendarDateEvent`, tolerant
+ `RadrootsParsedNip52CalendarDateEvent`, and strict admitted
+ `RadrootsAdmittedCalendarDateEvent` models for NIP-52 kind `31922`
+- strict authored `RadrootsAuthoredCalendarTimeEvent`, tolerant
+ `RadrootsParsedNip52CalendarTimeEvent`, and strict admitted
+ `RadrootsAdmittedCalendarTimeEvent` models for NIP-52 kind `31923`
The production-v1 public social substrate includes:
@@ -77,11 +88,100 @@ though both use kind `1063`. The public generic model must cover the current sim
including URL, MIME type, SHA-256 hash, original hash, size, dimensions, blurhash, thumbnail, image,
summary, alt text, fallback, `magnet`, `i`, and `service`.
-`RadrootsCalendarDateEvent`, `RadrootsCalendarTimeEvent`, and `RadrootsCalendar` use NIP-52
-description content. Optional `description` data is encoded as event content and empty content
-decodes to no description. Calendar date events use lowercase `d` for the replaceable identifier and
-optional uppercase `D` tags for covered all-day dates. Calendar time events require at least one
-uppercase `D` tag so timestamped events retain a deterministic calendar-date anchor across codecs.
+### Calendar Trust Layers
+
+Kinds `31922` and `31923` 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 |
+
+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.
+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.
+
+### Baseline NIP-52 Parse
+
+Kinds `31922` and `31923` use plain-text description content. Empty content projects to no typed
+description. The `d`, `title`, and `start` tags are required singletons, and `end` is an optional
+singleton with an exclusive boundary. The tolerant common projection covers the pinned standard
+fields:
+
+- optional `summary`, `image`, and `g`
+- repeated `location` values without collapsing them to one value
+- repeated participant `p` tags containing a 32-byte hexadecimal public key, optional recommended
+ relay URL, and optional role
+- repeated category `t` tags and absolute-URI reference `r` tags
+- repeated calendar-inclusion request `a` tags whose coordinates identify kind `31924`, with an
+ optional recommended relay URL
+- the deprecated singleton `name` as observed compatibility data; it never replaces the required
+ `title`
+
+Other tags remain available only through the complete structural envelope. Baseline parsing does
+not silently promote unknown fields into the typed calendar contract.
+
+Kind `31922` uses semantic Gregorian `YYYY-MM-DD` values for inclusive `start` and optional
+exclusive `end`. Year zero, impossible dates, non-canonical date spellings, and an `end` that is not
+later than `start` are invalid. When `end` is absent, the event ends on the same date as `start`.
+Uppercase `D` is not defined for date-based events by the pinned NIP-52. The tolerant parser retains
+every observed uppercase-`D` tag as uninterpreted extension data so standards-compatible inspection
+remains lossless at that boundary; strict Radroots admission rejects any such date-event extension.
+
+Kind `31923` uses unsigned decimal Unix seconds for inclusive `start` and optional exclusive `end`.
+When `end` is absent, the event is instantaneous. At least one uppercase-`D` day index on which the
+event takes place is required, and the tolerant parser rejects indices outside the event interval.
+In line with NIP-52's `SHOULD` for multiple coverage tags, baseline parsing does not require either
+the start-day index or complete coverage. It also preserves duplicate, unordered, or leading-zero
+numeric `D` observations for
+diagnostics; those spellings are parser tolerance, not canonical authored output. Baseline parsing
+does not apply the Radroots 366-day admission limit. Optional `start_tzid` and `end_tzid` values must
+be exact identifiers in the bundled IANA Time Zone Database (`jiff-tzdb` `0.1.8`, TZDB `2026c`).
+When `end_tzid` is absent and `start_tzid` is present, the effective end time zone is `start_tzid`.
+
+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.
+
+### 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,
+optional geohash and summary, repeated participants, categories, absolute-URI references, and
+kind-`31924` calendar-inclusion requests. It intentionally does not author deprecated `name` tags.
+
+Strict kind-`31922` events never emit or admit uppercase `D`. Strict kind-`31923` events require
+canonical decimal timestamps and the exact, ascending, duplicate-free sequence of every UTC-day
+index covered by the interval, where `D = floor(unix_seconds / 86400)` and `end` is exclusive.
+Authoring derives this sequence rather than accepting it from callers. Strict authored and admitted
+time events cover at most 366 UTC days.
+
+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
+safety. Strict authored models accept only `RadrootsAuthoredImage`, which wraps an approved,
+byte-verified Blossom descriptor declared as `image/*`. That typestate proves local
+descriptor-to-byte agreement only.
+
+A publication runtime must not sign or publish a media-bearing calendar draft until the exact blob
+has completed a successful BUD-02 upload and the runtime's bounded retrievability check has
+succeeded. Consumers remain responsible for bounded retrieval, redirects, content decoding, and
+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`
diff --git a/contracts/operations.toml b/contracts/operations.toml
@@ -47,6 +47,8 @@ public = [
"RadrootsEventRef",
"RadrootsEventPtr",
"RadrootsListingAddress",
+ "RadrootsAuthoredImage",
+ "RadrootsAuthoredImageError",
"RadrootsProfile",
"RadrootsNip05Identifier",
"RadrootsNip05IdentifierError",
@@ -66,8 +68,21 @@ public = [
"RadrootsReaction",
"RadrootsArticle",
"RadrootsFileMetadata",
- "RadrootsCalendarDateEvent",
- "RadrootsCalendarTimeEvent",
+ "RadrootsIanaTimeZoneId",
+ "RadrootsCalendarUri",
+ "RadrootsCalendarRequest",
+ "RadrootsCalendarEventError",
+ "RadrootsCalendarDate",
+ "RadrootsAuthoredCalendarDateEvent",
+ "RadrootsAuthoredCalendarTimeEvent",
+ "RadrootsParsedNip52CalendarCommon",
+ "RadrootsParsedNip52CalendarCommonParts",
+ "RadrootsParsedNip52CalendarDateEvent",
+ "RadrootsObservedUtcDay",
+ "RadrootsParsedNip52CalendarTimeEvent",
+ "RadrootsCalendarAdmissionError",
+ "RadrootsAdmittedCalendarDateEvent",
+ "RadrootsAdmittedCalendarTimeEvent",
"RadrootsTradeId",
"RadrootsTradeCandidateId",
"RadrootsTradeMutationId",
@@ -124,6 +139,7 @@ classes = [
"validation_error",
"address_error",
"decode_error",
+ "admission_error",
]
[implementation_provenance]
@@ -559,41 +575,172 @@ rust_types = ["radroots_event::file_metadata::RadrootsFileMetadata"]
[operations.social_file_metadata_build_tags.conformance]
vector = "contracts/conformance/vectors/social/mvp.v1.json"
-[operations.social_calendar_date_event_build_tags]
+[operations.social_calendar_date_event_build_authored_draft]
domain = "social"
-id = "social.calendar_date_event.build_tags"
+id = "social.calendar_date_event.build_authored_draft"
stability = "beta"
-inputs = ["RadrootsCalendarDateEvent"]
-outputs = ["NostrTags"]
+inputs = ["RadrootsAuthoredCalendarDateEvent"]
+outputs = ["RadrootsNip01EventWireParts"]
error_class = "encode_error"
deterministic = true
-signing = "native"
-transport = "native"
+signing = "none"
+transport = "none"
-[operations.social_calendar_date_event_build_tags.implementation]
-rust_modules = ["crates/event_codec/src/calendar/encode.rs"]
-rust_types = ["radroots_event::calendar::RadrootsCalendarDateEvent"]
+[operations.social_calendar_date_event_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::RadrootsAuthoredCalendarDateEvent",
+ "radroots_event::calendar::RadrootsCalendarDate",
+ "radroots_event::calendar::RadrootsCalendarEventError",
+ "radroots_event::media::RadrootsAuthoredImage",
+ "radroots_event::wire::RadrootsNip01EventWireParts",
+ "radroots_event_codec::error::EventEncodeError",
+]
-[operations.social_calendar_date_event_build_tags.conformance]
-vector = "contracts/conformance/vectors/social/mvp.v1.json"
+[operations.social_calendar_date_event_build_authored_draft.conformance]
+vector = "contracts/conformance/vectors/calendar/radroots_profile.v1.json"
-[operations.social_calendar_time_event_build_tags]
+[operations.social_calendar_date_event_parse_nip52]
domain = "social"
-id = "social.calendar_time_event.build_tags"
+id = "social.calendar_date_event.parse_nip52"
stability = "beta"
-inputs = ["RadrootsCalendarTimeEvent"]
-outputs = ["NostrTags"]
+inputs = ["u32", "NostrTags", "String"]
+outputs = ["RadrootsParsedNip52CalendarDateEvent"]
+error_class = "parse_error"
+deterministic = true
+signing = "none"
+transport = "none"
+
+[operations.social_calendar_date_event_parse_nip52.implementation]
+rust_modules = [
+ "crates/event/src/calendar.rs",
+ "crates/event_codec/src/calendar/decode.rs",
+]
+rust_types = [
+ "radroots_event::calendar::RadrootsCalendarDate",
+ "radroots_event::calendar::RadrootsCalendarRequest",
+ "radroots_event::calendar::RadrootsCalendarUri",
+ "radroots_event::calendar::RadrootsParsedNip52CalendarCommon",
+ "radroots_event::calendar::RadrootsParsedNip52CalendarCommonParts",
+ "radroots_event::calendar::RadrootsParsedNip52CalendarDateEvent",
+ "radroots_event_codec::error::EventParseError",
+]
+
+[operations.social_calendar_date_event_parse_nip52.conformance]
+vector = "contracts/conformance/vectors/calendar/nip52_baseline.v1.json"
+
+[operations.social_calendar_date_event_admit_radroots_profile]
+domain = "social"
+id = "social.calendar_date_event.admit_radroots_profile"
+stability = "beta"
+inputs = ["RadrootsParsedNip52CalendarDateEvent"]
+outputs = ["RadrootsAdmittedCalendarDateEvent"]
+error_class = "admission_error"
+deterministic = true
+signing = "none"
+transport = "none"
+
+[operations.social_calendar_date_event_admit_radroots_profile.implementation]
+rust_modules = [
+ "crates/event/src/calendar.rs",
+ "crates/event_codec/src/calendar/decode.rs",
+]
+rust_types = [
+ "radroots_event::calendar::RadrootsAdmittedCalendarDateEvent",
+ "radroots_event::calendar::RadrootsCalendarAdmissionError",
+ "radroots_event::calendar::RadrootsParsedNip52CalendarDateEvent",
+]
+
+[operations.social_calendar_date_event_admit_radroots_profile.conformance]
+vector = "contracts/conformance/vectors/calendar/radroots_profile.v1.json"
+
+[operations.social_calendar_time_event_build_authored_draft]
+domain = "social"
+id = "social.calendar_time_event.build_authored_draft"
+stability = "beta"
+inputs = ["RadrootsAuthoredCalendarTimeEvent"]
+outputs = ["RadrootsNip01EventWireParts"]
error_class = "encode_error"
deterministic = true
-signing = "native"
-transport = "native"
+signing = "none"
+transport = "none"
+
+[operations.social_calendar_time_event_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::RadrootsAuthoredCalendarTimeEvent",
+ "radroots_event::calendar::RadrootsCalendarEventError",
+ "radroots_event::calendar::RadrootsIanaTimeZoneId",
+ "radroots_event::media::RadrootsAuthoredImage",
+ "radroots_event::wire::RadrootsNip01EventWireParts",
+ "radroots_event_codec::error::EventEncodeError",
+]
-[operations.social_calendar_time_event_build_tags.implementation]
-rust_modules = ["crates/event_codec/src/calendar/encode.rs"]
-rust_types = ["radroots_event::calendar::RadrootsCalendarTimeEvent"]
+[operations.social_calendar_time_event_build_authored_draft.conformance]
+vector = "contracts/conformance/vectors/calendar/radroots_profile.v1.json"
-[operations.social_calendar_time_event_build_tags.conformance]
-vector = "contracts/conformance/vectors/social/mvp.v1.json"
+[operations.social_calendar_time_event_parse_nip52]
+domain = "social"
+id = "social.calendar_time_event.parse_nip52"
+stability = "beta"
+inputs = ["u32", "NostrTags", "String"]
+outputs = ["RadrootsParsedNip52CalendarTimeEvent"]
+error_class = "parse_error"
+deterministic = true
+signing = "none"
+transport = "none"
+
+[operations.social_calendar_time_event_parse_nip52.implementation]
+rust_modules = [
+ "crates/event/src/calendar.rs",
+ "crates/event_codec/src/calendar/decode.rs",
+]
+rust_types = [
+ "radroots_event::calendar::RadrootsCalendarRequest",
+ "radroots_event::calendar::RadrootsCalendarUri",
+ "radroots_event::calendar::RadrootsIanaTimeZoneId",
+ "radroots_event::calendar::RadrootsObservedUtcDay",
+ "radroots_event::calendar::RadrootsParsedNip52CalendarCommon",
+ "radroots_event::calendar::RadrootsParsedNip52CalendarCommonParts",
+ "radroots_event::calendar::RadrootsParsedNip52CalendarTimeEvent",
+ "radroots_event_codec::error::EventParseError",
+]
+
+[operations.social_calendar_time_event_parse_nip52.conformance]
+vector = "contracts/conformance/vectors/calendar/nip52_baseline.v1.json"
+
+[operations.social_calendar_time_event_admit_radroots_profile]
+domain = "social"
+id = "social.calendar_time_event.admit_radroots_profile"
+stability = "beta"
+inputs = ["RadrootsParsedNip52CalendarTimeEvent"]
+outputs = ["RadrootsAdmittedCalendarTimeEvent"]
+error_class = "admission_error"
+deterministic = true
+signing = "none"
+transport = "none"
+
+[operations.social_calendar_time_event_admit_radroots_profile.implementation]
+rust_modules = [
+ "crates/event/src/calendar.rs",
+ "crates/event_codec/src/calendar/decode.rs",
+]
+rust_types = [
+ "radroots_event::calendar::RadrootsAdmittedCalendarTimeEvent",
+ "radroots_event::calendar::RadrootsCalendarAdmissionError",
+ "radroots_event::calendar::RadrootsParsedNip52CalendarTimeEvent",
+]
+
+[operations.social_calendar_time_event_admit_radroots_profile.conformance]
+vector = "contracts/conformance/vectors/calendar/radroots_profile.v1.json"
[operations.trade_canonicalize_mutation]
domain = "trade"
diff --git a/crates/event/Cargo.toml b/crates/event/Cargo.toml
@@ -24,7 +24,7 @@ dto-bindgen = [
knowledge = []
knowledge-nip54 = ["knowledge"]
signature = ["dep:secp256k1"]
-std = ["radroots_blossom/std", "radroots_core/std"]
+std = ["radroots_blossom/std", "radroots_core/std", "url_nostd/std"]
serde = ["dep:serde", "radroots_core/serde"]
[dependencies]
@@ -32,6 +32,7 @@ dto_bindgen = { workspace = true, optional = true }
radroots_blossom = { workspace = true, default-features = false }
radroots_core = { workspace = true, default-features = false }
hex = { version = "0.4", default-features = false, features = ["alloc"] }
+jiff-tzdb = { workspace = true }
serde = { workspace = true, default-features = false, features = [
"alloc",
"derive",
@@ -41,6 +42,7 @@ serde_json = { workspace = true, default-features = false, features = [
] }
secp256k1 = { workspace = true, optional = true }
sha2 = { workspace = true, default-features = false }
+url_nostd = { workspace = true }
[dev-dependencies]
radroots_core = { workspace = true, default-features = false, features = [
diff --git a/crates/event/README b/crates/event/README
@@ -20,6 +20,38 @@ byte-verified Blossom descriptors; and its NIP-05 identifier type validates
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.
+
+Baseline projections retain the pinned NIP-52 common fields: repeated
+locations, participants, categories, absolute-URI references, kind-`31924`
+calendar-inclusion requests, and deprecated `name` compatibility data in
+addition to `d`, `title`, description, summary, image, and geohash. Date
+events use semantic Gregorian dates and retain observed uppercase-`D` tags as
+uninterpreted extensions. Time events validate unsigned timestamps, exact IANA
+time-zone identifiers, and at least one in-range `D` day while tolerating the
+NIP's non-mandatory start-day and complete-coverage forms. An absent `end_tzid`
+falls back to `start_tzid` when one is present.
+
+Strict authoring and admission add canonical metadata and bounded resource
+rules. Authored common fields include repeated locations, participants,
+categories, references, and calendar-inclusion requests; deprecated `name` is
+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.
+
## Field Event Boundary
`radroots_event` includes the public event-layer models needed by Field-style
diff --git a/crates/event/src/calendar.rs b/crates/event/src/calendar.rs
@@ -1,137 +1,1764 @@
#[cfg(not(feature = "std"))]
-use alloc::{string::String, vec::Vec};
+use alloc::{
+ format,
+ string::{String, ToString},
+ vec,
+ vec::Vec,
+};
+use core::{fmt, ops::RangeInclusive, str::FromStr};
+
+use crate::ids::{
+ RadrootsAddressableCoordinate, RadrootsDTag, 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,
+};
+use radroots_blossom::RadrootsBlossomBlobUrl;
+use url_nostd::Url;
+
+pub const RADROOTS_CALENDAR_SECONDS_PER_DAY: u64 = 86_400;
+pub const RADROOTS_CALENDAR_MAX_COVERED_UTC_DAYS: u64 = 366;
+pub const RADROOTS_CALENDAR_MAX_PARTICIPANTS: usize = DEFAULT_TAG_MAX_COUNT - 16;
+
+#[cfg_attr(
+ 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>,
+}
+
+#[non_exhaustive]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsCalendarEventError {
+ InvalidIdentifier,
+ InvalidTitle,
+ InvalidText(&'static str),
+ InvalidUrl(&'static str),
+ InvalidGeohash,
+ InvalidTimeZone,
+ InvalidParticipant {
+ index: usize,
+ },
+ TooManyParticipants {
+ max: usize,
+ actual: usize,
+ },
+ ContentTooLarge {
+ max: usize,
+ actual: usize,
+ },
+ TagElementTooLarge {
+ field: &'static str,
+ max: usize,
+ actual: usize,
+ },
+ TagCountExceeded {
+ max: usize,
+ actual: usize,
+ },
+ TagBytesExceeded {
+ max: usize,
+ actual: usize,
+ },
+ InvalidDate,
+ InvalidRange,
+ CoveredDayLimitExceeded {
+ max: u64,
+ actual: u64,
+ },
+}
+
+impl RadrootsCalendarEventError {
+ pub const fn code(&self) -> &'static str {
+ match self {
+ Self::InvalidIdentifier => "invalid_identifier",
+ Self::InvalidTitle => "invalid_title",
+ Self::InvalidText(_) => "invalid_text",
+ Self::InvalidUrl(_) => "invalid_url",
+ Self::InvalidGeohash => "invalid_geohash",
+ Self::InvalidTimeZone => "invalid_time_zone",
+ Self::InvalidParticipant { .. } => "invalid_participant",
+ Self::TooManyParticipants { .. } => "too_many_participants",
+ Self::ContentTooLarge { .. } => "content_too_large",
+ Self::TagElementTooLarge { .. } => "tag_element_too_large",
+ Self::TagCountExceeded { .. } => "tag_count_exceeded",
+ Self::TagBytesExceeded { .. } => "tag_bytes_exceeded",
+ Self::InvalidDate => "invalid_date",
+ Self::InvalidRange => "invalid_range",
+ Self::CoveredDayLimitExceeded { .. } => "covered_day_limit_exceeded",
+ }
+ }
+}
+
+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::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"),
+ Self::InvalidGeohash => f.write_str("calendar geohash is invalid"),
+ Self::InvalidTimeZone => {
+ f.write_str("calendar time zone is not a canonical IANA identifier")
+ }
+ Self::InvalidParticipant { index } => {
+ write!(f, "calendar participant at index {index} is invalid")
+ }
+ Self::TooManyParticipants { max, actual } => write!(
+ f,
+ "calendar participant count {actual} exceeds maximum {max}"
+ ),
+ Self::ContentTooLarge { max, actual } => {
+ write!(f, "calendar content size {actual} exceeds maximum {max}")
+ }
+ Self::TagElementTooLarge { field, max, actual } => write!(
+ f,
+ "calendar {field} size {actual} exceeds tag element maximum {max}"
+ ),
+ Self::TagCountExceeded { max, actual } => {
+ write!(f, "calendar tag count {actual} exceeds maximum {max}")
+ }
+ Self::TagBytesExceeded { max, actual } => {
+ write!(f, "calendar tag bytes {actual} exceed maximum {max}")
+ }
+ Self::InvalidDate => f.write_str("calendar date is not a valid Gregorian date"),
+ Self::InvalidRange => f.write_str("calendar end must be greater than start"),
+ Self::CoveredDayLimitExceeded { max, actual } => write!(
+ f,
+ "calendar interval covers {actual} UTC days, exceeding the {max}-day limit"
+ ),
+ }
+ }
+}
+
+#[cfg(feature = "std")]
+impl std::error::Error for RadrootsCalendarEventError {}
+
+/// A canonical IANA Time Zone Database identifier.
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct RadrootsIanaTimeZoneId(String);
+
+impl RadrootsIanaTimeZoneId {
+ pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsCalendarEventError> {
+ let value = value.as_ref();
+ let Some((canonical, _)) = jiff_tzdb::get(value) else {
+ return Err(RadrootsCalendarEventError::InvalidTimeZone);
+ };
+ if canonical != value || !canonical_calendar_tag_text_is_valid(value) {
+ return Err(RadrootsCalendarEventError::InvalidTimeZone);
+ }
+ Ok(Self(value.into()))
+ }
+
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+impl fmt::Display for RadrootsIanaTimeZoneId {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(self.as_str())
+ }
+}
+
+impl AsRef<str> for RadrootsIanaTimeZoneId {
+ fn as_ref(&self) -> &str {
+ self.as_str()
+ }
+}
+
+impl FromStr for RadrootsIanaTimeZoneId {
+ type Err = RadrootsCalendarEventError;
+
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
+ Self::parse(value)
+ }
+}
+
+#[cfg(any(feature = "serde", test))]
+impl serde::Serialize for RadrootsIanaTimeZoneId {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ serializer.serialize_str(self.as_str())
+ }
+}
+
+#[cfg(any(feature = "serde", test))]
+impl<'de> serde::Deserialize<'de> for RadrootsIanaTimeZoneId {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let value = <String as serde::Deserialize>::deserialize(deserializer)?;
+ Self::parse(value).map_err(serde::de::Error::custom)
+ }
+}
+
+/// A structurally valid absolute URI preserved exactly as it appeared on wire.
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct RadrootsCalendarUri(String);
+
+impl RadrootsCalendarUri {
+ pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsCalendarEventError> {
+ let value = value.as_ref();
+ if value.trim() != value
+ || value
+ .chars()
+ .any(|character| character.is_whitespace() || character.is_control())
+ || value.len() > DEFAULT_TAG_ELEMENT_MAX_BYTES
+ || Url::parse(value).is_err()
+ {
+ return Err(RadrootsCalendarEventError::InvalidUrl("URI"));
+ }
+ Ok(Self(value.into()))
+ }
+
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+impl AsRef<str> for RadrootsCalendarUri {
+ fn as_ref(&self) -> &str {
+ self.as_str()
+ }
+}
+
+impl FromStr for RadrootsCalendarUri {
+ type Err = RadrootsCalendarEventError;
+
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
+ Self::parse(value)
+ }
+}
+
+impl fmt::Display for RadrootsCalendarUri {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(self.as_str())
+ }
+}
+
+#[cfg(any(feature = "serde", test))]
+impl serde::Serialize for RadrootsCalendarUri {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ serializer.serialize_str(self.as_str())
+ }
+}
+
+#[cfg(any(feature = "serde", test))]
+impl<'de> serde::Deserialize<'de> for RadrootsCalendarUri {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let value = <String as serde::Deserialize>::deserialize(deserializer)?;
+ Self::parse(value).map_err(serde::de::Error::custom)
+ }
+}
+
+#[cfg_attr(any(feature = "serde", test), derive(serde::Serialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsCalendarRequest {
+ calendar: RadrootsAddressableCoordinate,
+ relay: Option<String>,
+}
+
+impl RadrootsCalendarRequest {
+ pub fn new(
+ calendar: impl AsRef<str>,
+ relay: Option<&str>,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ let calendar = RadrootsAddressableCoordinate::parse(calendar.as_ref())
+ .map_err(|_| RadrootsCalendarEventError::InvalidUrl("calendar request"))?;
+ let parts = crate::ids::RadrootsAddressableCoordinateParts::parse(calendar.as_str())
+ .map_err(|_| RadrootsCalendarEventError::InvalidUrl("calendar request"))?;
+ if parts.kind != crate::kinds::KIND_CALENDAR {
+ return Err(RadrootsCalendarEventError::InvalidUrl("calendar request"));
+ }
+ let relay = relay
+ .map(|value| {
+ if calendar_relay_url_is_valid(value) {
+ Ok(value.to_string())
+ } else {
+ Err(RadrootsCalendarEventError::InvalidUrl(
+ "calendar request relay",
+ ))
+ }
+ })
+ .transpose()?;
+ Ok(Self { calendar, relay })
+ }
+
+ pub fn calendar(&self) -> &RadrootsAddressableCoordinate {
+ &self.calendar
+ }
+
+ pub fn relay(&self) -> Option<&str> {
+ self.relay.as_deref()
+ }
+
+ pub fn is_canonical(&self) -> bool {
+ let Ok(parts) =
+ crate::ids::RadrootsAddressableCoordinateParts::parse(self.calendar.as_str())
+ else {
+ return false;
+ };
+ self.calendar.as_str() == format!("{}:{}:{}", parts.kind, parts.pubkey, parts.d_tag)
+ && self
+ .relay
+ .as_deref()
+ .is_none_or(|relay| RadrootsRelayUrl::parse(relay).is_ok())
+ }
+}
+
+/// A canonical Gregorian calendar date in `YYYY-MM-DD` form.
+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct RadrootsCalendarDate(String);
+
+impl RadrootsCalendarDate {
+ pub fn parse(value: &str) -> Result<Self, RadrootsCalendarEventError> {
+ let bytes = value.as_bytes();
+ if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
+ return Err(RadrootsCalendarEventError::InvalidDate);
+ }
+ let year = parse_date_digits(&bytes[0..4])?;
+ let month = parse_date_digits(&bytes[5..7])?;
+ let day = parse_date_digits(&bytes[8..10])?;
+ if year == 0 || month == 0 || month > 12 || day == 0 || day > days_in_month(year, month) {
+ return Err(RadrootsCalendarEventError::InvalidDate);
+ }
+ Ok(Self(value.into()))
+ }
+
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+impl fmt::Display for RadrootsCalendarDate {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(self.as_str())
+ }
+}
+
+impl AsRef<str> for RadrootsCalendarDate {
+ fn as_ref(&self) -> &str {
+ self.as_str()
+ }
+}
+
+impl FromStr for RadrootsCalendarDate {
+ type Err = RadrootsCalendarEventError;
+
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
+ Self::parse(value)
+ }
+}
+
+#[cfg(any(feature = "serde", test))]
+impl serde::Serialize for RadrootsCalendarDate {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ serializer.serialize_str(self.as_str())
+ }
+}
+
+#[cfg(any(feature = "serde", test))]
+impl<'de> serde::Deserialize<'de> for RadrootsCalendarDate {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let value = <String as serde::Deserialize>::deserialize(deserializer)?;
+ Self::parse(&value).map_err(serde::de::Error::custom)
+ }
+}
+
+/// Strict authored NIP-52 kind-31922 event.
+///
+/// Authored images can only enter through a byte-verified image descriptor.
+/// The model cannot represent the obsolete uppercase-`D` date list.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsAuthoredCalendarDateEvent {
+ d_tag: RadrootsDTag,
+ title: String,
+ start: RadrootsCalendarDate,
+ description: Option<String>,
+ end: Option<RadrootsCalendarDate>,
+ locations: Vec<String>,
+ geohash: Option<String>,
+ summary: Option<String>,
+ image: Option<RadrootsAuthoredImage>,
+ participants: Option<Vec<RadrootsCalendarParticipant>>,
+ categories: Vec<String>,
+ references: Vec<RadrootsCalendarUri>,
+ calendar_requests: Vec<RadrootsCalendarRequest>,
+}
+
+impl RadrootsAuthoredCalendarDateEvent {
+ pub fn new(
+ d_tag: impl AsRef<str>,
+ title: impl Into<String>,
+ start: RadrootsCalendarDate,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ let d_tag = RadrootsDTag::parse(d_tag.as_ref())
+ .map_err(|_| RadrootsCalendarEventError::InvalidIdentifier)?;
+ let title = validated_title(title.into())?;
+ Ok(Self {
+ d_tag,
+ title,
+ start,
+ description: None,
+ end: None,
+ locations: Vec::new(),
+ geohash: None,
+ summary: None,
+ image: None,
+ participants: None,
+ categories: Vec::new(),
+ references: Vec::new(),
+ calendar_requests: Vec::new(),
+ })
+ }
+
+ pub fn with_end(
+ mut self,
+ end: RadrootsCalendarDate,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ if end <= self.start {
+ return Err(RadrootsCalendarEventError::InvalidRange);
+ }
+ self.end = Some(end);
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn with_description(
+ mut self,
+ value: impl Into<String>,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ let value = value.into();
+ validate_calendar_content(&value)?;
+ self.description = Some(value);
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn with_locations(
+ mut self,
+ value: Vec<String>,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ validate_authored_calendar_locations(&value)?;
+ self.locations = value;
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn with_geohash(
+ mut self,
+ value: impl Into<String>,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ let value = value.into();
+ if !canonical_calendar_geohash_is_valid(&value) {
+ return Err(RadrootsCalendarEventError::InvalidGeohash);
+ }
+ self.geohash = Some(value);
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn with_summary(
+ mut self,
+ value: impl Into<String>,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ let value = value.into();
+ validate_canonical_calendar_tag_text(&value, "summary")?;
+ self.summary = Some(value);
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn with_image(
+ mut self,
+ value: RadrootsAuthoredImage,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ self.image = Some(value);
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn with_participants(
+ mut self,
+ value: Vec<RadrootsCalendarParticipant>,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ validate_authored_calendar_participants(&value)?;
+ self.participants = Some(value);
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn with_categories(
+ mut self,
+ value: Vec<String>,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ validate_authored_calendar_categories(&value)?;
+ self.categories = value;
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn with_references(
+ mut self,
+ value: Vec<RadrootsCalendarUri>,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ self.references = value;
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn with_calendar_requests(
+ mut self,
+ value: Vec<RadrootsCalendarRequest>,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ validate_authored_calendar_requests(&value)?;
+ self.calendar_requests = value;
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn d_tag(&self) -> &RadrootsDTag {
+ &self.d_tag
+ }
+
+ pub fn title(&self) -> &str {
+ &self.title
+ }
+
+ pub fn start(&self) -> &RadrootsCalendarDate {
+ &self.start
+ }
+
+ pub fn description(&self) -> Option<&str> {
+ self.description.as_deref()
+ }
+
+ pub fn end(&self) -> Option<&RadrootsCalendarDate> {
+ self.end.as_ref()
+ }
+
+ pub fn locations(&self) -> &[String] {
+ &self.locations
+ }
+
+ pub fn geohash(&self) -> Option<&str> {
+ self.geohash.as_deref()
+ }
+
+ pub fn summary(&self) -> Option<&str> {
+ self.summary.as_deref()
+ }
+
+ pub fn image(&self) -> Option<&RadrootsAuthoredImage> {
+ self.image.as_ref()
+ }
+
+ pub fn participants(&self) -> Option<&Vec<RadrootsCalendarParticipant>> {
+ self.participants.as_ref()
+ }
+
+ pub fn categories(&self) -> &[String] {
+ &self.categories
+ }
+
+ pub fn references(&self) -> &[RadrootsCalendarUri] {
+ &self.references
+ }
+
+ pub fn calendar_requests(&self) -> &[RadrootsCalendarRequest] {
+ &self.calendar_requests
+ }
+
+ fn validate_budget(&self) -> Result<(), RadrootsCalendarEventError> {
+ validate_authored_calendar_budget(self.description(), authored_date_tags_for_budget(self))
+ }
+}
+
+/// Strict authored NIP-52 kind-31923 event.
+///
+/// Covered uppercase-`D` values are derived from the timestamp range and are
+/// never accepted from callers.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsAuthoredCalendarTimeEvent {
+ d_tag: RadrootsDTag,
+ title: String,
+ start: u64,
+ description: Option<String>,
+ end: Option<u64>,
+ start_tzid: Option<RadrootsIanaTimeZoneId>,
+ end_tzid: Option<RadrootsIanaTimeZoneId>,
+ locations: Vec<String>,
+ geohash: Option<String>,
+ summary: Option<String>,
+ image: Option<RadrootsAuthoredImage>,
+ participants: Option<Vec<RadrootsCalendarParticipant>>,
+ categories: Vec<String>,
+ references: Vec<RadrootsCalendarUri>,
+ calendar_requests: Vec<RadrootsCalendarRequest>,
+}
+
+impl RadrootsAuthoredCalendarTimeEvent {
+ pub fn new(
+ d_tag: impl AsRef<str>,
+ title: impl Into<String>,
+ start: u64,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ let d_tag = RadrootsDTag::parse(d_tag.as_ref())
+ .map_err(|_| RadrootsCalendarEventError::InvalidIdentifier)?;
+ let title = validated_title(title.into())?;
+ covered_utc_days(start, None)?;
+ Ok(Self {
+ d_tag,
+ title,
+ start,
+ description: None,
+ end: None,
+ start_tzid: None,
+ end_tzid: None,
+ locations: Vec::new(),
+ geohash: None,
+ summary: None,
+ image: None,
+ participants: None,
+ categories: Vec::new(),
+ references: Vec::new(),
+ calendar_requests: Vec::new(),
+ })
+ }
+
+ pub fn with_end(mut self, end: u64) -> Result<Self, RadrootsCalendarEventError> {
+ covered_utc_days(self.start, Some(end))?;
+ self.end = Some(end);
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn with_description(
+ mut self,
+ value: impl Into<String>,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ let value = value.into();
+ validate_calendar_content(&value)?;
+ self.description = Some(value);
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn with_start_tzid(
+ mut self,
+ value: impl AsRef<str>,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ self.start_tzid = Some(RadrootsIanaTimeZoneId::parse(value)?);
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn with_end_tzid(
+ mut self,
+ value: impl AsRef<str>,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ self.end_tzid = Some(RadrootsIanaTimeZoneId::parse(value)?);
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn with_locations(
+ mut self,
+ value: Vec<String>,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ validate_authored_calendar_locations(&value)?;
+ self.locations = value;
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn with_geohash(
+ mut self,
+ value: impl Into<String>,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ let value = value.into();
+ if !canonical_calendar_geohash_is_valid(&value) {
+ return Err(RadrootsCalendarEventError::InvalidGeohash);
+ }
+ self.geohash = Some(value);
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn with_summary(
+ mut self,
+ value: impl Into<String>,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ let value = value.into();
+ validate_canonical_calendar_tag_text(&value, "summary")?;
+ self.summary = Some(value);
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn with_image(
+ mut self,
+ value: RadrootsAuthoredImage,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ self.image = Some(value);
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn with_participants(
+ mut self,
+ value: Vec<RadrootsCalendarParticipant>,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ validate_authored_calendar_participants(&value)?;
+ self.participants = Some(value);
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn with_categories(
+ mut self,
+ value: Vec<String>,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ validate_authored_calendar_categories(&value)?;
+ self.categories = value;
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn with_references(
+ mut self,
+ value: Vec<RadrootsCalendarUri>,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ self.references = value;
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn with_calendar_requests(
+ mut self,
+ value: Vec<RadrootsCalendarRequest>,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ validate_authored_calendar_requests(&value)?;
+ self.calendar_requests = value;
+ self.validate_budget()?;
+ Ok(self)
+ }
+
+ pub fn d_tag(&self) -> &RadrootsDTag {
+ &self.d_tag
+ }
+
+ pub fn title(&self) -> &str {
+ &self.title
+ }
+
+ pub const fn start(&self) -> u64 {
+ self.start
+ }
+
+ pub fn description(&self) -> Option<&str> {
+ self.description.as_deref()
+ }
+
+ pub const fn end(&self) -> Option<u64> {
+ self.end
+ }
+
+ pub fn start_tzid(&self) -> Option<&RadrootsIanaTimeZoneId> {
+ self.start_tzid.as_ref()
+ }
+
+ pub fn end_tzid(&self) -> Option<&RadrootsIanaTimeZoneId> {
+ self.end_tzid.as_ref()
+ }
+
+ pub fn effective_end_tzid(&self) -> Option<&RadrootsIanaTimeZoneId> {
+ self.end_tzid.as_ref().or(self.start_tzid.as_ref())
+ }
+
+ pub fn locations(&self) -> &[String] {
+ &self.locations
+ }
+
+ pub fn geohash(&self) -> Option<&str> {
+ self.geohash.as_deref()
+ }
+
+ pub fn summary(&self) -> Option<&str> {
+ self.summary.as_deref()
+ }
+
+ pub fn image(&self) -> Option<&RadrootsAuthoredImage> {
+ self.image.as_ref()
+ }
+
+ pub fn participants(&self) -> Option<&Vec<RadrootsCalendarParticipant>> {
+ self.participants.as_ref()
+ }
+
+ pub fn categories(&self) -> &[String] {
+ &self.categories
+ }
+
+ pub fn references(&self) -> &[RadrootsCalendarUri] {
+ &self.references
+ }
+
+ pub fn calendar_requests(&self) -> &[RadrootsCalendarRequest] {
+ &self.calendar_requests
+ }
+
+ fn validate_budget(&self) -> Result<(), RadrootsCalendarEventError> {
+ validate_authored_calendar_budget(self.description(), authored_time_tags_for_budget(self)?)
+ }
+}
+
+#[cfg_attr(any(feature = "serde", test), derive(serde::Serialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsParsedNip52CalendarCommon {
+ d_tag: String,
+ title: String,
+ description: Option<String>,
+ locations: Vec<String>,
+ geohash: Option<String>,
+ summary: Option<String>,
+ image: Option<RadrootsCalendarUri>,
+ participants: Vec<RadrootsCalendarParticipant>,
+ categories: Vec<String>,
+ references: Vec<RadrootsCalendarUri>,
+ calendar_requests: Vec<RadrootsCalendarRequest>,
+ legacy_name: Option<String>,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsParsedNip52CalendarCommonParts {
+ pub d_tag: String,
+ pub title: String,
+ pub description: Option<String>,
+ pub locations: Vec<String>,
+ pub geohash: Option<String>,
+ pub summary: Option<String>,
+ pub image: Option<RadrootsCalendarUri>,
+ pub participants: Vec<RadrootsCalendarParticipant>,
+ pub categories: Vec<String>,
+ pub references: Vec<RadrootsCalendarUri>,
+ pub calendar_requests: Vec<RadrootsCalendarRequest>,
+ pub legacy_name: Option<String>,
+}
+
+impl RadrootsParsedNip52CalendarCommon {
+ pub fn try_new(
+ parts: RadrootsParsedNip52CalendarCommonParts,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ validate_calendar_tag_text(&parts.d_tag, "d")?;
+ validate_calendar_tag_text(&parts.title, "title")?;
+ if let Some(description) = parts.description.as_deref() {
+ validate_calendar_content(description)?;
+ }
+ for location in &parts.locations {
+ validate_calendar_tag_text(location, "location")?;
+ }
+ if let Some(geohash) = parts.geohash.as_deref()
+ && !calendar_geohash_is_valid(geohash)
+ {
+ return Err(RadrootsCalendarEventError::InvalidGeohash);
+ }
+ if let Some(summary) = parts.summary.as_deref() {
+ validate_calendar_tag_text(summary, "summary")?;
+ }
+ validate_inbound_calendar_participants(&parts.participants)?;
+ for category in &parts.categories {
+ validate_calendar_tag_text(category, "category")?;
+ }
+ if let Some(legacy_name) = parts.legacy_name.as_deref() {
+ validate_calendar_tag_text(legacy_name, "legacy name")?;
+ }
+ Ok(Self {
+ d_tag: parts.d_tag,
+ title: parts.title,
+ description: parts.description,
+ locations: parts.locations,
+ geohash: parts.geohash,
+ summary: parts.summary,
+ image: parts.image,
+ participants: parts.participants,
+ categories: parts.categories,
+ references: parts.references,
+ calendar_requests: parts.calendar_requests,
+ legacy_name: parts.legacy_name,
+ })
+ }
+
+ pub fn d_tag(&self) -> &str {
+ &self.d_tag
+ }
+
+ pub fn title(&self) -> &str {
+ &self.title
+ }
+
+ pub fn description(&self) -> Option<&str> {
+ self.description.as_deref()
+ }
+
+ pub fn locations(&self) -> &[String] {
+ &self.locations
+ }
+
+ pub fn geohash(&self) -> Option<&str> {
+ self.geohash.as_deref()
+ }
+
+ pub fn summary(&self) -> Option<&str> {
+ self.summary.as_deref()
+ }
+
+ pub fn image(&self) -> Option<&RadrootsCalendarUri> {
+ self.image.as_ref()
+ }
+
+ pub fn participants(&self) -> &[RadrootsCalendarParticipant] {
+ &self.participants
+ }
+
+ pub fn categories(&self) -> &[String] {
+ &self.categories
+ }
+
+ pub fn references(&self) -> &[RadrootsCalendarUri] {
+ &self.references
+ }
+
+ pub fn calendar_requests(&self) -> &[RadrootsCalendarRequest] {
+ &self.calendar_requests
+ }
+
+ pub fn legacy_name(&self) -> Option<&str> {
+ self.legacy_name.as_deref()
+ }
+}
+
+#[cfg_attr(any(feature = "serde", test), derive(serde::Serialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsParsedNip52CalendarDateEvent {
+ common: RadrootsParsedNip52CalendarCommon,
+ start: RadrootsCalendarDate,
+ end: Option<RadrootsCalendarDate>,
+ extension_day_tags: Vec<Vec<String>>,
+}
+
+impl RadrootsParsedNip52CalendarDateEvent {
+ pub fn try_new(
+ common: RadrootsParsedNip52CalendarCommon,
+ start: RadrootsCalendarDate,
+ end: Option<RadrootsCalendarDate>,
+ extension_day_tags: Vec<Vec<String>>,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ if end.as_ref().is_some_and(|end| end <= &start) {
+ return Err(RadrootsCalendarEventError::InvalidRange);
+ }
+ Ok(Self {
+ common,
+ start,
+ end,
+ extension_day_tags,
+ })
+ }
+
+ pub fn common(&self) -> &RadrootsParsedNip52CalendarCommon {
+ &self.common
+ }
+
+ pub fn start(&self) -> &RadrootsCalendarDate {
+ &self.start
+ }
+
+ pub fn end(&self) -> Option<&RadrootsCalendarDate> {
+ self.end.as_ref()
+ }
+
+ pub fn extension_day_tags(&self) -> &[Vec<String>] {
+ &self.extension_day_tags
+ }
+}
+
+#[cfg_attr(any(feature = "serde", test), derive(serde::Serialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsObservedUtcDay {
+ wire_value: String,
+ index: u64,
+}
+
+impl RadrootsObservedUtcDay {
+ pub fn parse(value: impl Into<String>) -> Result<Self, RadrootsCalendarEventError> {
+ let wire_value = value.into();
+ if wire_value.is_empty() || !wire_value.bytes().all(|byte| byte.is_ascii_digit()) {
+ return Err(RadrootsCalendarEventError::InvalidText("D"));
+ }
+ let index = wire_value
+ .parse()
+ .map_err(|_| RadrootsCalendarEventError::InvalidText("D"))?;
+ Ok(Self { wire_value, index })
+ }
+
+ pub fn wire_value(&self) -> &str {
+ &self.wire_value
+ }
+
+ pub const fn index(&self) -> u64 {
+ self.index
+ }
+
+ pub fn is_canonical(&self) -> bool {
+ self.wire_value == self.index.to_string()
+ }
+}
+
+#[cfg_attr(any(feature = "serde", test), derive(serde::Serialize))]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsParsedNip52CalendarTimeEvent {
+ common: RadrootsParsedNip52CalendarCommon,
+ start_wire: String,
+ start: u64,
+ end_wire: Option<String>,
+ end: Option<u64>,
+ observed_day_indices: Vec<RadrootsObservedUtcDay>,
+ start_tzid: Option<RadrootsIanaTimeZoneId>,
+ end_tzid: Option<RadrootsIanaTimeZoneId>,
+}
+
+impl RadrootsParsedNip52CalendarTimeEvent {
+ #[allow(clippy::too_many_arguments)]
+ pub fn try_new(
+ common: RadrootsParsedNip52CalendarCommon,
+ start_wire: String,
+ start: u64,
+ end_wire: Option<String>,
+ end: Option<u64>,
+ observed_day_indices: Vec<RadrootsObservedUtcDay>,
+ start_tzid: Option<RadrootsIanaTimeZoneId>,
+ end_tzid: Option<RadrootsIanaTimeZoneId>,
+ ) -> Result<Self, RadrootsCalendarEventError> {
+ if parse_calendar_decimal(&start_wire) != Some(start)
+ || end_wire
+ .as_deref()
+ .zip(end)
+ .is_some_and(|(wire, end)| parse_calendar_decimal(wire) != Some(end))
+ || end_wire.is_some() != end.is_some()
+ {
+ return Err(RadrootsCalendarEventError::InvalidRange);
+ }
+ if end.is_some_and(|end| end <= start) {
+ return Err(RadrootsCalendarEventError::InvalidRange);
+ }
+ let first = start / RADROOTS_CALENDAR_SECONDS_PER_DAY;
+ let last = end
+ .map(|end| (end - 1) / RADROOTS_CALENDAR_SECONDS_PER_DAY)
+ .unwrap_or(first);
+ if observed_day_indices.is_empty()
+ || observed_day_indices
+ .iter()
+ .any(|day| !(first..=last).contains(&day.index))
+ {
+ return Err(RadrootsCalendarEventError::InvalidRange);
+ }
+ Ok(Self {
+ common,
+ start_wire,
+ start,
+ end_wire,
+ end,
+ observed_day_indices,
+ start_tzid,
+ end_tzid,
+ })
+ }
+
+ pub fn common(&self) -> &RadrootsParsedNip52CalendarCommon {
+ &self.common
+ }
+
+ pub fn start_wire(&self) -> &str {
+ &self.start_wire
+ }
+
+ pub const fn start(&self) -> u64 {
+ self.start
+ }
+
+ pub fn end_wire(&self) -> Option<&str> {
+ self.end_wire.as_deref()
+ }
+
+ pub const fn end(&self) -> Option<u64> {
+ self.end
+ }
+
+ pub fn observed_day_indices(&self) -> &[RadrootsObservedUtcDay] {
+ &self.observed_day_indices
+ }
+
+ pub fn start_tzid(&self) -> Option<&RadrootsIanaTimeZoneId> {
+ self.start_tzid.as_ref()
+ }
+
+ pub fn end_tzid(&self) -> Option<&RadrootsIanaTimeZoneId> {
+ self.end_tzid.as_ref()
+ }
+
+ pub fn effective_end_tzid(&self) -> Option<&RadrootsIanaTimeZoneId> {
+ self.end_tzid.as_ref().or(self.start_tzid.as_ref())
+ }
+}
+
+#[non_exhaustive]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsCalendarAdmissionError {
+ NonCanonicalField(&'static str),
+ ForbiddenDateDayIndex,
+ IncompleteDayCoverage,
+ CoveredDayLimitExceeded { max: u64, actual: u64 },
+ NonBlossomImage,
+}
+
+impl RadrootsCalendarAdmissionError {
+ pub const fn code(&self) -> &'static str {
+ match self {
+ Self::NonCanonicalField(_) => "non_canonical_field",
+ Self::ForbiddenDateDayIndex => "forbidden_date_day_index",
+ Self::IncompleteDayCoverage => "incomplete_day_coverage",
+ Self::CoveredDayLimitExceeded { .. } => "covered_day_limit_exceeded",
+ Self::NonBlossomImage => "non_blossom_image",
+ }
+ }
+}
+
+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::ForbiddenDateDayIndex => {
+ f.write_str("calendar date event carries a forbidden uppercase-D extension")
+ }
+ Self::IncompleteDayCoverage => {
+ f.write_str("calendar time event lacks exact ordered UTC-day coverage")
+ }
+ Self::CoveredDayLimitExceeded { max, actual } => write!(
+ f,
+ "calendar interval covers {actual} UTC days, exceeding the {max}-day admission limit"
+ ),
+ Self::NonBlossomImage => {
+ f.write_str("calendar image is not a structural Blossom hash-path URL")
+ }
+ }
+ }
+}
+
+#[cfg(feature = "std")]
+impl std::error::Error for RadrootsCalendarAdmissionError {}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsAdmittedCalendarDateEvent {
+ parsed: RadrootsParsedNip52CalendarDateEvent,
+ d_tag: RadrootsDTag,
+ blossom_image: Option<RadrootsBlossomBlobUrl>,
+}
+
+impl RadrootsAdmittedCalendarDateEvent {
+ pub fn try_from_parsed(
+ parsed: RadrootsParsedNip52CalendarDateEvent,
+ ) -> Result<Self, RadrootsCalendarAdmissionError> {
+ if !parsed.extension_day_tags.is_empty() {
+ return Err(RadrootsCalendarAdmissionError::ForbiddenDateDayIndex);
+ }
+ validate_admitted_calendar_common(&parsed.common)?;
+ let d_tag = admitted_d_tag(&parsed.common)?;
+ let blossom_image = admitted_blossom_image(&parsed.common)?;
+ Ok(Self {
+ parsed,
+ d_tag,
+ blossom_image,
+ })
+ }
+
+ pub fn parsed(&self) -> &RadrootsParsedNip52CalendarDateEvent {
+ &self.parsed
+ }
+
+ pub fn d_tag(&self) -> &RadrootsDTag {
+ &self.d_tag
+ }
+
+ pub fn blossom_image(&self) -> Option<&RadrootsBlossomBlobUrl> {
+ self.blossom_image.as_ref()
+ }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsAdmittedCalendarTimeEvent {
+ parsed: RadrootsParsedNip52CalendarTimeEvent,
+ d_tag: RadrootsDTag,
+ covered_utc_days: Vec<u64>,
+ blossom_image: Option<RadrootsBlossomBlobUrl>,
+}
+
+impl RadrootsAdmittedCalendarTimeEvent {
+ pub fn try_from_parsed(
+ parsed: RadrootsParsedNip52CalendarTimeEvent,
+ ) -> Result<Self, RadrootsCalendarAdmissionError> {
+ validate_admitted_calendar_common(&parsed.common)?;
+ let d_tag = admitted_d_tag(&parsed.common)?;
+ if parsed.start_wire != parsed.start.to_string()
+ || parsed
+ .end_wire
+ .as_deref()
+ .zip(parsed.end)
+ .is_some_and(|(wire, end)| wire != end.to_string())
+ {
+ return Err(RadrootsCalendarAdmissionError::NonCanonicalField(
+ "timestamp",
+ ));
+ }
+ let expected = covered_utc_days(parsed.start, parsed.end).map_err(|error| match error {
+ RadrootsCalendarEventError::CoveredDayLimitExceeded { max, actual } => {
+ RadrootsCalendarAdmissionError::CoveredDayLimitExceeded { max, actual }
+ }
+ _ => RadrootsCalendarAdmissionError::IncompleteDayCoverage,
+ })?;
+ let covered_utc_days = expected.clone().collect::<Vec<_>>();
+ if parsed.observed_day_indices.len() != covered_utc_days.len()
+ || parsed
+ .observed_day_indices
+ .iter()
+ .zip(&covered_utc_days)
+ .any(|(observed, expected)| !observed.is_canonical() || observed.index != *expected)
+ {
+ return Err(RadrootsCalendarAdmissionError::IncompleteDayCoverage);
+ }
+ let blossom_image = admitted_blossom_image(&parsed.common)?;
+ Ok(Self {
+ parsed,
+ d_tag,
+ covered_utc_days,
+ blossom_image,
+ })
+ }
+
+ pub fn parsed(&self) -> &RadrootsParsedNip52CalendarTimeEvent {
+ &self.parsed
+ }
-use crate::social::{
- RadrootsCalendarDateValue, RadrootsCalendarEventFreeBusy, RadrootsCalendarEventRsvpStatus,
- RadrootsCalendarParticipant, RadrootsSocialLocation, RadrootsSocialTarget,
-};
+ pub fn d_tag(&self) -> &RadrootsDTag {
+ &self.d_tag
+ }
-#[cfg_attr(
- 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>,
+ pub fn covered_utc_days(&self) -> &[u64] {
+ &self.covered_utc_days
+ }
+
+ pub fn blossom_image(&self) -> Option<&RadrootsBlossomBlobUrl> {
+ self.blossom_image.as_ref()
+ }
}
-#[cfg_attr(
- any(feature = "serde", test),
- derive(serde::Serialize, serde::Deserialize)
-)]
-#[derive(Clone, Debug)]
-pub struct RadrootsCalendarDateEvent {
- pub d_tag: String,
- pub title: String,
- pub start: String,
- #[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 end: Option<String>,
- #[cfg_attr(
- feature = "serde",
- serde(default, skip_serializing_if = "Option::is_none")
- )]
- pub days: Option<Vec<RadrootsCalendarDateValue>>,
- #[cfg_attr(
- feature = "serde",
- serde(default, skip_serializing_if = "Option::is_none")
- )]
- pub location: Option<RadrootsSocialLocation>,
- #[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>,
- #[cfg_attr(
- feature = "serde",
- serde(default, skip_serializing_if = "Option::is_none")
- )]
- pub participants: Option<Vec<RadrootsCalendarParticipant>>,
+pub fn covered_utc_days(
+ start: u64,
+ end: Option<u64>,
+) -> Result<RangeInclusive<u64>, RadrootsCalendarEventError> {
+ if end.is_some_and(|end| end <= start) {
+ return Err(RadrootsCalendarEventError::InvalidRange);
+ }
+ let first = start / RADROOTS_CALENDAR_SECONDS_PER_DAY;
+ let last = end
+ .map(|end| (end - 1) / RADROOTS_CALENDAR_SECONDS_PER_DAY)
+ .unwrap_or(first);
+ let actual = last - first + 1;
+ if actual > RADROOTS_CALENDAR_MAX_COVERED_UTC_DAYS {
+ return Err(RadrootsCalendarEventError::CoveredDayLimitExceeded {
+ max: RADROOTS_CALENDAR_MAX_COVERED_UTC_DAYS,
+ actual,
+ });
+ }
+ Ok(first..=last)
}
-#[cfg_attr(
- any(feature = "serde", test),
- derive(serde::Serialize, serde::Deserialize)
-)]
-#[derive(Clone, Debug)]
-pub struct RadrootsCalendarTimeEvent {
- pub d_tag: String,
- pub title: String,
- pub start: u64,
- #[cfg_attr(
- feature = "serde",
- serde(default, skip_serializing_if = "Vec::is_empty")
- )]
- pub dates: Vec<RadrootsCalendarDateValue>,
- #[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 end: Option<u64>,
- #[cfg_attr(
- feature = "serde",
- serde(default, skip_serializing_if = "Option::is_none")
- )]
- pub start_tzid: Option<String>,
- #[cfg_attr(
- feature = "serde",
- serde(default, skip_serializing_if = "Option::is_none")
- )]
- pub end_tzid: Option<String>,
- #[cfg_attr(
- feature = "serde",
- serde(default, skip_serializing_if = "Option::is_none")
- )]
- pub location: Option<RadrootsSocialLocation>,
- #[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>,
- #[cfg_attr(
- feature = "serde",
- serde(default, skip_serializing_if = "Option::is_none")
- )]
- pub participants: Option<Vec<RadrootsCalendarParticipant>>,
+fn validated_title(value: String) -> Result<String, RadrootsCalendarEventError> {
+ if !canonical_calendar_tag_text_is_valid(&value) {
+ return Err(RadrootsCalendarEventError::InvalidTitle);
+ }
+ Ok(value)
+}
+
+fn parse_calendar_decimal(value: &str) -> Option<u64> {
+ if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
+ return None;
+ }
+ value.parse().ok()
+}
+
+pub fn calendar_tag_text_is_valid(value: &str) -> bool {
+ !value.trim().is_empty()
+ && !value.chars().any(char::is_control)
+ && value.len() <= DEFAULT_TAG_ELEMENT_MAX_BYTES
+}
+
+pub fn canonical_calendar_tag_text_is_valid(value: &str) -> bool {
+ calendar_tag_text_is_valid(value) && value.trim() == value
+}
+
+pub fn calendar_geohash_is_valid(value: &str) -> bool {
+ !value.is_empty()
+ && value.len() <= 12
+ && value.bytes().all(|byte| {
+ matches!(
+ byte.to_ascii_lowercase(),
+ b'0'..=b'9'
+ | b'b'
+ | b'c'
+ | b'd'
+ | b'e'
+ | b'f'
+ | b'g'
+ | b'h'
+ | b'j'
+ | b'k'
+ | b'm'
+ | b'n'
+ | b'p'
+ | b'q'
+ | b'r'
+ | b's'
+ | b't'
+ | b'u'
+ | b'v'
+ | b'w'
+ | b'x'
+ | b'y'
+ | b'z'
+ )
+ })
+}
+
+pub fn canonical_calendar_geohash_is_valid(value: &str) -> bool {
+ calendar_geohash_is_valid(value) && value.bytes().all(|byte| !byte.is_ascii_uppercase())
+}
+
+pub fn calendar_relay_url_is_valid(value: &str) -> bool {
+ if value.is_empty()
+ || value
+ .chars()
+ .any(|character| character.is_control() || character.is_whitespace())
+ {
+ return false;
+ }
+ let Some((scheme, remainder)) = value.split_once("://") else {
+ return false;
+ };
+ if !(scheme.eq_ignore_ascii_case("ws") || scheme.eq_ignore_ascii_case("wss")) {
+ return false;
+ }
+ let Ok(parsed) = Url::parse(value) else {
+ return false;
+ };
+ let authority = remainder.split(['/', '?', '#']).next().unwrap_or(remainder);
+ matches!(parsed.scheme(), "ws" | "wss")
+ && parsed.host_str().is_some_and(|host| !host.is_empty())
+ && parsed.username().is_empty()
+ && parsed.password().is_none()
+ && parsed.fragment().is_none()
+ && parsed.port() != Some(0)
+ && !authority.contains('@')
+}
+
+fn validate_calendar_tag_text(
+ value: &str,
+ field: &'static str,
+) -> Result<(), RadrootsCalendarEventError> {
+ if !calendar_tag_text_is_valid(value) {
+ return Err(if value.len() > DEFAULT_TAG_ELEMENT_MAX_BYTES {
+ RadrootsCalendarEventError::TagElementTooLarge {
+ field,
+ max: DEFAULT_TAG_ELEMENT_MAX_BYTES,
+ actual: value.len(),
+ }
+ } else {
+ RadrootsCalendarEventError::InvalidText(field)
+ });
+ }
+ Ok(())
+}
+
+fn validate_canonical_calendar_tag_text(
+ value: &str,
+ field: &'static str,
+) -> Result<(), RadrootsCalendarEventError> {
+ validate_calendar_tag_text(value, field)?;
+ if value.trim() != value {
+ return Err(RadrootsCalendarEventError::InvalidText(field));
+ }
+ Ok(())
+}
+
+fn validate_calendar_content(value: &str) -> Result<(), RadrootsCalendarEventError> {
+ if value.len() > DEFAULT_CONTENT_MAX_BYTES {
+ return Err(RadrootsCalendarEventError::ContentTooLarge {
+ max: DEFAULT_CONTENT_MAX_BYTES,
+ actual: value.len(),
+ });
+ }
+ Ok(())
+}
+
+fn validate_authored_calendar_locations(
+ locations: &[String],
+) -> Result<(), RadrootsCalendarEventError> {
+ for location in locations {
+ validate_canonical_calendar_tag_text(location, "location")?;
+ }
+ Ok(())
+}
+
+fn validate_authored_calendar_requests(
+ requests: &[RadrootsCalendarRequest],
+) -> Result<(), RadrootsCalendarEventError> {
+ if requests.iter().any(|request| !request.is_canonical()) {
+ return Err(RadrootsCalendarEventError::InvalidUrl("calendar request"));
+ }
+ Ok(())
+}
+
+fn validate_inbound_calendar_participants(
+ participants: &[RadrootsCalendarParticipant],
+) -> Result<(), RadrootsCalendarEventError> {
+ for (index, participant) in participants.iter().enumerate() {
+ if RadrootsPublicKey::parse(&participant.pubkey).is_err()
+ || participant
+ .relay
+ .as_deref()
+ .is_some_and(|relay| !calendar_relay_url_is_valid(relay))
+ || participant
+ .role
+ .as_deref()
+ .is_some_and(|role| !calendar_tag_text_is_valid(role))
+ {
+ return Err(RadrootsCalendarEventError::InvalidParticipant { index });
+ }
+ }
+ Ok(())
+}
+
+fn validate_authored_calendar_participants(
+ participants: &[RadrootsCalendarParticipant],
+) -> Result<(), RadrootsCalendarEventError> {
+ if participants.len() > RADROOTS_CALENDAR_MAX_PARTICIPANTS {
+ return Err(RadrootsCalendarEventError::TooManyParticipants {
+ max: RADROOTS_CALENDAR_MAX_PARTICIPANTS,
+ actual: participants.len(),
+ });
+ }
+ validate_inbound_calendar_participants(participants)?;
+ for (index, participant) in participants.iter().enumerate() {
+ let pubkey = RadrootsPublicKey::parse(&participant.pubkey)
+ .map_err(|_| RadrootsCalendarEventError::InvalidParticipant { index })?;
+ if pubkey.as_str() != participant.pubkey
+ || participant
+ .relay
+ .as_deref()
+ .is_some_and(|relay| RadrootsRelayUrl::parse(relay).is_err())
+ || participant
+ .role
+ .as_deref()
+ .is_some_and(|role| !canonical_calendar_tag_text_is_valid(role))
+ {
+ return Err(RadrootsCalendarEventError::InvalidParticipant { index });
+ }
+ }
+ Ok(())
+}
+
+fn validate_authored_calendar_categories(
+ categories: &[String],
+) -> Result<(), RadrootsCalendarEventError> {
+ for category in categories {
+ validate_canonical_calendar_tag_text(category, "category")?;
+ }
+ Ok(())
+}
+
+fn validate_admitted_calendar_common(
+ common: &RadrootsParsedNip52CalendarCommon,
+) -> Result<(), RadrootsCalendarAdmissionError> {
+ let canonical = canonical_calendar_tag_text_is_valid(&common.title)
+ && common
+ .locations
+ .iter()
+ .all(|value| canonical_calendar_tag_text_is_valid(value))
+ && common
+ .summary
+ .as_deref()
+ .is_none_or(canonical_calendar_tag_text_is_valid)
+ && common
+ .geohash
+ .as_deref()
+ .is_none_or(canonical_calendar_geohash_is_valid)
+ && common
+ .categories
+ .iter()
+ .all(|value| canonical_calendar_tag_text_is_valid(value))
+ && common
+ .legacy_name
+ .as_deref()
+ .is_none_or(canonical_calendar_tag_text_is_valid)
+ && common
+ .calendar_requests
+ .iter()
+ .all(RadrootsCalendarRequest::is_canonical);
+ if !canonical {
+ return Err(RadrootsCalendarAdmissionError::NonCanonicalField(
+ "metadata",
+ ));
+ }
+ validate_authored_calendar_participants(&common.participants)
+ .map_err(|_| RadrootsCalendarAdmissionError::NonCanonicalField("participant"))?;
+ Ok(())
+}
+
+fn admitted_blossom_image(
+ common: &RadrootsParsedNip52CalendarCommon,
+) -> Result<Option<RadrootsBlossomBlobUrl>, RadrootsCalendarAdmissionError> {
+ common
+ .image
+ .as_ref()
+ .map(|image| {
+ RadrootsBlossomBlobUrl::parse(image.as_str())
+ .map_err(|_| RadrootsCalendarAdmissionError::NonBlossomImage)
+ })
+ .transpose()
+}
+
+fn admitted_d_tag(
+ common: &RadrootsParsedNip52CalendarCommon,
+) -> Result<RadrootsDTag, RadrootsCalendarAdmissionError> {
+ let d_tag = RadrootsDTag::parse(&common.d_tag)
+ .map_err(|_| RadrootsCalendarAdmissionError::NonCanonicalField("d"))?;
+ if d_tag.as_str() != common.d_tag {
+ return Err(RadrootsCalendarAdmissionError::NonCanonicalField("d"));
+ }
+ Ok(d_tag)
+}
+
+fn authored_date_tags_for_budget(event: &RadrootsAuthoredCalendarDateEvent) -> Vec<Vec<String>> {
+ let mut tags = vec![
+ budget_tag("d", event.d_tag().as_str()),
+ budget_tag("title", event.title()),
+ budget_tag("start", event.start().as_str()),
+ ];
+ if let Some(end) = event.end() {
+ tags.push(budget_tag("end", end.as_str()));
+ }
+ append_authored_common_tags_for_budget(
+ &mut tags,
+ event.locations(),
+ event.geohash(),
+ event.summary(),
+ event.image(),
+ event.participants(),
+ event.categories(),
+ event.references(),
+ event.calendar_requests(),
+ );
+ tags
+}
+
+fn authored_time_tags_for_budget(
+ event: &RadrootsAuthoredCalendarTimeEvent,
+) -> Result<Vec<Vec<String>>, RadrootsCalendarEventError> {
+ let mut tags = vec![
+ budget_tag("d", event.d_tag().as_str()),
+ budget_tag("title", event.title()),
+ budget_tag("start", &event.start().to_string()),
+ ];
+ if let Some(end) = event.end() {
+ tags.push(budget_tag("end", &end.to_string()));
+ }
+ for day in covered_utc_days(event.start(), event.end())? {
+ tags.push(budget_tag("D", &day.to_string()));
+ }
+ if let Some(tzid) = event.start_tzid() {
+ tags.push(budget_tag("start_tzid", tzid.as_str()));
+ }
+ if let Some(tzid) = event.end_tzid() {
+ tags.push(budget_tag("end_tzid", tzid.as_str()));
+ }
+ append_authored_common_tags_for_budget(
+ &mut tags,
+ event.locations(),
+ event.geohash(),
+ event.summary(),
+ event.image(),
+ event.participants(),
+ event.categories(),
+ event.references(),
+ event.calendar_requests(),
+ );
+ Ok(tags)
+}
+
+#[allow(clippy::too_many_arguments)]
+fn append_authored_common_tags_for_budget(
+ tags: &mut Vec<Vec<String>>,
+ locations: &[String],
+ geohash: Option<&str>,
+ summary: Option<&str>,
+ image: Option<&RadrootsAuthoredImage>,
+ participants: Option<&Vec<RadrootsCalendarParticipant>>,
+ categories: &[String],
+ references: &[RadrootsCalendarUri],
+ calendar_requests: &[RadrootsCalendarRequest],
+) {
+ tags.extend(
+ locations
+ .iter()
+ .map(|location| budget_tag("location", location)),
+ );
+ if let Some(geohash) = geohash {
+ tags.push(budget_tag("g", geohash));
+ }
+ if let Some(summary) = summary {
+ tags.push(budget_tag("summary", summary));
+ }
+ if let Some(image) = image {
+ tags.push(budget_tag("image", image.descriptor().url().as_str()));
+ }
+ if let Some(participants) = participants {
+ for participant in participants {
+ let mut tag = vec!["p".to_string(), participant.pubkey.clone()];
+ if let Some(role) = participant.role.as_ref() {
+ tag.push(participant.relay.clone().unwrap_or_default());
+ tag.push(role.clone());
+ } else if let Some(relay) = participant.relay.as_ref() {
+ tag.push(relay.clone());
+ }
+ 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() {
+ 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_authored_calendar_budget(
+ description: Option<&str>,
+ tags: Vec<Vec<String>>,
+) -> Result<(), RadrootsCalendarEventError> {
+ if let Some(description) = description {
+ validate_calendar_content(description)?;
+ }
+ if tags.len() > DEFAULT_TAG_MAX_COUNT {
+ return Err(RadrootsCalendarEventError::TagCountExceeded {
+ max: DEFAULT_TAG_MAX_COUNT,
+ actual: tags.len(),
+ });
+ }
+ let mut total = 0usize;
+ for tag in &tags {
+ for value in tag {
+ if value.len() > DEFAULT_TAG_ELEMENT_MAX_BYTES {
+ return Err(RadrootsCalendarEventError::TagElementTooLarge {
+ field: "tag",
+ max: DEFAULT_TAG_ELEMENT_MAX_BYTES,
+ actual: value.len(),
+ });
+ }
+ total = total.saturating_add(value.len());
+ }
+ }
+ if total > DEFAULT_TAG_TOTAL_MAX_BYTES {
+ return Err(RadrootsCalendarEventError::TagBytesExceeded {
+ max: DEFAULT_TAG_TOTAL_MAX_BYTES,
+ actual: total,
+ });
+ }
+ Ok(())
+}
+
+fn parse_date_digits(bytes: &[u8]) -> Result<u16, RadrootsCalendarEventError> {
+ bytes.iter().try_fold(0_u16, |value, byte| {
+ if byte.is_ascii_digit() {
+ Ok(value * 10 + u16::from(byte - b'0'))
+ } else {
+ Err(RadrootsCalendarEventError::InvalidDate)
+ }
+ })
+}
+
+const fn days_in_month(year: u16, month: u16) -> u16 {
+ match month {
+ 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
+ 4 | 6 | 9 | 11 => 30,
+ 2 if is_leap_year(year) => 29,
+ 2 => 28,
+ _ => 0,
+ }
+}
+
+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(
@@ -165,62 +1792,219 @@ pub struct RadrootsCalendarEventRsvp {
pub participants: Option<Vec<RadrootsCalendarParticipant>>,
}
-#[cfg(all(test, feature = "std", feature = "serde"))]
+#[cfg(test)]
mod tests {
use super::*;
#[test]
- fn date_event_represents_all_day_event_fields() {
- let event = RadrootsCalendarDateEvent {
- d_tag: "market-day".to_string(),
- title: "market day".to_string(),
- start: "2026-06-20".to_string(),
- description: Some("Farm stand pickup window.".to_string()),
- end: None,
- days: Some(vec![RadrootsCalendarDateValue {
- value: "2026-06-20".to_string(),
- }]),
- location: Some(RadrootsSocialLocation {
- name: Some("farm stand".to_string()),
- geohash: Some("c23nb62w20st".to_string()),
- }),
- summary: Some("weekly pickup".to_string()),
- image: None,
- participants: None,
- };
+ fn authored_date_event_validates_each_optional_field_at_construction() {
+ let event = RadrootsAuthoredCalendarDateEvent::new(
+ "market-day",
+ "Market day",
+ RadrootsCalendarDate::parse("2026-06-20").unwrap(),
+ )
+ .unwrap()
+ .with_description("Farm stand pickup window.")
+ .unwrap()
+ .with_locations(vec!["Farm stand".to_string()])
+ .unwrap()
+ .with_geohash("c23nb62w20st")
+ .unwrap()
+ .with_summary("Weekly pickup")
+ .unwrap()
+ .with_categories(vec!["farmers-market".to_string(), "vegetables".to_string()])
+ .unwrap();
+
+ assert_eq!(event.d_tag().as_str(), "market-day");
+ assert_eq!(event.start().as_str(), "2026-06-20");
+ assert_eq!(event.summary(), Some("Weekly pickup"));
+ assert_eq!(event.categories(), ["farmers-market", "vegetables"]);
- assert_eq!(event.d_tag, "market-day");
- assert_eq!(event.start, "2026-06-20");
- assert_eq!(event.days.expect("days")[0].value, "2026-06-20");
+ let error = RadrootsAuthoredCalendarDateEvent::new(
+ "market-day",
+ " Market day ",
+ RadrootsCalendarDate::parse("2026-06-20").unwrap(),
+ )
+ .unwrap_err();
+ assert_eq!(error, RadrootsCalendarEventError::InvalidTitle);
+
+ let error = RadrootsAuthoredCalendarDateEvent::new(
+ "market-day",
+ "Market day",
+ RadrootsCalendarDate::parse("2026-06-20").unwrap(),
+ )
+ .unwrap()
+ .with_geohash("C23NB62W20ST")
+ .unwrap_err();
+ assert_eq!(error, RadrootsCalendarEventError::InvalidGeohash);
}
#[test]
- fn time_event_represents_timestamped_event_fields() {
- let event = RadrootsCalendarTimeEvent {
- d_tag: "wash-pack".to_string(),
- title: "wash pack shift".to_string(),
- start: 1_781_895_600,
- dates: vec![RadrootsCalendarDateValue {
- value: "2026-06-20".to_string(),
- }],
- description: Some("Pack CSA shares before pickup.".to_string()),
- end: Some(1_781_899_200),
- start_tzid: Some("America/Vancouver".to_string()),
- end_tzid: Some("America/Vancouver".to_string()),
- location: None,
- summary: None,
- image: None,
- participants: Some(vec![RadrootsCalendarParticipant {
- pubkey: "a".repeat(64),
- relay: None,
- role: Some("host".to_string()),
- }]),
- };
+ fn authored_time_event_uses_exact_iana_ids_and_end_timezone_fallback() {
+ assert_eq!(jiff_tzdb::VERSION, Some("2026c"));
+
+ let event =
+ RadrootsAuthoredCalendarTimeEvent::new("wash-pack", "Wash pack shift", 1_781_895_600)
+ .unwrap()
+ .with_end(1_781_899_200)
+ .unwrap()
+ .with_description("Pack CSA shares before pickup.")
+ .unwrap()
+ .with_start_tzid("America/Vancouver")
+ .unwrap()
+ .with_participants(vec![RadrootsCalendarParticipant {
+ pubkey: "a".repeat(64),
+ relay: None,
+ role: Some("host".to_string()),
+ }])
+ .unwrap();
+
+ assert_eq!(event.start(), 1_781_895_600);
+ assert_eq!(event.end(), Some(1_781_899_200));
+ assert_eq!(
+ event.start_tzid().map(RadrootsIanaTimeZoneId::as_str),
+ Some("America/Vancouver")
+ );
+ assert_eq!(event.end_tzid(), None);
+ assert_eq!(
+ event
+ .effective_end_tzid()
+ .map(RadrootsIanaTimeZoneId::as_str),
+ Some("America/Vancouver")
+ );
+ assert_eq!(event.participants().map(Vec::len), Some(1));
+
+ for invalid in ["america/vancouver", "America/Imaginary", " UTC ", ""] {
+ assert_eq!(
+ RadrootsIanaTimeZoneId::parse(invalid),
+ Err(RadrootsCalendarEventError::InvalidTimeZone),
+ "{invalid}"
+ );
+ }
+ }
+
+ #[test]
+ fn calendar_dates_validate_gregorian_semantics_and_strict_ranges() {
+ for valid in ["0001-01-01", "2000-02-29", "2024-02-29", "9999-12-31"] {
+ assert_eq!(RadrootsCalendarDate::parse(valid).unwrap().as_str(), valid);
+ }
+ for invalid in [
+ "0000-01-01",
+ "1900-02-29",
+ "2026-02-29",
+ "2026-02-30",
+ "2026-13-01",
+ "2026-00-01",
+ "2026-06-00",
+ "2026-6-20",
+ "+2026-06-20",
+ ] {
+ assert_eq!(
+ RadrootsCalendarDate::parse(invalid),
+ Err(RadrootsCalendarEventError::InvalidDate),
+ "{invalid}"
+ );
+ }
+
+ let start = RadrootsCalendarDate::parse("2026-06-20").unwrap();
+ let event =
+ RadrootsAuthoredCalendarDateEvent::new("market-day", "Market", start.clone()).unwrap();
+ assert_eq!(
+ event
+ .clone()
+ .with_end(RadrootsCalendarDate::parse("2026-06-20").unwrap()),
+ Err(RadrootsCalendarEventError::InvalidRange)
+ );
+ assert_eq!(
+ event.with_end(RadrootsCalendarDate::parse("2026-06-19").unwrap()),
+ Err(RadrootsCalendarEventError::InvalidRange)
+ );
+ }
+
+ #[test]
+ fn covered_utc_days_are_exclusive_end_and_bounded() {
+ assert_eq!(
+ covered_utc_days(86_399, None).unwrap().collect::<Vec<_>>(),
+ vec![0]
+ );
+ assert_eq!(
+ covered_utc_days(86_399, Some(86_401))
+ .unwrap()
+ .collect::<Vec<_>>(),
+ vec![0, 1]
+ );
+ assert_eq!(
+ covered_utc_days(0, Some(86_400))
+ .unwrap()
+ .collect::<Vec<_>>(),
+ vec![0]
+ );
+ assert_eq!(
+ covered_utc_days(10, Some(10)),
+ Err(RadrootsCalendarEventError::InvalidRange)
+ );
+ assert_eq!(
+ covered_utc_days(10, Some(9)),
+ Err(RadrootsCalendarEventError::InvalidRange)
+ );
+ assert_eq!(
+ covered_utc_days(0, Some(367 * RADROOTS_CALENDAR_SECONDS_PER_DAY)),
+ Err(RadrootsCalendarEventError::CoveredDayLimitExceeded {
+ max: RADROOTS_CALENDAR_MAX_COVERED_UTC_DAYS,
+ actual: 367,
+ })
+ );
+ }
- assert_eq!(event.start, 1_781_895_600);
- assert_eq!(event.end, Some(1_781_899_200));
- assert_eq!(event.start_tzid.as_deref(), Some("America/Vancouver"));
- assert_eq!(event.participants.expect("participants").len(), 1);
+ #[test]
+ fn baseline_wire_types_preserve_noncanonical_but_structural_values() {
+ let uri = RadrootsCalendarUri::parse("ipfs://bafybeigdyrzt/calendar.webp").unwrap();
+ assert_eq!(uri.as_str(), "ipfs://bafybeigdyrzt/calendar.webp");
+
+ let observed = RadrootsObservedUtcDay::parse("00042").unwrap();
+ assert_eq!(observed.wire_value(), "00042");
+ assert_eq!(observed.index(), 42);
+ assert!(!observed.is_canonical());
+
+ assert_eq!(
+ RadrootsCalendarUri::parse("not an absolute URI"),
+ Err(RadrootsCalendarEventError::InvalidUrl("URI"))
+ );
+ assert_eq!(
+ RadrootsObservedUtcDay::parse("+42"),
+ Err(RadrootsCalendarEventError::InvalidText("D"))
+ );
+ }
+
+ #[test]
+ fn authored_models_enforce_content_and_participant_limits() {
+ let event = RadrootsAuthoredCalendarDateEvent::new(
+ "market-day",
+ "Market day",
+ RadrootsCalendarDate::parse("2026-06-20").unwrap(),
+ )
+ .unwrap();
+ let oversized = "x".repeat(DEFAULT_CONTENT_MAX_BYTES + 1);
+ assert_eq!(
+ event.clone().with_description(oversized),
+ Err(RadrootsCalendarEventError::ContentTooLarge {
+ max: DEFAULT_CONTENT_MAX_BYTES,
+ actual: DEFAULT_CONTENT_MAX_BYTES + 1,
+ })
+ );
+
+ let participant = RadrootsCalendarParticipant {
+ pubkey: "a".repeat(64),
+ relay: None,
+ role: None,
+ };
+ assert_eq!(
+ event.with_participants(vec![participant; RADROOTS_CALENDAR_MAX_PARTICIPANTS + 1]),
+ Err(RadrootsCalendarEventError::TooManyParticipants {
+ max: RADROOTS_CALENDAR_MAX_PARTICIPANTS,
+ actual: RADROOTS_CALENDAR_MAX_PARTICIPANTS + 1,
+ })
+ );
}
#[test]
diff --git a/crates/event/src/contract.rs b/crates/event/src/contract.rs
@@ -5,12 +5,18 @@ use alloc::{borrow::ToOwned, string::String, vec::Vec};
use crate::{
RadrootsEventEnvelope,
+ calendar::{
+ RADROOTS_CALENDAR_MAX_PARTICIPANTS, RadrootsCalendarDate, RadrootsCalendarUri,
+ RadrootsIanaTimeZoneId, canonical_calendar_geohash_is_valid,
+ canonical_calendar_tag_text_is_valid, covered_utc_days,
+ },
ids::{
RadrootsAddressableCoordinate, RadrootsDTag, RadrootsEventId, RadrootsPublicKey,
relay_url_is_valid,
},
kinds::*,
};
+use radroots_blossom::RadrootsBlossomBlobUrl;
pub const RADROOTS_EVENT_CONTRACT_REGISTRY_VERSION: u32 = 1;
@@ -113,6 +119,9 @@ pub enum RadrootsTagCardinality {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RadrootsTagSemantic {
AddressableCoordinate,
+ CalendarInclusionRequest,
+ CalendarEnd,
+ CalendarStart,
Category,
Citation,
Contract,
@@ -127,10 +136,12 @@ pub enum RadrootsTagSemantic {
ListingAddress,
ListingSnapshot,
Location,
+ Participant,
PreviousEvent,
Price,
PublishedAt,
Relay,
+ Reference,
ReviewTarget,
RootEvent,
ServiceInput,
@@ -140,24 +151,30 @@ pub enum RadrootsTagSemantic {
Summary,
Title,
Topic,
+ TimeZone,
Url,
+ UtcDayCoverage,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RadrootsTagValueType {
AddressableCoordinate,
+ CalendarDate,
ContractId,
DTag,
EventId,
EventPointer,
Geohash,
+ IanaTimeZoneId,
Kind,
PublicKey,
RelayUrl,
Sha256,
Text,
UnixTimestamp,
+ Uri,
Url,
+ UtcDayIndex,
Uuid,
}
@@ -427,6 +444,13 @@ const TAG_P_MANY: RadrootsTagContract = tag(
RadrootsTagValueType::PublicKey,
true,
);
+const TAG_CALENDAR_PARTICIPANT: RadrootsTagContract = tag(
+ "p",
+ RadrootsTagCardinality::OptionalMany,
+ RadrootsTagSemantic::Participant,
+ RadrootsTagValueType::PublicKey,
+ true,
+);
const TAG_A_ADDRESS_REQUIRED: RadrootsTagContract = tag(
"a",
RadrootsTagCardinality::RequiredOne,
@@ -448,6 +472,13 @@ const TAG_A_MANY: RadrootsTagContract = tag(
RadrootsTagValueType::AddressableCoordinate,
true,
);
+const TAG_CALENDAR_INCLUSION_REQUEST: RadrootsTagContract = tag(
+ "a",
+ RadrootsTagCardinality::OptionalMany,
+ RadrootsTagSemantic::CalendarInclusionRequest,
+ RadrootsTagValueType::AddressableCoordinate,
+ true,
+);
const TAG_E_ROOT: RadrootsTagContract = tag(
"e",
RadrootsTagCardinality::RequiredOne,
@@ -504,6 +535,69 @@ const TAG_TITLE: RadrootsTagContract = tag(
RadrootsTagValueType::Text,
false,
);
+const TAG_CALENDAR_TITLE: RadrootsTagContract = tag(
+ "title",
+ RadrootsTagCardinality::RequiredOne,
+ RadrootsTagSemantic::Title,
+ RadrootsTagValueType::Text,
+ false,
+);
+const TAG_CALENDAR_LEGACY_NAME: RadrootsTagContract = tag(
+ "name",
+ RadrootsTagCardinality::OptionalOne,
+ RadrootsTagSemantic::Title,
+ RadrootsTagValueType::Text,
+ false,
+);
+const TAG_CALENDAR_DATE_START: RadrootsTagContract = tag(
+ "start",
+ RadrootsTagCardinality::RequiredOne,
+ RadrootsTagSemantic::CalendarStart,
+ RadrootsTagValueType::CalendarDate,
+ false,
+);
+const TAG_CALENDAR_DATE_END: RadrootsTagContract = tag(
+ "end",
+ RadrootsTagCardinality::OptionalOne,
+ RadrootsTagSemantic::CalendarEnd,
+ RadrootsTagValueType::CalendarDate,
+ false,
+);
+const TAG_CALENDAR_TIME_START: RadrootsTagContract = tag(
+ "start",
+ RadrootsTagCardinality::RequiredOne,
+ RadrootsTagSemantic::CalendarStart,
+ RadrootsTagValueType::UnixTimestamp,
+ false,
+);
+const TAG_CALENDAR_TIME_END: RadrootsTagContract = tag(
+ "end",
+ RadrootsTagCardinality::OptionalOne,
+ RadrootsTagSemantic::CalendarEnd,
+ RadrootsTagValueType::UnixTimestamp,
+ false,
+);
+const TAG_CALENDAR_COVERED_UTC_DAY: RadrootsTagContract = tag(
+ "D",
+ RadrootsTagCardinality::RequiredMany,
+ RadrootsTagSemantic::UtcDayCoverage,
+ RadrootsTagValueType::UtcDayIndex,
+ true,
+);
+const TAG_CALENDAR_START_TZID: RadrootsTagContract = tag(
+ "start_tzid",
+ RadrootsTagCardinality::OptionalOne,
+ RadrootsTagSemantic::TimeZone,
+ RadrootsTagValueType::IanaTimeZoneId,
+ false,
+);
+const TAG_CALENDAR_END_TZID: RadrootsTagContract = tag(
+ "end_tzid",
+ RadrootsTagCardinality::OptionalOne,
+ RadrootsTagSemantic::TimeZone,
+ RadrootsTagValueType::IanaTimeZoneId,
+ false,
+);
const TAG_SUMMARY: RadrootsTagContract = tag(
"summary",
RadrootsTagCardinality::OptionalOne,
@@ -525,6 +619,13 @@ const TAG_LOCATION: RadrootsTagContract = tag(
RadrootsTagValueType::Text,
false,
);
+const TAG_CALENDAR_LOCATION: RadrootsTagContract = tag(
+ "location",
+ RadrootsTagCardinality::OptionalMany,
+ RadrootsTagSemantic::Location,
+ RadrootsTagValueType::Text,
+ false,
+);
const TAG_PRICE: RadrootsTagContract = tag(
"price",
RadrootsTagCardinality::OptionalMany,
@@ -553,6 +654,13 @@ const TAG_IMAGE: RadrootsTagContract = tag(
RadrootsTagValueType::Url,
false,
);
+const TAG_CALENDAR_IMAGE: RadrootsTagContract = tag(
+ "image",
+ RadrootsTagCardinality::OptionalOne,
+ RadrootsTagSemantic::Image,
+ RadrootsTagValueType::Url,
+ false,
+);
const TAG_SERVICE_OUTPUT: RadrootsTagContract = tag(
"output",
RadrootsTagCardinality::RequiredOne,
@@ -581,6 +689,13 @@ const TAG_TOPIC_MANY: RadrootsTagContract = tag(
RadrootsTagValueType::Text,
true,
);
+const TAG_CALENDAR_REFERENCE: RadrootsTagContract = tag(
+ "r",
+ RadrootsTagCardinality::OptionalMany,
+ RadrootsTagSemantic::Reference,
+ RadrootsTagValueType::Uri,
+ true,
+);
const TAG_GEOHASH_OPTIONAL: RadrootsTagContract = tag(
"g",
RadrootsTagCardinality::OptionalOne,
@@ -645,6 +760,39 @@ const WIKI_MERGE_REQUEST_TAGS: &[RadrootsTagContract] = &[
TAG_E_SOURCE_VERSION,
TAG_E_BASE_VERSION,
];
+const CALENDAR_DATE_EVENT_TAGS: &[RadrootsTagContract] = &[
+ TAG_D,
+ TAG_CALENDAR_TITLE,
+ TAG_CALENDAR_LEGACY_NAME,
+ TAG_CALENDAR_DATE_START,
+ TAG_CALENDAR_DATE_END,
+ TAG_CALENDAR_LOCATION,
+ TAG_GEOHASH_OPTIONAL,
+ TAG_SUMMARY,
+ TAG_CALENDAR_IMAGE,
+ TAG_CALENDAR_PARTICIPANT,
+ TAG_TOPIC_MANY,
+ TAG_CALENDAR_REFERENCE,
+ TAG_CALENDAR_INCLUSION_REQUEST,
+];
+const CALENDAR_TIME_EVENT_TAGS: &[RadrootsTagContract] = &[
+ TAG_D,
+ TAG_CALENDAR_TITLE,
+ TAG_CALENDAR_LEGACY_NAME,
+ TAG_CALENDAR_TIME_START,
+ TAG_CALENDAR_TIME_END,
+ TAG_CALENDAR_COVERED_UTC_DAY,
+ TAG_CALENDAR_START_TZID,
+ TAG_CALENDAR_END_TZID,
+ TAG_CALENDAR_LOCATION,
+ TAG_GEOHASH_OPTIONAL,
+ TAG_SUMMARY,
+ TAG_CALENDAR_IMAGE,
+ TAG_CALENDAR_PARTICIPANT,
+ TAG_TOPIC_MANY,
+ TAG_CALENDAR_REFERENCE,
+ TAG_CALENDAR_INCLUSION_REQUEST,
+];
const CALENDAR_EVENT_TAGS: &[RadrootsTagContract] =
&[TAG_D, TAG_TITLE, TAG_LOCATION, TAG_PUBLISHED_AT];
const FARM_TAGS: &[RadrootsTagContract] = &[TAG_D, TAG_TITLE, TAG_LOCATION, TAG_IMAGE];
@@ -2415,26 +2563,26 @@ static ALL_EVENT_CONTRACTS: &[RadrootsEventContract] = &[
"radroots.calendar.date_event.v1",
KIND_CALENDAR_DATE_EVENT,
"Calendar Date Event",
- "RadrootsCalendarDateEvent",
+ "RadrootsAdmittedCalendarDateEvent",
RadrootsEventClass::Addressable,
RadrootsEventPrivacy::Public,
RadrootsActorRole::Any,
- RadrootsContentSchema::JsonObject,
+ RadrootsContentSchema::PlainText,
RadrootsEventDiscriminator::KindOnly,
- CALENDAR_EVENT_TAGS,
+ CALENDAR_DATE_EVENT_TAGS,
CALENDAR_REDUCERS
),
event_contract!(
"radroots.calendar.time_event.v1",
KIND_CALENDAR_TIME_EVENT,
"Calendar Time Event",
- "RadrootsCalendarTimeEvent",
+ "RadrootsAdmittedCalendarTimeEvent",
RadrootsEventClass::Addressable,
RadrootsEventPrivacy::Public,
RadrootsActorRole::Any,
- RadrootsContentSchema::JsonObject,
+ RadrootsContentSchema::PlainText,
RadrootsEventDiscriminator::KindOnly,
- CALENDAR_EVENT_TAGS,
+ CALENDAR_TIME_EVENT_TAGS,
CALENDAR_REDUCERS
),
event_contract!(
@@ -3070,6 +3218,7 @@ pub fn validate_event_contract_parts(
validate_content_shape_parts(content, contract)?;
validate_contract_tags_parts(tags, contract)?;
validate_discriminator_parts(content, contract)?;
+ validate_custom_calendar_contract_parts(tags, contract)?;
validate_custom_knowledge_contract_parts(content, contract)?;
Ok(())
}
@@ -3243,6 +3392,7 @@ fn tag_value_is_valid(tag: &[String], value_type: RadrootsTagValueType) -> bool
RadrootsTagValueType::AddressableCoordinate => {
RadrootsAddressableCoordinate::parse(value).is_ok()
}
+ RadrootsTagValueType::CalendarDate => RadrootsCalendarDate::parse(value).is_ok(),
RadrootsTagValueType::ContractId => all_event_contracts()
.iter()
.any(|contract| contract.id == value),
@@ -3252,12 +3402,15 @@ fn tag_value_is_valid(tag: &[String], value_type: RadrootsTagValueType) -> bool
}
RadrootsTagValueType::EventPointer => event_pointer_tag_is_valid(tag),
RadrootsTagValueType::Geohash => geohash_is_valid(value),
+ RadrootsTagValueType::IanaTimeZoneId => RadrootsIanaTimeZoneId::parse(value).is_ok(),
RadrootsTagValueType::Kind => value.parse::<u32>().is_ok(),
RadrootsTagValueType::PublicKey => RadrootsPublicKey::parse(value).is_ok(),
RadrootsTagValueType::RelayUrl => relay_url_is_valid(value),
RadrootsTagValueType::Text => visible_text_is_valid(value),
RadrootsTagValueType::UnixTimestamp => value.parse::<u64>().is_ok(),
+ RadrootsTagValueType::Uri => RadrootsCalendarUri::parse(value).is_ok(),
RadrootsTagValueType::Url => url_is_valid(value),
+ RadrootsTagValueType::UtcDayIndex => canonical_u64(value).is_some(),
RadrootsTagValueType::Uuid => uuid_is_valid(value),
}
}
@@ -3285,8 +3438,10 @@ fn visible_text_is_valid(value: &str) -> bool {
}
fn url_is_valid(value: &str) -> bool {
- (value.starts_with("http://") || value.starts_with("https://"))
- && value.len() > "http://".len()
+ value
+ .strip_prefix("https://")
+ .or_else(|| value.strip_prefix("http://"))
+ .is_some_and(|remainder| !remainder.is_empty())
&& value.trim() == value
&& !value.chars().any(char::is_control)
}
@@ -3313,22 +3468,381 @@ fn uuid_is_valid(value: &str) -> bool {
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::ContractId => "contract_id",
RadrootsTagValueType::DTag => "d_tag",
RadrootsTagValueType::EventId => "event_id",
RadrootsTagValueType::EventPointer => "event_pointer",
RadrootsTagValueType::Geohash => "geohash",
+ RadrootsTagValueType::IanaTimeZoneId => "canonical_iana_time_zone_id",
RadrootsTagValueType::Kind => "kind",
RadrootsTagValueType::PublicKey => "public_key",
RadrootsTagValueType::RelayUrl => "relay_url",
RadrootsTagValueType::Sha256 => "sha256",
RadrootsTagValueType::Text => "text",
RadrootsTagValueType::UnixTimestamp => "unix_timestamp",
+ RadrootsTagValueType::Uri => "absolute_uri",
RadrootsTagValueType::Url => "url",
+ RadrootsTagValueType::UtcDayIndex => "canonical_decimal_utc_day_index",
RadrootsTagValueType::Uuid => "uuid",
}
}
+fn canonical_u64(value: &str) -> Option<u64> {
+ if value.is_empty()
+ || (value.len() > 1 && value.starts_with('0'))
+ || !value.bytes().all(|byte| byte.is_ascii_digit())
+ {
+ return None;
+ }
+ value.parse().ok()
+}
+
+fn validate_custom_calendar_contract_parts(
+ tags: &[Vec<String>],
+ contract: &RadrootsEventContract,
+) -> Result<(), RadrootsContractValidationError> {
+ 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),
+ _ => Ok(()),
+ }
+}
+
+fn validate_calendar_date_contract(
+ tags: &[Vec<String>],
+ contract: &RadrootsEventContract,
+) -> Result<(), RadrootsContractValidationError> {
+ validate_exact_calendar_tags(
+ tags,
+ contract,
+ &[
+ "d", "title", "start", "end", "location", "g", "summary", "image", "t", "r", "name",
+ ],
+ )?;
+ validate_calendar_participant_tags(tags, contract)?;
+ validate_calendar_inclusion_request_tags(tags, contract)?;
+ validate_canonical_calendar_common_tags(tags, contract)?;
+
+ if let Some(tag) = tags
+ .iter()
+ .find(|tag| tag.first().map(String::as_str) == Some("D"))
+ {
+ return Err(calendar_tag_mismatch(
+ contract,
+ "D",
+ "forbidden_on_calendar_date_event",
+ tag.get(1).cloned(),
+ ));
+ }
+
+ let start = calendar_date_tag(tags, contract, "start")?;
+ if let Some(end) = optional_calendar_date_tag(tags, contract, "end")?
+ && end <= start
+ {
+ return Err(calendar_tag_mismatch(
+ contract,
+ "end",
+ "gregorian_date_later_than_start",
+ Some(end.as_str().to_owned()),
+ ));
+ }
+ Ok(())
+}
+
+fn validate_calendar_time_contract(
+ tags: &[Vec<String>],
+ contract: &RadrootsEventContract,
+) -> Result<(), RadrootsContractValidationError> {
+ validate_exact_calendar_tags(
+ tags,
+ contract,
+ &[
+ "d",
+ "title",
+ "start",
+ "end",
+ "start_tzid",
+ "end_tzid",
+ "location",
+ "g",
+ "summary",
+ "image",
+ "D",
+ "t",
+ "r",
+ "name",
+ ],
+ )?;
+ validate_calendar_participant_tags(tags, contract)?;
+ validate_calendar_inclusion_request_tags(tags, contract)?;
+ validate_canonical_calendar_common_tags(tags, contract)?;
+
+ let start = canonical_calendar_u64_tag(tags, contract, "start")?;
+ let end = optional_canonical_calendar_u64_tag(tags, contract, "end")?;
+ if end.is_some_and(|end| end <= start) {
+ return Err(calendar_tag_mismatch(
+ contract,
+ "end",
+ "canonical_unix_seconds_later_than_start",
+ tag_value(tags, "end").map(ToOwned::to_owned),
+ ));
+ }
+
+ let expected_days = covered_utc_days(start, end).map_err(|_| {
+ calendar_tag_mismatch(
+ contract,
+ "D",
+ "complete_ascending_utc_day_coverage_with_maximum_366_days",
+ None,
+ )
+ })?;
+ let mut expected_days = expected_days.into_iter();
+ let mut actual_days = tags
+ .iter()
+ .filter(|tag| tag.first().map(String::as_str) == Some("D"));
+ loop {
+ match (expected_days.next(), actual_days.next()) {
+ (Some(expected), Some(actual))
+ if actual.get(1).and_then(|value| canonical_u64(value)) == Some(expected) => {}
+ (None, None) => break,
+ (_, actual) => {
+ return Err(calendar_tag_mismatch(
+ contract,
+ "D",
+ "complete_ascending_utc_day_coverage",
+ actual.and_then(|tag| tag.get(1)).cloned(),
+ ));
+ }
+ }
+ }
+ Ok(())
+}
+
+fn validate_exact_calendar_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.len() != 2 {
+ return Err(calendar_tag_mismatch(
+ contract,
+ name,
+ "exact_two_element_tag",
+ tag.get(1).cloned(),
+ ));
+ }
+ }
+ }
+ Ok(())
+}
+
+fn validate_calendar_participant_tags(
+ tags: &[Vec<String>],
+ contract: &RadrootsEventContract,
+) -> Result<(), RadrootsContractValidationError> {
+ if tag_count(tags, "p") > RADROOTS_CALENDAR_MAX_PARTICIPANTS {
+ return Err(calendar_tag_mismatch(
+ contract,
+ "p",
+ "bounded_participant_count",
+ None,
+ ));
+ }
+ for tag in tags
+ .iter()
+ .filter(|tag| tag.first().map(String::as_str) == Some("p"))
+ {
+ let pubkey_is_canonical = tag.get(1).is_some_and(|value| {
+ RadrootsPublicKey::parse(value).is_ok_and(|pubkey| pubkey.as_str() == value.as_str())
+ });
+ let relay_is_valid = tag
+ .get(2)
+ .map(|relay| relay.is_empty() || relay_url_is_valid(relay))
+ .unwrap_or(true);
+ let role_is_valid = tag
+ .get(3)
+ .map(|role| canonical_calendar_tag_text_is_valid(role))
+ .unwrap_or(true);
+ let placeholder_is_canonical = !(tag.len() == 3 && tag[2].is_empty());
+ if !(2..=4).contains(&tag.len())
+ || !pubkey_is_canonical
+ || !relay_is_valid
+ || !role_is_valid
+ || !placeholder_is_canonical
+ {
+ return Err(calendar_tag_mismatch(
+ contract,
+ "p",
+ "participant_pubkey_with_optional_relay_and_role",
+ tag.get(1).cloned(),
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn validate_calendar_inclusion_request_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_calendar = tag
+ .get(1)
+ .is_some_and(|value| canonical_calendar_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_calendar || !relay_is_valid {
+ return Err(calendar_tag_mismatch(
+ contract,
+ "a",
+ "kind_31924_coordinate_with_optional_relay",
+ tag.get(1).cloned(),
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn canonical_calendar_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;
+ };
+ kind == "31924" && pubkey == parts.pubkey.as_str() && d_tag == parts.d_tag.as_str()
+}
+
+fn validate_canonical_calendar_common_tags(
+ tags: &[Vec<String>],
+ contract: &RadrootsEventContract,
+) -> Result<(), RadrootsContractValidationError> {
+ for name in ["title", "location", "summary", "t", "name"] {
+ 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(),
+ ));
+ }
+ }
+ }
+ if let Some(geohash) = tag_value(tags, "g")
+ && !canonical_calendar_geohash_is_valid(geohash)
+ {
+ return Err(calendar_tag_mismatch(
+ contract,
+ "g",
+ "canonical_lowercase_geohash",
+ Some(geohash.to_owned()),
+ ));
+ }
+ 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 calendar_date_tag(
+ tags: &[Vec<String>],
+ contract: &RadrootsEventContract,
+ name: &'static str,
+) -> Result<RadrootsCalendarDate, RadrootsContractValidationError> {
+ let value = tag_value(tags, name).ok_or(RadrootsContractValidationError::MissingTag {
+ contract_id: contract.id,
+ name,
+ })?;
+ RadrootsCalendarDate::parse(value).map_err(|_| {
+ calendar_tag_mismatch(
+ contract,
+ name,
+ "calendar_date_yyyy_mm_dd",
+ Some(value.to_owned()),
+ )
+ })
+}
+
+fn optional_calendar_date_tag(
+ tags: &[Vec<String>],
+ contract: &RadrootsEventContract,
+ name: &'static str,
+) -> Result<Option<RadrootsCalendarDate>, RadrootsContractValidationError> {
+ tag_value(tags, name)
+ .map(|_| calendar_date_tag(tags, contract, name))
+ .transpose()
+}
+
+fn canonical_calendar_u64_tag(
+ tags: &[Vec<String>],
+ contract: &RadrootsEventContract,
+ name: &'static str,
+) -> Result<u64, RadrootsContractValidationError> {
+ let value = tag_value(tags, name).ok_or(RadrootsContractValidationError::MissingTag {
+ contract_id: contract.id,
+ name,
+ })?;
+ canonical_u64(value).ok_or_else(|| {
+ calendar_tag_mismatch(
+ contract,
+ name,
+ "canonical_decimal_u64",
+ Some(value.to_owned()),
+ )
+ })
+}
+
+fn optional_canonical_calendar_u64_tag(
+ tags: &[Vec<String>],
+ contract: &RadrootsEventContract,
+ name: &'static str,
+) -> Result<Option<u64>, RadrootsContractValidationError> {
+ tag_value(tags, name)
+ .map(|_| canonical_calendar_u64_tag(tags, contract, name))
+ .transpose()
+}
+
+fn calendar_tag_mismatch(
+ contract: &RadrootsEventContract,
+ name: &'static str,
+ expected: &'static str,
+ actual: Option<String>,
+) -> RadrootsContractValidationError {
+ RadrootsContractValidationError::TagValueMismatch {
+ contract_id: contract.id,
+ name,
+ expected: expected.to_owned(),
+ actual,
+ }
+}
+
fn validate_custom_knowledge_contract_parts(
content: &str,
contract: &RadrootsEventContract,
@@ -3725,6 +4239,161 @@ mod tests {
}
#[test]
+ fn calendar_contracts_expose_current_nip52_types_content_and_tags() {
+ let date = event_contract("radroots.calendar.date_event.v1").expect("calendar date");
+ assert_eq!(date.payload_type, "RadrootsAdmittedCalendarDateEvent");
+ assert_eq!(date.content_schema, RadrootsContentSchema::PlainText);
+ assert!(date.tags.iter().any(|tag| {
+ tag.name == "title" && tag.cardinality == RadrootsTagCardinality::RequiredOne
+ }));
+ assert!(date.tags.iter().any(|tag| {
+ tag.name == "name" && tag.cardinality == RadrootsTagCardinality::OptionalOne
+ }));
+ assert!(
+ date.tags
+ .iter()
+ .any(|tag| tag.name == "start"
+ && tag.value_type == RadrootsTagValueType::CalendarDate)
+ );
+ assert!(!date.tags.iter().any(|tag| tag.name == "D"));
+
+ let time = event_contract("radroots.calendar.time_event.v1").expect("calendar time");
+ assert_eq!(time.payload_type, "RadrootsAdmittedCalendarTimeEvent");
+ assert_eq!(time.content_schema, RadrootsContentSchema::PlainText);
+ assert!(time.tags.iter().any(|tag| {
+ tag.name == "D"
+ && tag.cardinality == RadrootsTagCardinality::RequiredMany
+ && tag.value_type == RadrootsTagValueType::UtcDayIndex
+ }));
+ assert!(time.tags.iter().any(|tag| {
+ tag.name == "name" && tag.cardinality == RadrootsTagCardinality::OptionalOne
+ }));
+ }
+
+ #[test]
+ fn calendar_date_contract_validates_gregorian_exclusive_ranges_without_day_tags() {
+ let valid = vec![
+ owned_tag(&["d", "market-day"]),
+ owned_tag(&["title", "Market day"]),
+ owned_tag(&["start", "2026-06-20"]),
+ owned_tag(&["end", "2026-06-21"]),
+ owned_tag(&["location", "Moss Street Market"]),
+ owned_tag(&["location", "Victoria, BC"]),
+ ];
+ assert_eq!(
+ validate_event_contract_parts(
+ KIND_CALENDAR_DATE_EVENT,
+ &valid,
+ "Farm stand pickup window.",
+ "radroots.calendar.date_event.v1",
+ ),
+ Ok(())
+ );
+
+ let duplicate_legacy_name = vec![
+ owned_tag(&["d", "market-day"]),
+ owned_tag(&["title", "Market day"]),
+ owned_tag(&["name", "Market day"]),
+ owned_tag(&["name", "Deprecated duplicate"]),
+ owned_tag(&["start", "2026-06-20"]),
+ ];
+ assert!(matches!(
+ validate_event_contract_parts(
+ KIND_CALENDAR_DATE_EVENT,
+ &duplicate_legacy_name,
+ "description",
+ "radroots.calendar.date_event.v1",
+ ),
+ Err(RadrootsContractValidationError::TagCardinalityMismatch { name: "name", .. })
+ ));
+
+ for invalid in [
+ vec![
+ owned_tag(&["d", "market-day"]),
+ owned_tag(&["title", "Market day"]),
+ owned_tag(&["start", "2026-02-29"]),
+ ],
+ vec![
+ owned_tag(&["d", "market-day"]),
+ owned_tag(&["title", "Market day"]),
+ owned_tag(&["start", "2026-06-20"]),
+ owned_tag(&["end", "2026-06-20"]),
+ ],
+ vec![
+ owned_tag(&["d", "market-day"]),
+ owned_tag(&["title", "Market day"]),
+ owned_tag(&["start", "2026-06-20"]),
+ owned_tag(&["D", "20624"]),
+ ],
+ ] {
+ assert!(matches!(
+ validate_event_contract_parts(
+ KIND_CALENDAR_DATE_EVENT,
+ &invalid,
+ "description",
+ "radroots.calendar.date_event.v1",
+ ),
+ Err(RadrootsContractValidationError::TagValueMismatch { .. })
+ ));
+ }
+ }
+
+ #[test]
+ fn calendar_time_contract_requires_exact_derived_bounded_day_coverage() {
+ let valid = vec![
+ owned_tag(&["d", "wash-pack"]),
+ owned_tag(&["title", "Wash and pack"]),
+ owned_tag(&["start", "86399"]),
+ owned_tag(&["end", "86401"]),
+ owned_tag(&["D", "0"]),
+ owned_tag(&["D", "1"]),
+ ];
+ assert_eq!(
+ validate_event_contract_parts(
+ KIND_CALENDAR_TIME_EVENT,
+ &valid,
+ "Pack CSA shares.",
+ "radroots.calendar.time_event.v1",
+ ),
+ Ok(())
+ );
+
+ for invalid in [
+ vec![
+ owned_tag(&["d", "wash-pack"]),
+ owned_tag(&["title", "Wash and pack"]),
+ owned_tag(&["start", "086399"]),
+ owned_tag(&["D", "0"]),
+ ],
+ vec![
+ owned_tag(&["d", "wash-pack"]),
+ owned_tag(&["title", "Wash and pack"]),
+ owned_tag(&["start", "86399"]),
+ owned_tag(&["end", "86401"]),
+ owned_tag(&["D", "1"]),
+ owned_tag(&["D", "0"]),
+ ],
+ vec![
+ owned_tag(&["d", "wash-pack"]),
+ owned_tag(&["title", "Wash and pack"]),
+ owned_tag(&["start", "0"]),
+ owned_tag(&["end", "31708800"]),
+ owned_tag(&["D", "0"]),
+ ],
+ ] {
+ assert!(matches!(
+ validate_event_contract_parts(
+ KIND_CALENDAR_TIME_EVENT,
+ &invalid,
+ "description",
+ "radroots.calendar.time_event.v1",
+ ),
+ Err(RadrootsContractValidationError::TagValueMismatch { .. })
+ ));
+ }
+ }
+
+ #[test]
fn trade_mutation_contract_requires_exact_contract_tag() {
let contract = event_contract("radroots.trade.proposal.v1").expect("trade proposal");
let tag = contract
diff --git a/crates/event/src/ids.rs b/crates/event/src/ids.rs
@@ -7,6 +7,7 @@ use alloc::{string::String, string::ToString, vec::Vec};
use std::{string::String, vec::Vec};
use core::{borrow::Borrow, fmt, ops::Deref, str::FromStr};
+use url_nostd::Url;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RadrootsIdParseError {
@@ -332,18 +333,36 @@ fn validate_relay_url(value: &str) -> Result<String, RadrootsIdParseError> {
if value.is_empty() {
return Err(RadrootsIdParseError::Empty);
}
- if value.trim() != value || value.chars().any(char::is_control) {
+ if value
+ .chars()
+ .any(|character| character.is_control() || character.is_whitespace())
+ {
return Err(RadrootsIdParseError::InvalidCharacter);
}
if !(value.starts_with("ws://") || value.starts_with("wss://")) {
return Err(RadrootsIdParseError::InvalidFormat);
}
- if value.len() <= "ws://".len() {
- return Err(RadrootsIdParseError::InvalidLength {
- expected: "ws://".len() + 1,
- actual: value.len(),
- });
+
+ let parsed = Url::parse(value).map_err(|_| RadrootsIdParseError::InvalidFormat)?;
+ if !matches!(parsed.scheme(), "ws" | "wss")
+ || parsed.host_str().is_none_or(str::is_empty)
+ || !parsed.username().is_empty()
+ || parsed.password().is_some()
+ || parsed.fragment().is_some()
+ || parsed.port() == Some(0)
+ {
+ return Err(RadrootsIdParseError::InvalidFormat);
+ }
+
+ // `Url::username` cannot distinguish absent userinfo from an empty userinfo field.
+ let authority = value
+ .split_once("://")
+ .map(|(_, remainder)| remainder.split(['/', '?', '#']).next().unwrap_or(remainder))
+ .ok_or(RadrootsIdParseError::InvalidFormat)?;
+ if authority.contains('@') {
+ return Err(RadrootsIdParseError::InvalidFormat);
}
+
Ok(value.to_string())
}
@@ -607,7 +626,7 @@ mod tests {
}
#[test]
- fn relay_urls_require_websocket_scheme_and_visible_boundaries() {
+ fn relay_urls_require_valid_websocket_urls() {
assert_eq!(
RadrootsRelayUrl::parse("ws://relay.example.com")
.expect("relay url")
@@ -630,11 +649,16 @@ mod tests {
RadrootsIdParseError::InvalidFormat
);
assert_eq!(
+ RadrootsRelayUrl::parse("WSS://relay.example.com").unwrap_err(),
+ RadrootsIdParseError::InvalidFormat
+ );
+ assert_eq!(
RadrootsRelayUrl::parse("ws://").unwrap_err(),
- RadrootsIdParseError::InvalidLength {
- expected: 6,
- actual: 5
- }
+ RadrootsIdParseError::InvalidFormat
+ );
+ assert_eq!(
+ RadrootsRelayUrl::parse("wss://").unwrap_err(),
+ RadrootsIdParseError::InvalidFormat
);
assert_eq!(
RadrootsRelayUrl::parse(" wss://relay.example.com").unwrap_err(),
@@ -644,6 +668,28 @@ mod tests {
RadrootsRelayUrl::parse("wss://relay.example.com\nmiddle").unwrap_err(),
RadrootsIdParseError::InvalidCharacter
);
+ assert_eq!(
+ RadrootsRelayUrl::parse("wss://user@relay.example.com").unwrap_err(),
+ RadrootsIdParseError::InvalidFormat
+ );
+ assert_eq!(
+ RadrootsRelayUrl::parse("wss://user:secret@relay.example.com").unwrap_err(),
+ RadrootsIdParseError::InvalidFormat
+ );
+ assert_eq!(
+ RadrootsRelayUrl::parse("wss://relay.example.com#read").unwrap_err(),
+ RadrootsIdParseError::InvalidFormat
+ );
+ assert_eq!(
+ RadrootsRelayUrl::parse("wss://relay.example.com:0").unwrap_err(),
+ RadrootsIdParseError::InvalidFormat
+ );
+ assert_eq!(
+ RadrootsRelayUrl::parse("wss://relay.example.com/nostr/v1?region=ca-bc")
+ .expect("relay URL with path and query")
+ .as_str(),
+ "wss://relay.example.com/nostr/v1?region=ca-bc"
+ );
}
#[test]
diff --git a/crates/event/src/lib.rs b/crates/event/src/lib.rs
@@ -44,6 +44,7 @@ pub mod list;
pub mod list_set;
pub mod listing;
pub mod location;
+pub mod media;
pub mod message;
pub mod message_file;
pub mod order;
@@ -70,6 +71,7 @@ pub use envelope::{
RadrootsEventEnvelopeParts, RadrootsEventKind, RadrootsEventKindClass, RadrootsEventTag,
RadrootsEventTags, RadrootsEventTimestamp,
};
+pub use media::{RadrootsAuthoredImage, RadrootsAuthoredImageError};
pub use wire::{
RadrootsCanonicalEventIdError, RadrootsEventWireError, RadrootsEventWireLimits,
RadrootsNip01EventWire, canonical_nip01_event_id_preimage, compute_canonical_nip01_event_id,
diff --git a/crates/event/src/media.rs b/crates/event/src/media.rs
@@ -0,0 +1,118 @@
+use core::fmt;
+
+use radroots_blossom::RadrootsBlossomByteVerifiedDescriptor;
+
+/// Errors raised while constructing strict authored image media.
+#[non_exhaustive]
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum RadrootsAuthoredImageError {
+ MediaTypeNotImage,
+}
+
+impl RadrootsAuthoredImageError {
+ pub const fn code(&self) -> &'static str {
+ match self {
+ Self::MediaTypeNotImage => "media_type_not_image",
+ }
+ }
+}
+
+impl fmt::Display for RadrootsAuthoredImageError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::MediaTypeNotImage => {
+ f.write_str("authored image descriptor must have an image media type")
+ }
+ }
+ }
+}
+
+#[cfg(feature = "std")]
+impl std::error::Error for RadrootsAuthoredImageError {}
+
+/// A byte-verified Blossom descriptor whose declared media type is `image/*`.
+///
+/// Construction requires the non-forgeable Blossom byte-verification state.
+/// This typestate does not prove upload completion, network availability, or
+/// image format safety. Owning runtimes remain responsible for those policies.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct RadrootsAuthoredImage(RadrootsBlossomByteVerifiedDescriptor);
+
+impl RadrootsAuthoredImage {
+ pub fn try_from_verified_descriptor(
+ descriptor: RadrootsBlossomByteVerifiedDescriptor,
+ ) -> Result<Self, RadrootsAuthoredImageError> {
+ if !descriptor.media_type().as_str().starts_with("image/") {
+ return Err(RadrootsAuthoredImageError::MediaTypeNotImage);
+ }
+ Ok(Self(descriptor))
+ }
+
+ pub fn descriptor(&self) -> &RadrootsBlossomByteVerifiedDescriptor {
+ &self.0
+ }
+}
+
+impl TryFrom<RadrootsBlossomByteVerifiedDescriptor> for RadrootsAuthoredImage {
+ type Error = RadrootsAuthoredImageError;
+
+ fn try_from(value: RadrootsBlossomByteVerifiedDescriptor) -> Result<Self, Self::Error> {
+ Self::try_from_verified_descriptor(value)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use radroots_blossom::{
+ RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomMediaType,
+ RadrootsBlossomSha256,
+ };
+
+ #[test]
+ fn authored_image_requires_a_byte_verified_image_descriptor() {
+ let descriptor = verified_descriptor("image/webp", "webp");
+ let expected_url = descriptor.url().as_str().to_owned();
+ let image = RadrootsAuthoredImage::try_from(descriptor).unwrap();
+
+ assert_eq!(image.descriptor().url().as_str(), expected_url);
+ }
+
+ #[test]
+ fn authored_image_rejects_non_image_media_types_with_a_stable_error() {
+ let error = RadrootsAuthoredImage::try_from_verified_descriptor(verified_descriptor(
+ "text/plain",
+ "txt",
+ ))
+ .unwrap_err();
+
+ assert_eq!(error, RadrootsAuthoredImageError::MediaTypeNotImage);
+ assert_eq!(error.code(), "media_type_not_image");
+ assert_eq!(
+ error.to_string(),
+ "authored image descriptor must have an image media type"
+ );
+ }
+
+ fn verified_descriptor(
+ media_type: &str,
+ extension: &str,
+ ) -> RadrootsBlossomByteVerifiedDescriptor {
+ let bytes = b"hello";
+ let hash = RadrootsBlossomSha256::digest(bytes);
+ let media_type = RadrootsBlossomMediaType::parse(media_type).unwrap();
+ RadrootsBlossomBlobDescriptor::new(
+ RadrootsBlossomBlobUrl::parse(&format!("https://media.example/{hash}.{extension}"))
+ .unwrap(),
+ hash,
+ bytes.len() as u64,
+ media_type.clone(),
+ 1_784_347_200,
+ )
+ .unwrap()
+ .approve_reference()
+ .unwrap()
+ .verify_bytes(bytes, &media_type)
+ .unwrap()
+ }
+}
diff --git a/crates/event/src/social.rs b/crates/event/src/social.rs
@@ -118,15 +118,6 @@ pub struct RadrootsCalendarParticipant {
any(feature = "serde", test),
derive(serde::Serialize, serde::Deserialize)
)]
-#[derive(Clone, Debug, Default, PartialEq, Eq)]
-pub struct RadrootsCalendarDateValue {
- pub value: 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 {
diff --git a/crates/event_codec/README b/crates/event_codec/README
@@ -21,6 +21,50 @@ and residual fields and never upgrades observed media or NIP-05 syntax into
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`:
+
+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.
+
+Structural envelope construction, the baseline parsers, and the admission
+functions do not verify a Schnorr signature. Before treating inbound data as
+accepted, callers must recompute and compare its NIP-01 id, verify its
+signature against the author, use the parser for its expected kind, and keep
+the typed result bound to that verified envelope. The calendar
+`*_parsed_from_event` convenience functions remain structural wrappers; their
+names do not imply cryptographic verification.
+
+The baseline parser projects repeated NIP-52 `location`, participant `p`,
+category `t`, absolute-URI reference `r`, and kind-`31924` inclusion-request
+`a` tags, plus deprecated `name` compatibility data. It accepts arbitrary
+absolute image URIs. Kind `31922` requires semantic Gregorian dates and retains
+uppercase-`D` tags as uninterpreted extensions. Kind `31923` requires at least
+one in-range `D` day and exact IANA time-zone identifiers. NIP-52 makes
+multi-day coverage a `SHOULD`, so the baseline requires neither the start-day
+index nor complete coverage; duplicate, unordered, and leading-zero numeric
+observations are additionally preserved as parser tolerance and are never
+canonical authored output. An omitted
+`end_tzid` uses `start_tzid` when present.
+
+Strict date admission rejects every uppercase-`D` extension. Strict time
+admission requires canonical decimal timestamps and the exact ascending
+uppercase-`D` sequence for the exclusive interval, bounded to 366 covered
+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.
+
## Field Event Codecs
The Field codec surface validates the public Nostr event substrate exposed by
diff --git a/crates/event_codec/src/calendar/decode.rs b/crates/event_codec/src/calendar/decode.rs
@@ -6,124 +6,144 @@ use alloc::{
use radroots_event::{
calendar::{
- RadrootsCalendar, RadrootsCalendarDateEvent, RadrootsCalendarEventRsvp,
- RadrootsCalendarTimeEvent,
+ RADROOTS_CALENDAR_SECONDS_PER_DAY, RadrootsAdmittedCalendarDateEvent,
+ RadrootsAdmittedCalendarTimeEvent, RadrootsCalendar, RadrootsCalendarDate,
+ RadrootsCalendarEventRsvp, RadrootsCalendarRequest, RadrootsCalendarUri,
+ RadrootsIanaTimeZoneId, RadrootsObservedUtcDay, RadrootsParsedNip52CalendarCommon,
+ RadrootsParsedNip52CalendarCommonParts, RadrootsParsedNip52CalendarDateEvent,
+ 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::{
- RadrootsCalendarDateValue, RadrootsCalendarEventFreeBusy, RadrootsCalendarEventRsvpStatus,
- RadrootsSocialTarget,
+ RadrootsCalendarEventFreeBusy, RadrootsCalendarEventRsvpStatus,
+ RadrootsCalendarParticipant, RadrootsSocialTarget,
},
tags::{
- TAG_A, TAG_D, TAG_D_DAY, TAG_E, TAG_END, TAG_END_TZID, TAG_FREE_BUSY, TAG_IMAGE, TAG_START,
- TAG_START_TZID, TAG_STATUS, TAG_SUMMARY, TAG_TITLE,
+ 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,
+ },
+ wire::{
+ DEFAULT_CONTENT_MAX_BYTES, DEFAULT_TAG_ELEMENT_MAX_BYTES, DEFAULT_TAG_MAX_COUNT,
+ DEFAULT_TAG_TOTAL_MAX_BYTES,
},
};
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, tag_values,
- validate_lowercase_hex_64_tag,
+ optional_tag_value, parse_address_tag, required_tag_value, validate_lowercase_hex_64_tag,
};
use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent};
-use crate::social_helpers::{
- location_from_tags, participants_from_tags, validate_date_tag, validate_end_after_start,
-};
+use crate::social_helpers::participants_from_tags;
const EXPECTED_DATE_KIND: &str = "31922";
const EXPECTED_TIME_KIND: &str = "31923";
const EXPECTED_CALENDAR_KIND: &str = "31924";
const EXPECTED_RSVP_KIND: &str = "31925";
-pub fn calendar_date_event_from_event(
+pub fn parse_nip52_calendar_date_event(
kind: u32,
tags: &[Vec<String>],
content: &str,
-) -> Result<RadrootsCalendarDateEvent, EventParseError> {
+) -> Result<RadrootsParsedNip52CalendarDateEvent, EventParseError> {
+ validate_bounded_calendar_parts(tags, content)?;
if kind != KIND_CALENDAR_DATE_EVENT {
return Err(EventParseError::InvalidKind {
expected: EXPECTED_DATE_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 start = required_tag_value(tags, TAG_START)?;
- validate_date_tag(&start, TAG_START)?;
- let end = optional_tag_value(tags, TAG_END)?;
- if let Some(end) = end.as_deref() {
- validate_date_tag(end, TAG_END)?;
- if end < start.as_str() {
- return Err(EventParseError::InvalidTag(TAG_END));
- }
- }
- let days = tag_values(tags, TAG_D_DAY)?
- .into_iter()
- .map(|value| {
- validate_date_tag(&value, TAG_D_DAY)?;
- Ok(RadrootsCalendarDateValue { value })
- })
- .collect::<Result<Vec<_>, EventParseError>>()?;
- Ok(RadrootsCalendarDateEvent {
- d_tag,
- title,
- start,
- description: optional_content(content),
- end,
- days: non_empty_vec(days),
- location: location_from_tags(tags),
- summary: optional_tag_value(tags, TAG_SUMMARY)?,
- image: optional_tag_value(tags, TAG_IMAGE)?,
- participants: participants_from_tags(tags),
- })
+ let common = parse_nip52_calendar_common(tags, content)?;
+ let start = parse_calendar_date(&required_exact_tag_value(tags, TAG_START)?, TAG_START)?;
+ let end = optional_exact_tag_value(tags, TAG_END)?
+ .map(|value| parse_calendar_date(&value, TAG_END))
+ .transpose()?;
+ let extension_day_tags = tags
+ .iter()
+ .filter(|tag| tag.first().map(String::as_str) == Some(TAG_D_DAY))
+ .cloned()
+ .collect();
+ RadrootsParsedNip52CalendarDateEvent::try_new(common, start, end, extension_day_tags)
+ .map_err(|_| EventParseError::InvalidTag(TAG_END))
}
-pub fn calendar_time_event_from_event(
+pub fn parse_nip52_calendar_time_event(
kind: u32,
tags: &[Vec<String>],
content: &str,
-) -> Result<RadrootsCalendarTimeEvent, EventParseError> {
+) -> Result<RadrootsParsedNip52CalendarTimeEvent, EventParseError> {
+ validate_bounded_calendar_parts(tags, content)?;
if kind != KIND_CALENDAR_TIME_EVENT {
return Err(EventParseError::InvalidKind {
expected: EXPECTED_TIME_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 start = parse_required_u64(tags, TAG_START)?;
- let end = parse_optional_u64(tags, TAG_END)?;
- validate_end_after_start(start, end, TAG_END)
- .map_err(|_| EventParseError::InvalidTag(TAG_END))?;
- let dates = tag_values(tags, TAG_D_DAY)?
+ let common = parse_nip52_calendar_common(tags, content)?;
+ let start_wire = required_exact_tag_value(tags, TAG_START)?;
+ let start = parse_standard_u64(&start_wire, TAG_START)?;
+ let end_wire = optional_exact_tag_value(tags, TAG_END)?;
+ let end = end_wire
+ .as_deref()
+ .map(|value| parse_standard_u64(value, TAG_END))
+ .transpose()?;
+ let observed_day_indices = repeated_exact_tag_values(tags, TAG_D_DAY)?
.into_iter()
.map(|value| {
- validate_date_tag(&value, TAG_D_DAY)?;
- Ok(RadrootsCalendarDateValue { value })
+ RadrootsObservedUtcDay::parse(value).map_err(|_| EventParseError::InvalidTag(TAG_D_DAY))
})
- .collect::<Result<Vec<_>, EventParseError>>()?;
- if dates.is_empty() {
+ .collect::<Result<Vec<_>, _>>()?;
+ if end.is_some_and(|end| end <= start) {
+ return Err(EventParseError::InvalidTag(TAG_END));
+ }
+ if observed_day_indices.is_empty() {
return Err(EventParseError::MissingTag(TAG_D_DAY));
}
- Ok(RadrootsCalendarTimeEvent {
- d_tag,
- title,
+ let first_day = start / RADROOTS_CALENDAR_SECONDS_PER_DAY;
+ let last_day = end
+ .map(|end| (end - 1) / RADROOTS_CALENDAR_SECONDS_PER_DAY)
+ .unwrap_or(first_day);
+ if observed_day_indices
+ .iter()
+ .any(|day| !(first_day..=last_day).contains(&day.index()))
+ {
+ return Err(EventParseError::InvalidTag(TAG_D_DAY));
+ }
+ let start_tzid = optional_iana_tzid(tags, TAG_START_TZID)?;
+ let end_tzid = optional_iana_tzid(tags, TAG_END_TZID)?;
+ RadrootsParsedNip52CalendarTimeEvent::try_new(
+ common,
+ start_wire,
start,
- dates,
- description: optional_content(content),
+ end_wire,
end,
- start_tzid: optional_tag_value(tags, TAG_START_TZID)?,
- end_tzid: optional_tag_value(tags, TAG_END_TZID)?,
- location: location_from_tags(tags),
- summary: optional_tag_value(tags, TAG_SUMMARY)?,
- image: optional_tag_value(tags, TAG_IMAGE)?,
- participants: participants_from_tags(tags),
- })
+ observed_day_indices,
+ start_tzid,
+ end_tzid,
+ )
+ .map_err(|_| EventParseError::InvalidTag(TAG_END))
+}
+
+pub fn admit_radroots_calendar_date_event(
+ parsed: RadrootsParsedNip52CalendarDateEvent,
+) -> Result<
+ RadrootsAdmittedCalendarDateEvent,
+ radroots_event::calendar::RadrootsCalendarAdmissionError,
+> {
+ RadrootsAdmittedCalendarDateEvent::try_from_parsed(parsed)
+}
+
+pub fn admit_radroots_calendar_time_event(
+ parsed: RadrootsParsedNip52CalendarTimeEvent,
+) -> Result<
+ RadrootsAdmittedCalendarTimeEvent,
+ radroots_event::calendar::RadrootsCalendarAdmissionError,
+> {
+ RadrootsAdmittedCalendarTimeEvent::try_from_parsed(parsed)
}
pub fn calendar_from_event(
@@ -191,15 +211,15 @@ pub fn rsvp_from_event(
})
}
-pub fn date_data_from_event(
+pub fn nip52_date_data_from_event(
id: String,
author: String,
published_at: u64,
kind: u32,
content: String,
tags: Vec<Vec<String>>,
-) -> Result<RadrootsParsedData<RadrootsCalendarDateEvent>, EventParseError> {
- let event = calendar_date_event_from_event(kind, &tags, &content)?;
+) -> Result<RadrootsParsedData<RadrootsParsedNip52CalendarDateEvent>, EventParseError> {
+ let event = parse_nip52_calendar_date_event(kind, &tags, &content)?;
Ok(RadrootsParsedData::new(
id,
author,
@@ -209,15 +229,15 @@ pub fn date_data_from_event(
))
}
-pub fn time_data_from_event(
+pub fn nip52_time_data_from_event(
id: String,
author: String,
published_at: u64,
kind: u32,
content: String,
tags: Vec<Vec<String>>,
-) -> Result<RadrootsParsedData<RadrootsCalendarTimeEvent>, EventParseError> {
- let event = calendar_time_event_from_event(kind, &tags, &content)?;
+) -> Result<RadrootsParsedData<RadrootsParsedNip52CalendarTimeEvent>, EventParseError> {
+ let event = parse_nip52_calendar_time_event(kind, &tags, &content)?;
Ok(RadrootsParsedData::new(
id,
author,
@@ -263,7 +283,7 @@ pub fn rsvp_data_from_event(
))
}
-pub fn date_parsed_from_event(
+pub fn nip52_date_parsed_from_event(
id: String,
author: String,
published_at: u64,
@@ -271,8 +291,8 @@ pub fn date_parsed_from_event(
content: String,
tags: Vec<Vec<String>>,
sig: String,
-) -> Result<RadrootsParsedEvent<RadrootsCalendarDateEvent>, EventParseError> {
- let data = date_data_from_event(
+) -> Result<RadrootsParsedEvent<RadrootsParsedNip52CalendarDateEvent>, EventParseError> {
+ let data = nip52_date_data_from_event(
id.clone(),
author.clone(),
published_at,
@@ -283,7 +303,7 @@ pub fn date_parsed_from_event(
RadrootsParsedEvent::from_event_parts(id, author, published_at, kind, content, tags, sig, data)
}
-pub fn time_parsed_from_event(
+pub fn nip52_time_parsed_from_event(
id: String,
author: String,
published_at: u64,
@@ -291,8 +311,8 @@ pub fn time_parsed_from_event(
content: String,
tags: Vec<Vec<String>>,
sig: String,
-) -> Result<RadrootsParsedEvent<RadrootsCalendarTimeEvent>, EventParseError> {
- let data = time_data_from_event(
+) -> Result<RadrootsParsedEvent<RadrootsParsedNip52CalendarTimeEvent>, EventParseError> {
+ let data = nip52_time_data_from_event(
id.clone(),
author.clone(),
published_at,
@@ -343,31 +363,248 @@ pub fn rsvp_parsed_from_event(
RadrootsParsedEvent::from_event_parts(id, author, published_at, kind, content, tags, sig, data)
}
-fn parse_required_u64(tags: &[Vec<String>], key: &'static str) -> Result<u64, EventParseError> {
- required_tag_value(tags, key)?
+fn parse_nip52_calendar_common(
+ tags: &[Vec<String>],
+ content: &str,
+) -> Result<RadrootsParsedNip52CalendarCommon, EventParseError> {
+ 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 locations = repeated_calendar_text_values(tags, TAG_LOCATION)?;
+ let geohash = optional_exact_tag_value(tags, TAG_G)?;
+ if geohash
+ .as_deref()
+ .is_some_and(|value| !calendar_geohash_is_valid(value))
+ {
+ return Err(EventParseError::InvalidTag(TAG_G));
+ }
+ let summary = optional_calendar_text_tag(tags, TAG_SUMMARY)?;
+ let image = optional_calendar_uri_tag(tags, TAG_IMAGE)?;
+ let participants = calendar_participants_from_tags(tags)?;
+ let categories = repeated_calendar_text_values(tags, TAG_T)?;
+ let references = repeated_exact_tag_values(tags, TAG_R)?
+ .into_iter()
+ .map(|value| {
+ RadrootsCalendarUri::parse(value).map_err(|_| EventParseError::InvalidTag(TAG_R))
+ })
+ .collect::<Result<Vec<_>, _>>()?;
+ let calendar_requests = calendar_requests_from_tags(tags)?;
+ let legacy_name = optional_calendar_text_tag(tags, "name")?;
+ RadrootsParsedNip52CalendarCommon::try_new(RadrootsParsedNip52CalendarCommonParts {
+ d_tag,
+ title,
+ description: optional_content(content),
+ locations,
+ geohash,
+ summary,
+ image,
+ participants,
+ categories,
+ references,
+ calendar_requests,
+ legacy_name,
+ })
+ .map_err(|_| EventParseError::InvalidTag("calendar"))
+}
+
+fn validate_bounded_calendar_parts(
+ tags: &[Vec<String>],
+ content: &str,
+) -> Result<(), EventParseError> {
+ if content.len() > DEFAULT_CONTENT_MAX_BYTES {
+ return Err(EventParseError::InvalidEnvelope);
+ }
+ if tags.len() > DEFAULT_TAG_MAX_COUNT {
+ return Err(EventParseError::InvalidEnvelope);
+ }
+ let mut total_bytes = 0usize;
+ for tag in tags {
+ let Some(key) = tag.first() else {
+ return Err(EventParseError::InvalidEnvelope);
+ };
+ if key.is_empty() || key.chars().any(char::is_control) {
+ return Err(EventParseError::InvalidEnvelope);
+ }
+ for value in tag {
+ if value.len() > DEFAULT_TAG_ELEMENT_MAX_BYTES {
+ return Err(EventParseError::InvalidEnvelope);
+ }
+ total_bytes = total_bytes.saturating_add(value.len());
+ if total_bytes > DEFAULT_TAG_TOTAL_MAX_BYTES {
+ return Err(EventParseError::InvalidEnvelope);
+ }
+ }
+ }
+ Ok(())
+}
+
+fn required_exact_tag_value(
+ tags: &[Vec<String>],
+ key: &'static str,
+) -> Result<String, EventParseError> {
+ optional_exact_tag_value(tags, key)?.ok_or(EventParseError::MissingTag(key))
+}
+
+fn repeated_exact_tag_values(
+ tags: &[Vec<String>],
+ key: &'static str,
+) -> Result<Vec<String>, EventParseError> {
+ tags.iter()
+ .filter(|tag| tag.first().map(String::as_str) == Some(key))
+ .map(|tag| {
+ if tag.len() != 2 || tag[1].is_empty() {
+ Err(EventParseError::InvalidTag(key))
+ } else {
+ Ok(tag[1].clone())
+ }
+ })
+ .collect()
+}
+
+fn optional_exact_tag_value(
+ tags: &[Vec<String>],
+ key: &'static str,
+) -> Result<Option<String>, EventParseError> {
+ let mut matching = tags
+ .iter()
+ .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));
+ }
+ if tag.len() != 2 {
+ return Err(EventParseError::InvalidTag(key));
+ }
+ let value = tag[1].clone();
+ if value.is_empty() {
+ return Err(EventParseError::InvalidTag(key));
+ }
+ Ok(Some(value))
+}
+
+fn parse_calendar_date(
+ value: &str,
+ key: &'static str,
+) -> Result<RadrootsCalendarDate, EventParseError> {
+ RadrootsCalendarDate::parse(value).map_err(|_| EventParseError::InvalidTag(key))
+}
+
+fn parse_standard_u64(value: &str, key: &'static str) -> Result<u64, EventParseError> {
+ if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
+ return Err(EventParseError::InvalidTag(key));
+ }
+ value
.parse::<u64>()
- .map_err(|err| EventParseError::InvalidNumber(key, err))
+ .map_err(|error| EventParseError::InvalidNumber(key, error))
}
-fn parse_optional_u64(
+fn optional_iana_tzid(
tags: &[Vec<String>],
key: &'static str,
-) -> Result<Option<u64>, EventParseError> {
- optional_tag_value(tags, key)?
+) -> Result<Option<RadrootsIanaTimeZoneId>, EventParseError> {
+ optional_exact_tag_value(tags, key)?
.map(|value| {
- value
- .parse::<u64>()
- .map_err(|err| EventParseError::InvalidNumber(key, err))
+ RadrootsIanaTimeZoneId::parse(value).map_err(|_| EventParseError::InvalidTag(key))
})
.transpose()
}
-fn non_empty_vec<T>(values: Vec<T>) -> Option<Vec<T>> {
- if values.is_empty() {
- None
+fn optional_calendar_text_tag(
+ tags: &[Vec<String>],
+ key: &'static str,
+) -> Result<Option<String>, EventParseError> {
+ let value = optional_exact_tag_value(tags, key)?;
+ if let Some(value) = value.as_deref() {
+ validate_calendar_text_tag(value, key)?;
+ }
+ Ok(value)
+}
+
+fn validate_calendar_text_tag(value: &str, key: &'static str) -> Result<(), EventParseError> {
+ if !calendar_tag_text_is_valid(value) {
+ Err(EventParseError::InvalidTag(key))
} else {
- Some(values)
+ Ok(())
+ }
+}
+
+fn repeated_calendar_text_values(
+ tags: &[Vec<String>],
+ key: &'static str,
+) -> Result<Vec<String>, EventParseError> {
+ let values = repeated_exact_tag_values(tags, key)?;
+ for value in &values {
+ validate_calendar_text_tag(value, key)?;
}
+ Ok(values)
+}
+
+fn optional_calendar_uri_tag(
+ tags: &[Vec<String>],
+ key: &'static str,
+) -> Result<Option<RadrootsCalendarUri>, EventParseError> {
+ optional_exact_tag_value(tags, key)?
+ .map(|value| {
+ RadrootsCalendarUri::parse(value).map_err(|_| EventParseError::InvalidTag(key))
+ })
+ .transpose()
+}
+
+fn calendar_participants_from_tags(
+ tags: &[Vec<String>],
+) -> Result<Vec<RadrootsCalendarParticipant>, EventParseError> {
+ let mut participants = Vec::new();
+ for tag in tags
+ .iter()
+ .filter(|tag| tag.first().map(String::as_str) == Some("p"))
+ {
+ if !(2..=4).contains(&tag.len()) {
+ return Err(EventParseError::InvalidTag("p"));
+ }
+ radroots_event::ids::RadrootsPublicKey::parse(&tag[1])
+ .map_err(|_| EventParseError::InvalidTag(TAG_P))?;
+ if tag.len() == 3 && tag[2].is_empty() {
+ return Err(EventParseError::InvalidTag(TAG_P));
+ }
+ let relay = tag.get(2).filter(|value| !value.is_empty()).cloned();
+ if relay
+ .as_deref()
+ .is_some_and(|value| !calendar_relay_url_is_valid(value))
+ {
+ return Err(EventParseError::InvalidTag(TAG_P));
+ }
+ let role = tag.get(3).cloned();
+ if let Some(role) = role.as_deref() {
+ validate_calendar_text_tag(role, TAG_P)?;
+ }
+ participants.push(RadrootsCalendarParticipant {
+ pubkey: tag[1].clone(),
+ relay,
+ role,
+ });
+ }
+ Ok(participants)
+}
+
+fn calendar_requests_from_tags(
+ tags: &[Vec<String>],
+) -> Result<Vec<RadrootsCalendarRequest>, EventParseError> {
+ tags.iter()
+ .filter(|tag| tag.first().map(String::as_str) == Some(TAG_A))
+ .map(|tag| {
+ if !(2..=3).contains(&tag.len())
+ || tag[1].is_empty()
+ || tag.get(2).is_some_and(String::is_empty)
+ {
+ return Err(EventParseError::InvalidTag(TAG_A));
+ }
+ RadrootsCalendarRequest::new(&tag[1], tag.get(2).map(String::as_str))
+ .map_err(|_| EventParseError::InvalidTag(TAG_A))
+ })
+ .collect()
}
fn optional_content(content: &str) -> Option<String> {
diff --git a/crates/event_codec/src/calendar/encode.rs b/crates/event_codec/src/calendar/encode.rs
@@ -8,8 +8,8 @@ use alloc::{
use radroots_event::{
calendar::{
- RadrootsCalendar, RadrootsCalendarDateEvent, RadrootsCalendarEventRsvp,
- RadrootsCalendarTimeEvent,
+ RadrootsAuthoredCalendarDateEvent, RadrootsAuthoredCalendarTimeEvent, RadrootsCalendar,
+ RadrootsCalendarEventRsvp, RadrootsCalendarRequest, RadrootsCalendarUri, covered_utc_days,
},
kinds::{
KIND_CALENDAR, KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_EVENT_RSVP, KIND_CALENDAR_TIME_EVENT,
@@ -18,8 +18,8 @@ use radroots_event::{
RadrootsCalendarEventFreeBusy, RadrootsCalendarEventRsvpStatus, RadrootsSocialTarget,
},
tags::{
- TAG_A, TAG_D, TAG_D_DAY, TAG_E, TAG_END, TAG_END_TZID, TAG_FREE_BUSY, TAG_IMAGE, TAG_START,
- TAG_START_TZID, TAG_STATUS, TAG_SUMMARY, TAG_TITLE,
+ 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,
},
};
@@ -29,59 +29,69 @@ 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::social_helpers::{
- push_location_tags, push_participants, validate_date, validate_date_end_after_start,
- validate_end_after_start,
-};
+use crate::social_helpers::push_participants;
use radroots_event::wire::RadrootsNip01EventWireParts;
pub fn calendar_date_event_build_tags(
- event: &RadrootsCalendarDateEvent,
+ event: &RadrootsAuthoredCalendarDateEvent,
) -> Result<Vec<Vec<String>>, EventEncodeError> {
- validate_date_event(event)?;
+ validate_authored_date_event(event)?;
let mut tags = Vec::new();
- push_tag(&mut tags, TAG_D, event.d_tag.as_str());
- push_tag(&mut tags, TAG_TITLE, event.title.as_str());
- push_tag(&mut tags, TAG_START, event.start.as_str());
- push_optional_tag(&mut tags, TAG_END, event.end.as_deref());
- if let Some(days) = event.days.as_ref() {
- for day in days {
- validate_date(&day.value, "days")?;
- push_tag(&mut tags, TAG_D_DAY, day.value.as_str());
- }
- }
- if let Some(location) = event.location.as_ref() {
- push_location_tags(&mut tags, location);
- }
- push_optional_tag(&mut tags, TAG_SUMMARY, event.summary.as_deref());
- push_optional_tag(&mut tags, TAG_IMAGE, event.image.as_deref());
- push_participants(&mut tags, event.participants.as_ref());
+ push_tag(&mut tags, TAG_D, event.d_tag().as_str());
+ push_tag(&mut tags, TAG_TITLE, event.title());
+ push_tag(&mut tags, TAG_START, event.start().as_str());
+ push_optional_tag(&mut tags, TAG_END, event.end().map(|end| end.as_str()));
+ push_authored_calendar_common_tags(
+ &mut tags,
+ event.locations(),
+ event.geohash(),
+ event.summary(),
+ event.image().map(|image| image.descriptor().url().as_str()),
+ event.participants(),
+ event.categories(),
+ event.references(),
+ event.calendar_requests(),
+ );
Ok(tags)
}
pub fn calendar_time_event_build_tags(
- event: &RadrootsCalendarTimeEvent,
+ event: &RadrootsAuthoredCalendarTimeEvent,
) -> Result<Vec<Vec<String>>, EventEncodeError> {
- validate_time_event(event)?;
+ validate_authored_time_event(event)?;
let mut tags = Vec::new();
- push_tag(&mut tags, TAG_D, event.d_tag.as_str());
- push_tag(&mut tags, TAG_TITLE, event.title.as_str());
- push_tag(&mut tags, TAG_START, event.start.to_string());
- for date in &event.dates {
- validate_date(&date.value, "dates")?;
- push_tag(&mut tags, TAG_D_DAY, date.value.as_str());
- }
- if let Some(end) = event.end {
+ push_tag(&mut tags, TAG_D, event.d_tag().as_str());
+ push_tag(&mut tags, TAG_TITLE, event.title());
+ push_tag(&mut tags, TAG_START, event.start().to_string());
+ if let Some(end) = event.end() {
push_tag(&mut tags, TAG_END, end.to_string());
}
- push_optional_tag(&mut tags, TAG_START_TZID, event.start_tzid.as_deref());
- push_optional_tag(&mut tags, TAG_END_TZID, event.end_tzid.as_deref());
- if let Some(location) = event.location.as_ref() {
- push_location_tags(&mut tags, location);
+ for day in covered_utc_days(event.start(), event.end())
+ .map_err(|_| EventEncodeError::InvalidField("end"))?
+ {
+ push_tag(&mut tags, TAG_D_DAY, day.to_string());
}
- push_optional_tag(&mut tags, TAG_SUMMARY, event.summary.as_deref());
- push_optional_tag(&mut tags, TAG_IMAGE, event.image.as_deref());
- push_participants(&mut tags, event.participants.as_ref());
+ push_optional_tag(
+ &mut tags,
+ TAG_START_TZID,
+ event.start_tzid().map(|tzid| tzid.as_str()),
+ );
+ push_optional_tag(
+ &mut tags,
+ TAG_END_TZID,
+ event.end_tzid().map(|tzid| tzid.as_str()),
+ );
+ push_authored_calendar_common_tags(
+ &mut tags,
+ event.locations(),
+ event.geohash(),
+ event.summary(),
+ event.image().map(|image| image.descriptor().url().as_str()),
+ event.participants(),
+ event.categories(),
+ event.references(),
+ event.calendar_requests(),
+ );
Ok(tags)
}
@@ -128,13 +138,13 @@ pub fn rsvp_build_tags(
}
pub fn date_to_wire_parts(
- event: &RadrootsCalendarDateEvent,
+ event: &RadrootsAuthoredCalendarDateEvent,
) -> Result<RadrootsNip01EventWireParts, EventEncodeError> {
date_to_wire_parts_with_kind(event, KIND_CALENDAR_DATE_EVENT)
}
pub fn time_to_wire_parts(
- event: &RadrootsCalendarTimeEvent,
+ event: &RadrootsAuthoredCalendarTimeEvent,
) -> Result<RadrootsNip01EventWireParts, EventEncodeError> {
time_to_wire_parts_with_kind(event, KIND_CALENDAR_TIME_EVENT)
}
@@ -152,7 +162,7 @@ pub fn rsvp_to_wire_parts(
}
pub fn date_to_wire_parts_with_kind(
- event: &RadrootsCalendarDateEvent,
+ event: &RadrootsAuthoredCalendarDateEvent,
kind: u32,
) -> Result<RadrootsNip01EventWireParts, EventEncodeError> {
if kind != KIND_CALENDAR_DATE_EVENT {
@@ -160,13 +170,13 @@ pub fn date_to_wire_parts_with_kind(
}
Ok(RadrootsNip01EventWireParts {
kind,
- content: event.description.clone().unwrap_or_default(),
+ content: event.description().unwrap_or_default().to_string(),
tags: calendar_date_event_build_tags(event)?,
})
}
pub fn time_to_wire_parts_with_kind(
- event: &RadrootsCalendarTimeEvent,
+ event: &RadrootsAuthoredCalendarTimeEvent,
kind: u32,
) -> Result<RadrootsNip01EventWireParts, EventEncodeError> {
if kind != KIND_CALENDAR_TIME_EVENT {
@@ -174,7 +184,7 @@ pub fn time_to_wire_parts_with_kind(
}
Ok(RadrootsNip01EventWireParts {
kind,
- content: event.description.clone().unwrap_or_default(),
+ content: event.description().unwrap_or_default().to_string(),
tags: calendar_time_event_build_tags(event)?,
})
}
@@ -207,28 +217,52 @@ pub fn rsvp_to_wire_parts_with_kind(
})
}
-fn validate_date_event(event: &RadrootsCalendarDateEvent) -> Result<(), EventEncodeError> {
- validate_d_tag(&event.d_tag, "d_tag")?;
- validate_non_empty_field(&event.title, "title")?;
- validate_date(&event.start, "start")?;
- if let Some(end) = event.end.as_deref() {
- validate_date(end, "end")?;
- }
- validate_date_end_after_start(&event.start, event.end.as_deref(), "end")?;
+fn validate_authored_date_event(
+ _event: &RadrootsAuthoredCalendarDateEvent,
+) -> Result<(), EventEncodeError> {
+ Ok(())
+}
+
+fn validate_authored_time_event(
+ event: &RadrootsAuthoredCalendarTimeEvent,
+) -> Result<(), EventEncodeError> {
+ covered_utc_days(event.start(), event.end())
+ .map_err(|_| EventEncodeError::InvalidField("end"))?;
Ok(())
}
-fn validate_time_event(event: &RadrootsCalendarTimeEvent) -> Result<(), EventEncodeError> {
- validate_d_tag(&event.d_tag, "d_tag")?;
- validate_non_empty_field(&event.title, "title")?;
- validate_end_after_start(event.start, event.end, "end")?;
- if event.dates.is_empty() {
- return Err(EventEncodeError::EmptyRequiredField("dates"));
+#[allow(clippy::too_many_arguments)]
+fn push_authored_calendar_common_tags(
+ tags: &mut Vec<Vec<String>>,
+ locations: &[String],
+ geohash: Option<&str>,
+ summary: Option<&str>,
+ image: Option<&str>,
+ participants: Option<&Vec<radroots_event::social::RadrootsCalendarParticipant>>,
+ categories: &[String],
+ references: &[RadrootsCalendarUri],
+ calendar_requests: &[RadrootsCalendarRequest],
+) {
+ for location in locations {
+ push_tag(tags, TAG_LOCATION, location);
}
- for date in &event.dates {
- validate_date(&date.value, "dates")?;
+ push_optional_tag(tags, TAG_G, geohash);
+ push_optional_tag(tags, TAG_SUMMARY, summary);
+ push_optional_tag(tags, TAG_IMAGE, image);
+ push_participants(tags, participants);
+ for category in categories {
+ push_tag(tags, TAG_T, category);
+ }
+ for reference in references {
+ push_tag(tags, TAG_R, reference.as_str());
+ }
+ for request in calendar_requests {
+ let mut tag = vec![TAG_A.to_string(), request.calendar().as_str().to_string()];
+ if let Some(relay) = request.relay() {
+ tag.push(relay.to_string());
+ }
+ tags.push(tag);
}
- Ok(())
}
fn validate_calendar_collection(calendar: &RadrootsCalendar) -> Result<(), EventEncodeError> {
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::CalendarInclusionRequest => "calendar_inclusion_request",
+ RadrootsTagSemantic::CalendarEnd => "calendar_end",
+ RadrootsTagSemantic::CalendarStart => "calendar_start",
RadrootsTagSemantic::Category => "category",
RadrootsTagSemantic::Citation => "citation",
RadrootsTagSemantic::Contract => "contract",
@@ -344,10 +347,12 @@ fn tag_semantic_label(value: RadrootsTagSemantic) -> &'static str {
RadrootsTagSemantic::ListingAddress => "listing_address",
RadrootsTagSemantic::ListingSnapshot => "listing_snapshot",
RadrootsTagSemantic::Location => "location",
+ RadrootsTagSemantic::Participant => "participant",
RadrootsTagSemantic::PreviousEvent => "previous_event",
RadrootsTagSemantic::Price => "price",
RadrootsTagSemantic::PublishedAt => "published_at",
RadrootsTagSemantic::Relay => "relay",
+ RadrootsTagSemantic::Reference => "reference",
RadrootsTagSemantic::ReviewTarget => "review_target",
RadrootsTagSemantic::RootEvent => "root_event",
RadrootsTagSemantic::ServiceInput => "service_input",
@@ -357,25 +362,31 @@ fn tag_semantic_label(value: RadrootsTagSemantic) -> &'static str {
RadrootsTagSemantic::Summary => "summary",
RadrootsTagSemantic::Title => "title",
RadrootsTagSemantic::Topic => "topic",
+ RadrootsTagSemantic::TimeZone => "time_zone",
RadrootsTagSemantic::Url => "url",
+ RadrootsTagSemantic::UtcDayCoverage => "utc_day_coverage",
}
}
fn tag_value_type_label(value: RadrootsTagValueType) -> &'static str {
match value {
RadrootsTagValueType::AddressableCoordinate => "addressable_coordinate",
+ RadrootsTagValueType::CalendarDate => "calendar_date",
RadrootsTagValueType::ContractId => "contract_id",
RadrootsTagValueType::DTag => "d_tag",
RadrootsTagValueType::EventId => "event_id",
RadrootsTagValueType::EventPointer => "event_pointer",
RadrootsTagValueType::Geohash => "geohash",
+ RadrootsTagValueType::IanaTimeZoneId => "iana_time_zone_id",
RadrootsTagValueType::Kind => "kind",
RadrootsTagValueType::PublicKey => "public_key",
RadrootsTagValueType::RelayUrl => "relay_url",
RadrootsTagValueType::Sha256 => "sha256",
RadrootsTagValueType::Text => "text",
RadrootsTagValueType::UnixTimestamp => "unix_timestamp",
+ RadrootsTagValueType::Uri => "uri",
RadrootsTagValueType::Url => "url",
+ RadrootsTagValueType::UtcDayIndex => "utc_day_index",
RadrootsTagValueType::Uuid => "uuid",
}
}
diff --git a/crates/event_codec/src/social_helpers.rs b/crates/event_codec/src/social_helpers.rs
@@ -22,61 +22,6 @@ pub(crate) fn validate_http_url(value: &str, field: &'static str) -> Result<(),
}
}
-pub(crate) fn validate_date(value: &str, field: &'static str) -> Result<(), EventEncodeError> {
- if is_date(value) {
- Ok(())
- } else {
- Err(EventEncodeError::InvalidField(field))
- }
-}
-
-pub(crate) fn validate_date_tag(value: &str, tag: &'static str) -> Result<(), EventParseError> {
- if is_date(value) {
- Ok(())
- } else {
- Err(EventParseError::InvalidTag(tag))
- }
-}
-
-pub(crate) fn is_date(value: &str) -> bool {
- let bytes = value.as_bytes();
- bytes.len() == 10
- && bytes[0].is_ascii_digit()
- && bytes[1].is_ascii_digit()
- && bytes[2].is_ascii_digit()
- && bytes[3].is_ascii_digit()
- && bytes[4] == b'-'
- && bytes[5].is_ascii_digit()
- && bytes[6].is_ascii_digit()
- && bytes[7] == b'-'
- && bytes[8].is_ascii_digit()
- && bytes[9].is_ascii_digit()
-}
-
-pub(crate) fn validate_end_after_start(
- start: u64,
- end: Option<u64>,
- field: &'static str,
-) -> Result<(), EventEncodeError> {
- if end.is_some_and(|end| end < start) {
- Err(EventEncodeError::InvalidField(field))
- } else {
- Ok(())
- }
-}
-
-pub(crate) fn validate_date_end_after_start(
- start: &str,
- end: Option<&str>,
- field: &'static str,
-) -> Result<(), EventEncodeError> {
- if end.is_some_and(|end| end < start) {
- Err(EventEncodeError::InvalidField(field))
- } else {
- Ok(())
- }
-}
-
pub(crate) fn push_location_tags(tags: &mut Vec<Vec<String>>, location: &RadrootsSocialLocation) {
if let Some(name) = location
.name
@@ -216,51 +161,13 @@ mod tests {
use super::*;
#[test]
- fn validates_dates_and_ordered_time_ranges() {
- assert!(is_date("2026-06-20"));
- assert!(!is_date("2026-6-20"));
- for invalid in [
- "x026-06-20",
- "2x26-06-20",
- "20x6-06-20",
- "202x-06-20",
- "2026/06-20",
- "2026-x6-20",
- "2026-0x-20",
- "2026-06/20",
- "2026-06-x0",
- "2026-06-2x",
- ] {
- assert!(!is_date(invalid));
- }
+ fn validates_http_urls() {
assert!(validate_http_url("https://example.test/file", "url").is_ok());
assert!(validate_http_url("http://example.test/file", "url").is_ok());
assert!(matches!(
validate_http_url("ftp://example.test/file", "url"),
Err(EventEncodeError::InvalidField("url"))
));
- assert!(validate_date("2026-06-20", "date").is_ok());
- assert!(matches!(
- validate_date("bad", "date"),
- Err(EventEncodeError::InvalidField("date"))
- ));
- assert!(validate_date_tag("2026-06-20", "start").is_ok());
- assert!(validate_end_after_start(10, Some(10), "end").is_ok());
- assert!(validate_end_after_start(10, None, "end").is_ok());
- assert!(matches!(
- validate_end_after_start(10, Some(9), "end"),
- Err(EventEncodeError::InvalidField("end"))
- ));
- assert!(validate_date_end_after_start("2026-06-20", None, "end").is_ok());
- assert!(validate_date_end_after_start("2026-06-20", Some("2026-06-20"), "end").is_ok());
- assert!(matches!(
- validate_date_end_after_start("2026-06-20", Some("2026-06-19"), "end"),
- Err(EventEncodeError::InvalidField("end"))
- ));
- assert!(matches!(
- validate_date_tag("bad", "start"),
- Err(EventParseError::InvalidTag("start"))
- ));
}
#[test]
diff --git a/crates/event_codec/tests/calendar.rs b/crates/event_codec/tests/calendar.rs
@@ -1,884 +1,625 @@
-#![cfg(feature = "serde_json")]
-
+use radroots_blossom::{
+ RadrootsBlossomBlobDescriptor, RadrootsBlossomBlobUrl, RadrootsBlossomMediaType,
+ RadrootsBlossomSha256,
+};
use radroots_event::{
+ RadrootsAuthoredImage, RadrootsEventTags,
calendar::{
- RadrootsCalendar, RadrootsCalendarDateEvent, RadrootsCalendarEventRsvp,
- RadrootsCalendarTimeEvent,
- },
- kinds::{
- KIND_ARTICLE, KIND_CALENDAR, KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_EVENT_RSVP,
- KIND_CALENDAR_TIME_EVENT, KIND_POST,
- },
- social::{
- RadrootsCalendarDateValue, RadrootsCalendarEventFreeBusy, RadrootsCalendarEventRsvpStatus,
- RadrootsCalendarParticipant, RadrootsSocialLocation, RadrootsSocialTarget,
+ RADROOTS_CALENDAR_MAX_COVERED_UTC_DAYS, RADROOTS_CALENDAR_SECONDS_PER_DAY,
+ RadrootsAuthoredCalendarDateEvent, RadrootsAuthoredCalendarTimeEvent,
+ RadrootsCalendarAdmissionError, RadrootsCalendarDate, RadrootsCalendarEventError,
+ RadrootsIanaTimeZoneId, covered_utc_days,
},
+ kinds::{KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_TIME_EVENT, KIND_POST},
+ social::RadrootsCalendarParticipant,
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_START, TAG_START_TZID, TAG_STATUS, TAG_SUMMARY, TAG_TITLE,
+ 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,
},
+ wire::{DEFAULT_CONTENT_MAX_BYTES, DEFAULT_TAG_ELEMENT_MAX_BYTES, DEFAULT_TAG_MAX_COUNT},
};
use radroots_event_codec::{
calendar::{
decode::{
- calendar_data_from_event, calendar_date_event_from_event, calendar_from_event,
- calendar_parsed_from_event, calendar_time_event_from_event, date_data_from_event,
- date_parsed_from_event, rsvp_data_from_event, rsvp_from_event, rsvp_parsed_from_event,
- time_data_from_event, time_parsed_from_event,
+ admit_radroots_calendar_date_event, admit_radroots_calendar_time_event,
+ nip52_date_data_from_event, nip52_date_parsed_from_event,
+ parse_nip52_calendar_date_event, parse_nip52_calendar_time_event,
},
encode::{
- calendar_collection_build_tags, 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_build_tags, rsvp_to_wire_parts, rsvp_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, date_to_wire_parts,
+ date_to_wire_parts_with_kind, time_to_wire_parts, time_to_wire_parts_with_kind,
},
},
error::{EventEncodeError, EventParseError},
};
-const VALID_D_TAG: &str = "CCCCCCCCCCCCCCCCCCCCCA";
+const DATE_D_TAG: &str = "fernwood-csa-pickup";
+const TIME_D_TAG: &str = "wash-pack-shift";
const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const EVENT_AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+const SECOND_AUTHOR: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
const EVENT_SIG: &str = concat!(
- "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
- "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
+ "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
+ "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
);
-const EVENT_D_TAG: &str = "EEEEEEEEEEEEEEEEEEEEEA";
-
-fn sample_date_event() -> RadrootsCalendarDateEvent {
- RadrootsCalendarDateEvent {
- d_tag: VALID_D_TAG.to_string(),
- title: "CSA pickup".to_string(),
- start: "2026-06-20".to_string(),
- description: Some("Bring clean bins to the farm stand.".to_string()),
- end: Some("2026-06-21".to_string()),
- days: Some(vec![RadrootsCalendarDateValue {
- value: "2026-06-20".to_string(),
- }]),
- location: Some(RadrootsSocialLocation {
- name: Some("Farm stand".to_string()),
- geohash: Some("c23nb62w20st".to_string()),
- }),
- summary: Some("Weekly pickup".to_string()),
- image: Some("https://media.example.test/calendar.jpg".to_string()),
- participants: Some(vec![RadrootsCalendarParticipant {
- pubkey: "host_pubkey".to_string(),
- relay: Some("wss://relay.example.test".to_string()),
- role: Some("host".to_string()),
- }]),
- }
+
+fn date(value: &str) -> RadrootsCalendarDate {
+ RadrootsCalendarDate::parse(value).unwrap()
}
-fn sample_time_event() -> RadrootsCalendarTimeEvent {
- RadrootsCalendarTimeEvent {
- d_tag: VALID_D_TAG.to_string(),
- title: "Wash pack shift".to_string(),
- start: 1_781_895_600,
- dates: vec![RadrootsCalendarDateValue {
- value: "2026-06-20".to_string(),
- }],
- description: Some("Prepare CSA bins before pickup.".to_string()),
- end: Some(1_781_899_200),
- start_tzid: Some("America/Vancouver".to_string()),
- end_tzid: Some("America/Vancouver".to_string()),
- location: Some(RadrootsSocialLocation {
- name: Some("Pack shed".to_string()),
- geohash: Some("c23nb62w20st".to_string()),
- }),
- summary: Some("Prepare CSA bins".to_string()),
- image: None,
- participants: Some(vec![RadrootsCalendarParticipant {
- pubkey: "crew_pubkey".to_string(),
- relay: None,
- role: Some("participant".to_string()),
- }]),
- }
+fn authored_image() -> RadrootsAuthoredImage {
+ let bytes = b"canonical-calendar-image";
+ let hash = RadrootsBlossomSha256::digest(bytes);
+ let media_type = RadrootsBlossomMediaType::parse("image/webp").unwrap();
+ let descriptor = RadrootsBlossomBlobDescriptor::new(
+ RadrootsBlossomBlobUrl::parse(&format!("https://media.example.test/{hash}.webp")).unwrap(),
+ hash,
+ bytes.len() as u64,
+ media_type.clone(),
+ 1_784_347_200,
+ )
+ .unwrap()
+ .approve_reference()
+ .unwrap()
+ .verify_bytes(bytes, &media_type)
+ .unwrap();
+ RadrootsAuthoredImage::try_from(descriptor).unwrap()
}
-fn sample_calendar_collection() -> RadrootsCalendar {
- RadrootsCalendar {
- d_tag: VALID_D_TAG.to_string(),
- title: "Farm calendar".to_string(),
- events: vec![RadrootsSocialTarget::Address {
- address: format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{EVENT_D_TAG}"),
- author: Some(EVENT_AUTHOR.to_string()),
- event_kind: Some(KIND_CALENDAR_TIME_EVENT),
- relays: Some(vec!["wss://relay.example.test".to_string()]),
- }],
- description: Some("Shared schedule for farm operations.".to_string()),
- summary: Some("CSA and harvest schedule".to_string()),
- image: Some("https://media.example.test/calendar.jpg".to_string()),
- }
+fn sample_date_event() -> RadrootsAuthoredCalendarDateEvent {
+ RadrootsAuthoredCalendarDateEvent::new(DATE_D_TAG, "CSA pickup", date("2026-06-20"))
+ .unwrap()
+ .with_end(date("2026-06-21"))
+ .unwrap()
+ .with_description("Bring clean bins to the farm stand.")
+ .unwrap()
+ .with_locations(vec!["Farm stand".to_string()])
+ .unwrap()
+ .with_geohash("c23nb62w20st")
+ .unwrap()
+ .with_summary("Weekly pickup")
+ .unwrap()
+ .with_image(authored_image())
+ .unwrap()
+ .with_participants(vec![RadrootsCalendarParticipant {
+ pubkey: EVENT_AUTHOR.to_string(),
+ relay: Some("wss://relay.example.test".to_string()),
+ role: Some("host".to_string()),
+ }])
+ .unwrap()
+ .with_categories(vec!["csa".to_string(), "vegetables".to_string()])
+ .unwrap()
}
-fn sample_rsvp() -> RadrootsCalendarEventRsvp {
- RadrootsCalendarEventRsvp {
- d_tag: VALID_D_TAG.to_string(),
- event: RadrootsSocialTarget::Address {
- address: format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{EVENT_D_TAG}"),
- author: Some(EVENT_AUTHOR.to_string()),
- event_kind: Some(KIND_CALENDAR_TIME_EVENT),
- relays: Some(vec!["wss://relay.example.test".to_string()]),
- },
- event_id: Some(EVENT_ID.to_string()),
- status: RadrootsCalendarEventRsvpStatus::Accepted,
- free_busy: Some(RadrootsCalendarEventFreeBusy::Busy),
- note: Some("I can attend after harvest".to_string()),
- participants: Some(vec![RadrootsCalendarParticipant {
- pubkey: "crew_pubkey".to_string(),
+fn sample_time_event() -> RadrootsAuthoredCalendarTimeEvent {
+ RadrootsAuthoredCalendarTimeEvent::new(TIME_D_TAG, "Wash pack shift", 86_399)
+ .unwrap()
+ .with_end(172_801)
+ .unwrap()
+ .with_description("Prepare CSA bins before pickup.")
+ .unwrap()
+ .with_start_tzid("America/Vancouver")
+ .unwrap()
+ .with_locations(vec!["Pack shed".to_string()])
+ .unwrap()
+ .with_geohash("c23nb62w20st")
+ .unwrap()
+ .with_summary("Prepare CSA bins")
+ .unwrap()
+ .with_participants(vec![RadrootsCalendarParticipant {
+ pubkey: EVENT_AUTHOR.to_string(),
relay: None,
role: Some("participant".to_string()),
- }]),
- }
+ }])
+ .unwrap()
+ .with_categories(vec!["farm-work".to_string()])
+ .unwrap()
+}
+
+fn baseline_common_tags(image: &str) -> Vec<Vec<String>> {
+ vec![
+ vec![TAG_D.to_string(), DATE_D_TAG.to_string()],
+ vec![TAG_TITLE.to_string(), " CSA pickup ".to_string()],
+ vec![TAG_LOCATION.to_string(), "Farm stand".to_string()],
+ vec![TAG_LOCATION.to_string(), "Moss Street Market".to_string()],
+ vec![TAG_G.to_string(), "C23NB62W20ST".to_string()],
+ vec![TAG_SUMMARY.to_string(), " Weekly pickup ".to_string()],
+ vec![TAG_IMAGE.to_string(), image.to_string()],
+ vec![TAG_P.to_string(), EVENT_AUTHOR.to_string()],
+ vec![
+ TAG_P.to_string(),
+ SECOND_AUTHOR.to_string(),
+ "wss://relay.example.test/events".to_string(),
+ ],
+ vec![
+ TAG_P.to_string(),
+ EVENT_AUTHOR.to_string(),
+ String::new(),
+ " host ".to_string(),
+ ],
+ vec![TAG_T.to_string(), "vegetables".to_string()],
+ vec![TAG_T.to_string(), "CSA".to_string()],
+ vec![
+ TAG_R.to_string(),
+ "ipfs://bafybeigdyrzt/reference".to_string(),
+ ],
+ vec![
+ TAG_A.to_string(),
+ format!("31924:{EVENT_AUTHOR}:farm-calendar"),
+ "wss://relay.example.test".to_string(),
+ ],
+ vec!["name".to_string(), "Deprecated display name".to_string()],
+ ]
}
fn has_tag(tags: &[Vec<String>], key: &str, value: &str) -> bool {
tags.iter().any(|tag| {
- tag.first().map(|entry| entry.as_str()) == Some(key)
- && tag.get(1).map(|entry| entry.as_str()) == Some(value)
+ tag.first().map(String::as_str) == Some(key)
+ && tag.get(1).map(String::as_str) == Some(value)
})
}
+fn tag_values<'a>(tags: &'a [Vec<String>], key: &str) -> Vec<&'a str> {
+ tags.iter()
+ .filter(|tag| tag.first().map(String::as_str) == Some(key))
+ .map(|tag| tag[1].as_str())
+ .collect()
+}
+
fn replace_tag_value(tags: &mut [Vec<String>], key: &str, value: &str) {
let tag = tags
.iter_mut()
- .find(|tag| tag.first().map(|entry| entry.as_str()) == Some(key))
+ .find(|tag| tag.first().map(String::as_str) == Some(key))
.expect("tag");
tag[1] = value.to_string();
}
#[test]
-fn calendar_date_event_to_wire_parts_roundtrips_tags() {
+fn authored_date_event_emits_canonical_fields_and_no_uppercase_day_tag() {
let event = sample_date_event();
let parts = date_to_wire_parts(&event).unwrap();
assert_eq!(parts.kind, KIND_CALENDAR_DATE_EVENT);
assert_eq!(parts.content, "Bring clean bins to the farm stand.");
- assert!(has_tag(&parts.tags, TAG_D, VALID_D_TAG));
+ assert!(has_tag(&parts.tags, TAG_D, DATE_D_TAG));
assert!(has_tag(&parts.tags, TAG_TITLE, "CSA pickup"));
assert!(has_tag(&parts.tags, TAG_START, "2026-06-20"));
assert!(has_tag(&parts.tags, TAG_END, "2026-06-21"));
- assert!(has_tag(&parts.tags, TAG_D_DAY, "2026-06-20"));
assert!(has_tag(&parts.tags, TAG_LOCATION, "Farm stand"));
assert!(has_tag(&parts.tags, TAG_G, "c23nb62w20st"));
- assert!(has_tag(&parts.tags, TAG_SUMMARY, "Weekly pickup"));
- assert!(has_tag(
- &parts.tags,
- TAG_IMAGE,
- "https://media.example.test/calendar.jpg"
- ));
- assert!(has_tag(&parts.tags, TAG_P, "host_pubkey"));
-
- let decoded = calendar_date_event_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
- assert_eq!(decoded.d_tag, VALID_D_TAG);
- assert_eq!(decoded.title, "CSA pickup");
- assert_eq!(
- decoded.description.as_deref(),
- Some("Bring clean bins to the farm stand.")
- );
- assert_eq!(decoded.start, "2026-06-20");
- assert_eq!(decoded.end.as_deref(), Some("2026-06-21"));
- assert_eq!(decoded.days.as_ref().map(Vec::len), Some(1));
- assert_eq!(
- decoded
- .location
- .as_ref()
- .and_then(|location| location.name.as_deref()),
- Some("Farm stand")
- );
- assert_eq!(decoded.participants.as_ref().map(Vec::len), Some(1));
+ assert!(has_tag(&parts.tags, TAG_T, "csa"));
+ assert!(has_tag(&parts.tags, TAG_T, "vegetables"));
+ assert!(!parts.tags.iter().any(|tag| tag[0] == TAG_D_DAY));
+ assert_eq!(calendar_date_event_build_tags(&event).unwrap(), parts.tags);
+
+ let parsed = parse_nip52_calendar_date_event(parts.kind, &parts.tags, &parts.content).unwrap();
+ let admitted = admit_radroots_calendar_date_event(parsed).unwrap();
+ assert_eq!(admitted.d_tag().as_str(), DATE_D_TAG);
+ assert!(admitted.blossom_image().is_some());
}
#[test]
-fn calendar_time_event_to_wire_parts_roundtrips_tags() {
+fn authored_time_event_derives_exact_ordered_days_and_iana_fallback() {
let event = sample_time_event();
- let parts = time_to_wire_parts(&event).unwrap();
+ assert_eq!(
+ event
+ .effective_end_tzid()
+ .map(RadrootsIanaTimeZoneId::as_str),
+ Some("America/Vancouver")
+ );
+ assert_eq!(event.end_tzid(), None);
+ let parts = time_to_wire_parts(&event).unwrap();
assert_eq!(parts.kind, KIND_CALENDAR_TIME_EVENT);
- assert_eq!(parts.content, "Prepare CSA bins before pickup.");
- assert!(has_tag(&parts.tags, TAG_D, VALID_D_TAG));
- assert!(has_tag(&parts.tags, TAG_TITLE, "Wash pack shift"));
- assert!(has_tag(&parts.tags, TAG_START, "1781895600"));
- assert!(has_tag(&parts.tags, TAG_D_DAY, "2026-06-20"));
- assert!(has_tag(&parts.tags, TAG_END, "1781899200"));
+ assert_eq!(tag_values(&parts.tags, TAG_D_DAY), ["0", "1", "2"]);
assert!(has_tag(&parts.tags, TAG_START_TZID, "America/Vancouver"));
- assert!(has_tag(&parts.tags, TAG_END_TZID, "America/Vancouver"));
- assert!(has_tag(&parts.tags, TAG_LOCATION, "Pack shed"));
- assert!(has_tag(&parts.tags, TAG_P, "crew_pubkey"));
+ assert!(!parts.tags.iter().any(|tag| tag[0] == TAG_END_TZID));
+ assert_eq!(calendar_time_event_build_tags(&event).unwrap(), parts.tags);
- let decoded = calendar_time_event_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
- assert_eq!(decoded.d_tag, VALID_D_TAG);
- assert_eq!(decoded.title, "Wash pack shift");
- assert_eq!(
- decoded.description.as_deref(),
- Some("Prepare CSA bins before pickup.")
- );
- assert_eq!(decoded.start, 1_781_895_600);
- assert_eq!(decoded.dates.len(), 1);
- assert_eq!(decoded.end, Some(1_781_899_200));
- assert_eq!(decoded.start_tzid.as_deref(), Some("America/Vancouver"));
- assert_eq!(decoded.participants.as_ref().map(Vec::len), Some(1));
+ let parsed = parse_nip52_calendar_time_event(parts.kind, &parts.tags, &parts.content).unwrap();
+ let admitted = admit_radroots_calendar_time_event(parsed).unwrap();
+ assert_eq!(admitted.covered_utc_days(), [0, 1, 2]);
}
#[test]
-fn calendar_collection_to_wire_parts_roundtrips_event_addresses() {
- let calendar = sample_calendar_collection();
- let parts = calendar_to_wire_parts(&calendar).unwrap();
-
- assert_eq!(parts.kind, KIND_CALENDAR);
- assert_eq!(parts.content, "Shared schedule for farm operations.");
- assert!(has_tag(&parts.tags, TAG_D, VALID_D_TAG));
- assert!(has_tag(&parts.tags, TAG_TITLE, "Farm calendar"));
- assert!(has_tag(
- &parts.tags,
- TAG_A,
- format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{EVENT_D_TAG}").as_str()
- ));
-
- let decoded = calendar_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
- assert_eq!(decoded.d_tag, VALID_D_TAG);
- assert_eq!(decoded.title, "Farm calendar");
+fn baseline_date_parser_preserves_nip52_extensions_and_common_metadata() {
+ let image = "https://ordinary.example.test/images/market.jpg?size=large";
+ let mut tags = baseline_common_tags(image);
+ tags.push(vec![TAG_START.to_string(), "2026-06-20".to_string()]);
+ tags.push(vec![TAG_END.to_string(), "2026-06-22".to_string()]);
+ tags.push(vec![
+ TAG_D_DAY.to_string(),
+ "extension".to_string(),
+ "preserved".to_string(),
+ ]);
+
+ let parsed =
+ parse_nip52_calendar_date_event(KIND_CALENDAR_DATE_EVENT, &tags, "Bring reusable bags.")
+ .unwrap();
+ let common = parsed.common();
+ assert_eq!(common.d_tag(), DATE_D_TAG);
+ assert_eq!(common.title(), " CSA pickup ");
+ assert_eq!(common.description(), Some("Bring reusable bags."));
+ assert_eq!(common.locations(), ["Farm stand", "Moss Street Market"]);
+ assert_eq!(common.geohash(), Some("C23NB62W20ST"));
+ assert_eq!(common.summary(), Some(" Weekly pickup "));
+ assert_eq!(common.image().unwrap().as_str(), image);
+ assert_eq!(common.participants().len(), 3);
+ assert_eq!(common.participants()[2].relay, None);
+ assert_eq!(common.participants()[2].role.as_deref(), Some(" host "));
+ assert_eq!(common.categories(), ["vegetables", "CSA"]);
assert_eq!(
- decoded.description.as_deref(),
- Some("Shared schedule for farm operations.")
+ common.references()[0].as_str(),
+ "ipfs://bafybeigdyrzt/reference"
+ );
+ assert_eq!(
+ common.calendar_requests()[0].calendar().as_str(),
+ format!("31924:{EVENT_AUTHOR}:farm-calendar")
+ );
+ assert_eq!(common.legacy_name(), Some("Deprecated display name"));
+ assert_eq!(parsed.start().as_str(), "2026-06-20");
+ assert_eq!(parsed.end().unwrap().as_str(), "2026-06-22");
+ assert_eq!(
+ parsed.extension_day_tags(),
+ [vec![
+ TAG_D_DAY.to_string(),
+ "extension".to_string(),
+ "preserved".to_string()
+ ]]
);
- assert_eq!(decoded.events.len(), 1);
- assert!(matches!(
- decoded.events[0],
- RadrootsSocialTarget::Address {
- event_kind: Some(KIND_CALENDAR_TIME_EVENT),
- ..
- }
- ));
-}
-
-#[test]
-fn calendar_rsvp_to_wire_parts_roundtrips_status_event_id_and_participants() {
- let rsvp = sample_rsvp();
- let parts = rsvp_to_wire_parts(&rsvp).unwrap();
-
- assert_eq!(parts.kind, KIND_CALENDAR_EVENT_RSVP);
- assert_eq!(parts.content, "I can attend after harvest");
- assert!(has_tag(&parts.tags, TAG_D, VALID_D_TAG));
- assert!(has_tag(
- &parts.tags,
- TAG_A,
- format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{EVENT_D_TAG}").as_str()
- ));
- assert!(has_tag(&parts.tags, TAG_E, EVENT_ID));
- assert!(has_tag(&parts.tags, TAG_STATUS, "accepted"));
- assert!(has_tag(&parts.tags, TAG_FREE_BUSY, "busy"));
- assert!(has_tag(&parts.tags, TAG_P, "crew_pubkey"));
-
- let decoded = rsvp_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
- assert_eq!(decoded.event_id.as_deref(), Some(EVENT_ID));
- assert_eq!(decoded.status, RadrootsCalendarEventRsvpStatus::Accepted);
- assert_eq!(decoded.free_busy, Some(RadrootsCalendarEventFreeBusy::Busy));
- assert_eq!(decoded.note.as_deref(), Some("I can attend after harvest"));
- assert_eq!(decoded.participants.as_ref().map(Vec::len), Some(1));
}
#[test]
-fn calendar_encode_omits_absent_optional_fields() {
- let mut time = sample_time_event();
- time.end = None;
- time.location = None;
- let tags = calendar_time_event_build_tags(&time).unwrap();
- assert!(!tags.iter().any(|tag| tag[0] == TAG_END));
- assert!(!tags.iter().any(|tag| tag[0] == TAG_LOCATION));
-
- let mut collection = sample_calendar_collection();
- collection.events[0] = RadrootsSocialTarget::Address {
- address: format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{EVENT_D_TAG}"),
- author: Some(EVENT_AUTHOR.to_string()),
- event_kind: None,
- relays: None,
- };
- let tags = calendar_collection_build_tags(&collection).unwrap();
+fn baseline_time_parser_accepts_nip52_should_level_variance() {
+ let mut tags = vec![
+ vec![TAG_D.to_string(), TIME_D_TAG.to_string()],
+ vec![TAG_TITLE.to_string(), "Harvest window".to_string()],
+ vec![TAG_START.to_string(), "086399".to_string()],
+ vec![TAG_END.to_string(), "172801".to_string()],
+ vec![TAG_D_DAY.to_string(), "000".to_string()],
+ vec![TAG_D_DAY.to_string(), "2".to_string()],
+ vec![TAG_START_TZID.to_string(), "America/Vancouver".to_string()],
+ ];
+ tags.extend([
+ vec![TAG_T.to_string(), "harvest".to_string()],
+ vec![
+ TAG_R.to_string(),
+ "https://example.test/harvest".to_string(),
+ ],
+ ]);
+
+ let parsed = parse_nip52_calendar_time_event(KIND_CALENDAR_TIME_EVENT, &tags, "").unwrap();
+ assert_eq!(parsed.start_wire(), "086399");
+ assert_eq!(parsed.start(), 86_399);
+ assert_eq!(parsed.end(), Some(172_801));
assert_eq!(
- tags.iter()
- .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_A))
- .map(Vec::len),
- Some(2)
+ parsed
+ .observed_day_indices()
+ .iter()
+ .map(|day| (day.wire_value(), day.index(), day.is_canonical()))
+ .collect::<Vec<_>>(),
+ vec![("000", 0, false), ("2", 2, true)]
);
-
- let mut rsvp = sample_rsvp();
- rsvp.event = RadrootsSocialTarget::Address {
- address: format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{EVENT_D_TAG}"),
- author: Some(EVENT_AUTHOR.to_string()),
- event_kind: None,
- relays: None,
- };
- rsvp.free_busy = None;
- let tags = rsvp_build_tags(&rsvp).unwrap();
assert_eq!(
- tags.iter()
- .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_E))
- .map(Vec::len),
- Some(2)
+ parsed
+ .effective_end_tzid()
+ .map(RadrootsIanaTimeZoneId::as_str),
+ Some("America/Vancouver")
);
- assert!(!tags.iter().any(|tag| tag[0] == TAG_FREE_BUSY));
}
#[test]
-fn calendar_codecs_reject_wrong_kind_invalid_dates_and_missing_time_dates() {
- assert!(matches!(
- date_to_wire_parts_with_kind(&sample_date_event(), KIND_POST),
- Err(EventEncodeError::InvalidKind(KIND_POST))
- ));
- assert!(matches!(
- time_to_wire_parts_with_kind(&sample_time_event(), KIND_POST),
- Err(EventEncodeError::InvalidKind(KIND_POST))
- ));
-
- let mut event = sample_date_event();
- event.start = "2026-6-20".to_string();
- assert!(matches!(
- calendar_date_event_build_tags(&event),
- Err(EventEncodeError::InvalidField("start"))
- ));
-
- let mut event = sample_time_event();
- event.end = Some(event.start - 1);
- assert!(matches!(
- calendar_time_event_build_tags(&event),
- Err(EventEncodeError::InvalidField("end"))
- ));
-
- let mut event = sample_time_event();
- event.dates.clear();
- assert!(matches!(
- calendar_time_event_build_tags(&event),
- Err(EventEncodeError::EmptyRequiredField("dates"))
- ));
-
- let tags = calendar_date_event_build_tags(&sample_date_event()).unwrap();
- let decoded = calendar_date_event_from_event(KIND_CALENDAR_DATE_EVENT, &tags, "body").unwrap();
- assert_eq!(decoded.description.as_deref(), Some("body"));
-
- let mut tags = calendar_date_event_build_tags(&sample_date_event()).unwrap();
- let start = tags
- .iter_mut()
- .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_START))
- .expect("start tag");
- start[1] = "bad".to_string();
- assert!(matches!(
- calendar_date_event_from_event(KIND_CALENDAR_DATE_EVENT, &tags, ""),
- Err(EventParseError::InvalidTag(TAG_START))
- ));
+fn strict_admission_rejects_date_extensions_noncanonical_metadata_and_non_blossom_images() {
+ let mut extension_tags = vec![
+ vec![TAG_D.to_string(), DATE_D_TAG.to_string()],
+ vec![TAG_TITLE.to_string(), "CSA pickup".to_string()],
+ vec![TAG_START.to_string(), "2026-06-20".to_string()],
+ vec![TAG_D_DAY.to_string(), "20620".to_string()],
+ ];
+ let parsed =
+ parse_nip52_calendar_date_event(KIND_CALENDAR_DATE_EVENT, &extension_tags, "").unwrap();
+ assert_eq!(
+ admit_radroots_calendar_date_event(parsed),
+ Err(RadrootsCalendarAdmissionError::ForbiddenDateDayIndex)
+ );
- let mut tags = calendar_time_event_build_tags(&sample_time_event()).unwrap();
- tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some(TAG_D_DAY));
- assert!(matches!(
- calendar_time_event_from_event(KIND_CALENDAR_TIME_EVENT, &tags, ""),
- Err(EventParseError::MissingTag(TAG_D_DAY))
- ));
+ extension_tags.retain(|tag| tag[0] != TAG_D_DAY);
+ replace_tag_value(&mut extension_tags, TAG_TITLE, " CSA pickup ");
+ let parsed =
+ parse_nip52_calendar_date_event(KIND_CALENDAR_DATE_EVENT, &extension_tags, "").unwrap();
+ assert_eq!(
+ admit_radroots_calendar_date_event(parsed),
+ Err(RadrootsCalendarAdmissionError::NonCanonicalField(
+ "metadata"
+ ))
+ );
- let err = calendar_time_event_from_event(KIND_POST, &tags, "").unwrap_err();
- assert!(matches!(
- err,
- EventParseError::InvalidKind {
- expected: "31923",
- got: KIND_POST
- }
- ));
+ replace_tag_value(&mut extension_tags, TAG_TITLE, "CSA pickup");
+ extension_tags.push(vec![
+ TAG_IMAGE.to_string(),
+ "https://ordinary.example.test/market.jpg".to_string(),
+ ]);
+ let parsed =
+ parse_nip52_calendar_date_event(KIND_CALENDAR_DATE_EVENT, &extension_tags, "").unwrap();
+ assert_eq!(
+ admit_radroots_calendar_date_event(parsed),
+ Err(RadrootsCalendarAdmissionError::NonBlossomImage)
+ );
}
#[test]
-fn calendar_collection_and_rsvp_reject_missing_or_invalid_required_tags() {
- assert!(matches!(
- calendar_to_wire_parts_with_kind(&sample_calendar_collection(), KIND_POST),
- Err(EventEncodeError::InvalidKind(KIND_POST))
- ));
- assert!(matches!(
- rsvp_to_wire_parts_with_kind(&sample_rsvp(), KIND_POST),
- Err(EventEncodeError::InvalidKind(KIND_POST))
- ));
-
- let mut calendar = sample_calendar_collection();
- calendar.events.clear();
- assert!(matches!(
- calendar_collection_build_tags(&calendar),
- Err(EventEncodeError::EmptyRequiredField("events"))
- ));
-
- let mut rsvp = sample_rsvp();
- if let RadrootsSocialTarget::Address { event_kind, .. } = &mut rsvp.event {
- *event_kind = Some(KIND_ARTICLE);
+fn strict_time_admission_requires_canonical_exact_coverage() {
+ let base = vec![
+ vec![TAG_D.to_string(), TIME_D_TAG.to_string()],
+ vec![TAG_TITLE.to_string(), "Harvest window".to_string()],
+ vec![TAG_START.to_string(), "86399".to_string()],
+ vec![TAG_END.to_string(), "172801".to_string()],
+ ];
+
+ for days in [
+ vec!["0", "2"],
+ vec!["0", "2", "1"],
+ vec!["0", "1", "1", "2"],
+ vec!["00", "1", "2"],
+ ] {
+ let mut tags = base.clone();
+ tags.extend(
+ days.into_iter()
+ .map(|day| vec![TAG_D_DAY.to_string(), day.to_string()]),
+ );
+ let parsed = parse_nip52_calendar_time_event(KIND_CALENDAR_TIME_EVENT, &tags, "").unwrap();
+ assert_eq!(
+ admit_radroots_calendar_time_event(parsed),
+ Err(RadrootsCalendarAdmissionError::IncompleteDayCoverage)
+ );
}
- assert!(matches!(
- rsvp_build_tags(&rsvp),
- Err(EventEncodeError::InvalidField("event"))
- ));
-
- let mut tags = calendar_collection_build_tags(&sample_calendar_collection()).unwrap();
- tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some(TAG_A));
- assert!(matches!(
- calendar_from_event(KIND_CALENDAR, &tags, ""),
- Err(EventParseError::MissingTag(TAG_A))
- ));
-
- let mut tags = rsvp_build_tags(&sample_rsvp()).unwrap();
- let status = tags
- .iter_mut()
- .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_STATUS))
- .expect("status tag");
- status[1] = "maybe".to_string();
- assert!(matches!(
- rsvp_from_event(KIND_CALENDAR_EVENT_RSVP, &tags, ""),
- Err(EventParseError::InvalidTag(TAG_STATUS))
- ));
- let mut tags = rsvp_build_tags(&sample_rsvp()).unwrap();
- let free_busy = tags
- .iter_mut()
- .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_FREE_BUSY))
- .expect("fb tag");
- free_busy[1] = "unknown".to_string();
- assert!(matches!(
- rsvp_from_event(KIND_CALENDAR_EVENT_RSVP, &tags, ""),
- Err(EventParseError::InvalidTag(TAG_FREE_BUSY))
- ));
+ let mut noncanonical_timestamp = base.clone();
+ replace_tag_value(&mut noncanonical_timestamp, TAG_START, "086399");
+ noncanonical_timestamp
+ .extend([0, 1, 2].map(|day| vec![TAG_D_DAY.to_string(), day.to_string()]));
+ let parsed =
+ parse_nip52_calendar_time_event(KIND_CALENDAR_TIME_EVENT, &noncanonical_timestamp, "")
+ .unwrap();
+ assert_eq!(
+ admit_radroots_calendar_time_event(parsed),
+ Err(RadrootsCalendarAdmissionError::NonCanonicalField(
+ "timestamp"
+ ))
+ );
}
#[test]
-fn calendar_date_codecs_cover_optional_and_error_edges() {
- let date_tags = calendar_date_event_build_tags(&sample_date_event()).unwrap();
- let wrong_kind = calendar_date_event_from_event(KIND_POST, &date_tags, "").unwrap_err();
- assert!(matches!(
- wrong_kind,
- EventParseError::InvalidKind {
- expected: "31922",
- got: KIND_POST
- }
- ));
-
- let mut minimal = sample_date_event();
- minimal.description = None;
- minimal.end = None;
- minimal.days = None;
- minimal.location = None;
- minimal.summary = None;
- minimal.image = None;
- minimal.participants = None;
- let parts = date_to_wire_parts(&minimal).unwrap();
- assert_eq!(parts.content, "");
- assert!(
- !parts
- .tags
- .iter()
- .any(|tag| tag.first().map(String::as_str) == Some(TAG_D_DAY))
+fn baseline_can_parse_overlong_ranges_that_strict_admission_rejects() {
+ let end = (RADROOTS_CALENDAR_MAX_COVERED_UTC_DAYS + 1) * RADROOTS_CALENDAR_SECONDS_PER_DAY;
+ let tags = vec![
+ vec![TAG_D.to_string(), TIME_D_TAG.to_string()],
+ vec![TAG_TITLE.to_string(), "Long range".to_string()],
+ vec![TAG_START.to_string(), "0".to_string()],
+ vec![TAG_END.to_string(), end.to_string()],
+ vec![TAG_D_DAY.to_string(), "0".to_string()],
+ ];
+ let parsed = parse_nip52_calendar_time_event(KIND_CALENDAR_TIME_EVENT, &tags, "").unwrap();
+ assert_eq!(parsed.end(), Some(end));
+ assert_eq!(
+ admit_radroots_calendar_time_event(parsed),
+ Err(RadrootsCalendarAdmissionError::CoveredDayLimitExceeded {
+ max: RADROOTS_CALENDAR_MAX_COVERED_UTC_DAYS,
+ actual: RADROOTS_CALENDAR_MAX_COVERED_UTC_DAYS + 1,
+ })
);
- let decoded = calendar_date_event_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
- assert!(decoded.description.is_none());
- assert!(decoded.end.is_none());
- assert!(decoded.days.is_none());
-
- let mut tags = calendar_date_event_build_tags(&sample_date_event()).unwrap();
- replace_tag_value(&mut tags, TAG_END, "2026-06-19");
- assert!(matches!(
- calendar_date_event_from_event(KIND_CALENDAR_DATE_EVENT, &tags, ""),
- Err(EventParseError::InvalidTag(TAG_END))
- ));
-
- let mut tags = calendar_date_event_build_tags(&sample_date_event()).unwrap();
- replace_tag_value(&mut tags, TAG_D_DAY, "2026-6-20");
- assert!(matches!(
- calendar_date_event_from_event(KIND_CALENDAR_DATE_EVENT, &tags, ""),
- Err(EventParseError::InvalidTag(TAG_D_DAY))
- ));
-
- let mut event = sample_date_event();
- event.title.clear();
- assert!(matches!(
- calendar_date_event_build_tags(&event),
- Err(EventEncodeError::EmptyRequiredField("title"))
- ));
-
- let mut event = sample_date_event();
- event.end = Some("2026-6-21".to_string());
- assert!(matches!(
- calendar_date_event_build_tags(&event),
- Err(EventEncodeError::InvalidField("end"))
- ));
-
- let mut event = sample_date_event();
- event.end = Some("2026-06-19".to_string());
- assert!(matches!(
- calendar_date_event_build_tags(&event),
- Err(EventEncodeError::InvalidField("end"))
- ));
}
#[test]
-fn calendar_time_codecs_cover_numeric_and_validation_edges() {
- let mut tags = calendar_time_event_build_tags(&sample_time_event()).unwrap();
- replace_tag_value(&mut tags, TAG_START, "not-a-number");
+fn authored_construction_rejects_invalid_fields_before_encoding() {
+ assert_eq!(
+ RadrootsAuthoredCalendarDateEvent::new(DATE_D_TAG, " CSA pickup ", date("2026-06-20")),
+ Err(RadrootsCalendarEventError::InvalidTitle)
+ );
+ assert_eq!(
+ RadrootsAuthoredCalendarTimeEvent::new(TIME_D_TAG, "Shift", 10)
+ .unwrap()
+ .with_start_tzid("america/vancouver"),
+ Err(RadrootsCalendarEventError::InvalidTimeZone)
+ );
assert!(matches!(
- calendar_time_event_from_event(KIND_CALENDAR_TIME_EVENT, &tags, ""),
- Err(EventParseError::InvalidNumber(TAG_START, _))
+ RadrootsAuthoredCalendarDateEvent::new(DATE_D_TAG, "Pickup", date("2026-06-20"))
+ .unwrap()
+ .with_summary("x".repeat(DEFAULT_TAG_ELEMENT_MAX_BYTES + 1)),
+ Err(RadrootsCalendarEventError::TagElementTooLarge {
+ field: "summary",
+ ..
+ })
));
-
- let mut tags = calendar_time_event_build_tags(&sample_time_event()).unwrap();
- replace_tag_value(&mut tags, TAG_END, "not-a-number");
assert!(matches!(
- calendar_time_event_from_event(KIND_CALENDAR_TIME_EVENT, &tags, ""),
- Err(EventParseError::InvalidNumber(TAG_END, _))
+ RadrootsAuthoredCalendarDateEvent::new(DATE_D_TAG, "Pickup", date("2026-06-20"))
+ .unwrap()
+ .with_description("x".repeat(DEFAULT_CONTENT_MAX_BYTES + 1)),
+ Err(RadrootsCalendarEventError::ContentTooLarge { .. })
));
- let mut tags = calendar_time_event_build_tags(&sample_time_event()).unwrap();
- replace_tag_value(&mut tags, TAG_D_DAY, "2026-6-20");
+ let invalid_participant = RadrootsCalendarParticipant {
+ pubkey: EVENT_AUTHOR.to_ascii_uppercase(),
+ relay: None,
+ role: None,
+ };
assert!(matches!(
- calendar_time_event_from_event(KIND_CALENDAR_TIME_EVENT, &tags, ""),
- Err(EventParseError::InvalidTag(TAG_D_DAY))
+ RadrootsAuthoredCalendarDateEvent::new(DATE_D_TAG, "Pickup", date("2026-06-20"))
+ .unwrap()
+ .with_participants(vec![invalid_participant]),
+ Err(RadrootsCalendarEventError::InvalidParticipant { index: 0 })
));
+}
- let mut event = sample_time_event();
- event.d_tag = "bad".to_string();
+#[test]
+fn baseline_parser_rejects_malformed_fields_and_bounds_before_projection() {
+ let minimum = vec![
+ vec![TAG_D.to_string(), DATE_D_TAG.to_string()],
+ vec![TAG_TITLE.to_string(), "CSA pickup".to_string()],
+ vec![TAG_START.to_string(), "2026-06-20".to_string()],
+ ];
assert!(matches!(
- calendar_time_event_build_tags(&event),
- Err(EventEncodeError::InvalidField("d_tag"))
+ parse_nip52_calendar_date_event(KIND_POST, &minimum, ""),
+ Err(EventParseError::InvalidKind { .. })
));
- let mut event = sample_time_event();
- event.title.clear();
+ let mut duplicate = minimum.clone();
+ duplicate.push(vec![TAG_TITLE.to_string(), "Duplicate".to_string()]);
assert!(matches!(
- calendar_time_event_build_tags(&event),
- Err(EventEncodeError::EmptyRequiredField("title"))
+ parse_nip52_calendar_date_event(KIND_CALENDAR_DATE_EVENT, &duplicate, ""),
+ Err(EventParseError::DuplicateTag(TAG_TITLE))
));
- let mut event = sample_time_event();
- event.dates[0].value = "2026-6-20".to_string();
+ let mut blank_d_tag = minimum.clone();
+ replace_tag_value(&mut blank_d_tag, TAG_D, " \t");
assert!(matches!(
- calendar_time_event_build_tags(&event),
- Err(EventEncodeError::InvalidField("dates"))
+ parse_nip52_calendar_date_event(KIND_CALENDAR_DATE_EVENT, &blank_d_tag, ""),
+ Err(EventParseError::InvalidTag(TAG_D))
));
-}
-#[test]
-fn calendar_collection_codecs_cover_address_edges() {
- let tags = calendar_collection_build_tags(&sample_calendar_collection()).unwrap();
- let wrong_kind = calendar_from_event(KIND_POST, &tags, "").unwrap_err();
+ let mut blank_title = minimum.clone();
+ replace_tag_value(&mut blank_title, TAG_TITLE, " \t");
assert!(matches!(
- wrong_kind,
- EventParseError::InvalidKind {
- expected: "31924",
- got: KIND_POST
- }
+ parse_nip52_calendar_date_event(KIND_CALENDAR_DATE_EVENT, &blank_title, ""),
+ Err(EventParseError::InvalidTag(TAG_TITLE))
));
- let mut calendar = sample_calendar_collection();
- calendar.summary = None;
- calendar.image = None;
- calendar.description = None;
- if let RadrootsSocialTarget::Address { relays, .. } = &mut calendar.events[0] {
- *relays = None;
- }
- let parts = calendar_to_wire_parts(&calendar).unwrap();
- assert_eq!(parts.content, "");
- let decoded = calendar_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
- assert!(decoded.description.is_none());
+ let mut impossible = minimum.clone();
+ replace_tag_value(&mut impossible, TAG_START, "2026-02-30");
assert!(matches!(
- &decoded.events[0],
- RadrootsSocialTarget::Address { relays: None, .. }
+ parse_nip52_calendar_date_event(KIND_CALENDAR_DATE_EVENT, &impossible, ""),
+ Err(EventParseError::InvalidTag(TAG_START))
));
- let mut tags = calendar_collection_build_tags(&sample_calendar_collection()).unwrap();
- let address = tags
- .iter_mut()
- .find(|tag| tag.first().map(String::as_str) == Some(TAG_A))
- .expect("address tag");
- address.truncate(1);
+ let mut relative_image = minimum.clone();
+ relative_image.push(vec![TAG_IMAGE.to_string(), "/market.jpg".to_string()]);
assert!(matches!(
- calendar_from_event(KIND_CALENDAR, &tags, ""),
- Err(EventParseError::InvalidTag(TAG_A))
+ parse_nip52_calendar_date_event(KIND_CALENDAR_DATE_EVENT, &relative_image, ""),
+ Err(EventParseError::InvalidTag(TAG_IMAGE))
));
- let mut tags = calendar_collection_build_tags(&sample_calendar_collection()).unwrap();
+ let mut oversized_element = minimum.clone();
replace_tag_value(
- &mut tags,
- TAG_A,
- format!("{KIND_ARTICLE}:{EVENT_AUTHOR}:{EVENT_D_TAG}").as_str(),
+ &mut oversized_element,
+ TAG_TITLE,
+ &"x".repeat(DEFAULT_TAG_ELEMENT_MAX_BYTES + 1),
);
assert!(matches!(
- calendar_from_event(KIND_CALENDAR, &tags, ""),
- Err(EventParseError::InvalidTag(TAG_A))
- ));
-
- let mut calendar = sample_calendar_collection();
- calendar.title.clear();
- assert!(matches!(
- calendar_collection_build_tags(&calendar),
- Err(EventEncodeError::EmptyRequiredField("title"))
+ parse_nip52_calendar_date_event(KIND_CALENDAR_DATE_EVENT, &oversized_element, ""),
+ Err(EventParseError::InvalidEnvelope)
));
- let mut calendar = sample_calendar_collection();
- calendar.events[0] = RadrootsSocialTarget::Event {
- id: EVENT_ID.to_string(),
- author: Some(EVENT_AUTHOR.to_string()),
- event_kind: Some(KIND_CALENDAR_TIME_EVENT),
- relays: None,
- };
+ let mut invalid_participant = minimum.clone();
+ invalid_participant.push(vec![
+ TAG_P.to_string(),
+ EVENT_AUTHOR.to_string(),
+ String::new(),
+ ]);
assert!(matches!(
- calendar_collection_build_tags(&calendar),
- Err(EventEncodeError::InvalidField("events"))
+ parse_nip52_calendar_date_event(KIND_CALENDAR_DATE_EVENT, &invalid_participant, ""),
+ Err(EventParseError::InvalidTag(TAG_P))
));
- let mut calendar = sample_calendar_collection();
- if let RadrootsSocialTarget::Address { address, .. } = &mut calendar.events[0] {
- *address = "not-an-address".to_string();
- }
+ let invalid_tz = vec![
+ vec![TAG_D.to_string(), TIME_D_TAG.to_string()],
+ vec![TAG_TITLE.to_string(), "Shift".to_string()],
+ vec![TAG_START.to_string(), "0".to_string()],
+ vec![TAG_D_DAY.to_string(), "0".to_string()],
+ vec![TAG_START_TZID.to_string(), "america/vancouver".to_string()],
+ ];
assert!(matches!(
- calendar_collection_build_tags(&calendar),
- Err(EventEncodeError::InvalidField("events"))
+ parse_nip52_calendar_time_event(KIND_CALENDAR_TIME_EVENT, &invalid_tz, ""),
+ Err(EventParseError::InvalidTag(TAG_START_TZID))
));
- let mut calendar = sample_calendar_collection();
- if let RadrootsSocialTarget::Address {
- address,
- event_kind,
- ..
- } = &mut calendar.events[0]
- {
- *address = format!("{KIND_ARTICLE}:{EVENT_AUTHOR}:{EVENT_D_TAG}");
- *event_kind = Some(KIND_ARTICLE);
- }
+ let missing_day = vec![
+ vec![TAG_D.to_string(), TIME_D_TAG.to_string()],
+ vec![TAG_TITLE.to_string(), "Shift".to_string()],
+ vec![TAG_START.to_string(), "0".to_string()],
+ ];
assert!(matches!(
- calendar_collection_build_tags(&calendar),
- Err(EventEncodeError::InvalidField("events"))
+ parse_nip52_calendar_time_event(KIND_CALENDAR_TIME_EVENT, &missing_day, ""),
+ Err(EventParseError::MissingTag(TAG_D_DAY))
));
-}
-#[test]
-fn calendar_rsvp_codecs_cover_status_and_target_edges() {
- let tags = rsvp_build_tags(&sample_rsvp()).unwrap();
- let wrong_kind = rsvp_from_event(KIND_POST, &tags, "").unwrap_err();
+ let mut out_of_range_day = missing_day;
+ out_of_range_day.push(vec![TAG_D_DAY.to_string(), "1".to_string()]);
assert!(matches!(
- wrong_kind,
- EventParseError::InvalidKind {
- expected: "31925",
- got: KIND_POST
- }
+ parse_nip52_calendar_time_event(KIND_CALENDAR_TIME_EVENT, &out_of_range_day, ""),
+ Err(EventParseError::InvalidTag(TAG_D_DAY))
));
- let mut rsvp = sample_rsvp();
- rsvp.status = RadrootsCalendarEventRsvpStatus::Declined;
- rsvp.free_busy = Some(RadrootsCalendarEventFreeBusy::Free);
- rsvp.event_id = None;
- rsvp.note = None;
- rsvp.participants = None;
- if let RadrootsSocialTarget::Address { relays, .. } = &mut rsvp.event {
- *relays = None;
- }
- let parts = rsvp_to_wire_parts(&rsvp).unwrap();
- assert_eq!(parts.content, "");
- assert!(!parts.tags.iter().any(|tag| {
- tag.first().map(String::as_str) == Some(TAG_E)
- || tag.first().map(String::as_str) == Some(TAG_P)
- }));
- let decoded = rsvp_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
- assert_eq!(decoded.status, RadrootsCalendarEventRsvpStatus::Declined);
- assert_eq!(decoded.free_busy, Some(RadrootsCalendarEventFreeBusy::Free));
- assert!(decoded.note.is_none());
-
- let mut rsvp = sample_rsvp();
- rsvp.status = RadrootsCalendarEventRsvpStatus::Tentative;
- let parts = rsvp_to_wire_parts(&rsvp).unwrap();
- assert!(has_tag(&parts.tags, TAG_STATUS, "tentative"));
- let decoded = rsvp_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
- assert_eq!(decoded.status, RadrootsCalendarEventRsvpStatus::Tentative);
-
- let mut rsvp = sample_rsvp();
- rsvp.event_id = Some("not-a-lowercase-hex-id".to_string());
+ let oversized_content = "x".repeat(DEFAULT_CONTENT_MAX_BYTES + 1);
assert!(matches!(
- rsvp_build_tags(&rsvp),
- Err(EventEncodeError::InvalidField("event_id"))
+ parse_nip52_calendar_date_event(KIND_CALENDAR_DATE_EVENT, &minimum, &oversized_content),
+ Err(EventParseError::InvalidEnvelope)
));
- let mut tags = rsvp_build_tags(&sample_rsvp()).unwrap();
- replace_tag_value(&mut tags, TAG_E, "not-a-lowercase-hex-id");
+ let mut too_many_tags = minimum;
+ too_many_tags.extend(
+ (too_many_tags.len()..=DEFAULT_TAG_MAX_COUNT)
+ .map(|_| vec!["x".to_string(), "y".to_string()]),
+ );
+ assert!(RadrootsEventTags::new(too_many_tags.clone()).is_err());
assert!(matches!(
- rsvp_from_event(KIND_CALENDAR_EVENT_RSVP, &tags, ""),
- Err(EventParseError::InvalidTag(TAG_E))
+ parse_nip52_calendar_date_event(KIND_CALENDAR_DATE_EVENT, &too_many_tags, ""),
+ Err(EventParseError::InvalidEnvelope)
));
}
#[test]
-fn calendar_wrappers_preserve_event_metadata() {
- let date = sample_date_event();
- let date_parts = date_to_wire_parts(&date).unwrap();
- let date_data = date_data_from_event(
- "date_id".to_string(),
- "author".to_string(),
- 7,
- date_parts.kind,
- date_parts.content.clone(),
- date_parts.tags.clone(),
- )
- .unwrap();
- assert_eq!(date_data.kind, KIND_CALENDAR_DATE_EVENT);
- assert_eq!(date_data.data.title, "CSA pickup");
-
- let err = date_parsed_from_event(
- "date_id".to_string(),
- "author".to_string(),
- 7,
- KIND_POST,
- date_parts.content.clone(),
- date_parts.tags.clone(),
- "sig".to_string(),
- )
- .unwrap_err();
+fn kind_specific_encoders_and_parsed_wrappers_preserve_envelopes() {
assert!(matches!(
- err,
- EventParseError::InvalidKind {
- expected: "31922",
- got: KIND_POST
- }
+ date_to_wire_parts_with_kind(&sample_date_event(), KIND_POST),
+ Err(EventEncodeError::InvalidKind(KIND_POST))
));
-
- let date_parsed = date_parsed_from_event(
- EVENT_ID.to_string(),
- EVENT_AUTHOR.to_string(),
- 7,
- date_parts.kind,
- date_parts.content,
- date_parts.tags,
- EVENT_SIG.to_string(),
- )
- .unwrap();
- assert_eq!(date_parsed.event.sig_str(), EVENT_SIG);
-
- let time = sample_time_event();
- let time_parts = time_to_wire_parts(&time).unwrap();
- let time_data = time_data_from_event(
- "time_id".to_string(),
- "author".to_string(),
- 8,
- time_parts.kind,
- time_parts.content.clone(),
- time_parts.tags.clone(),
- )
- .unwrap();
- assert_eq!(time_data.kind, KIND_CALENDAR_TIME_EVENT);
- assert_eq!(time_data.data.title, "Wash pack shift");
-
- let err = time_parsed_from_event(
- "time_id".to_string(),
- "author".to_string(),
- 8,
- KIND_POST,
- time_parts.content.clone(),
- time_parts.tags.clone(),
- "sig".to_string(),
- )
- .unwrap_err();
assert!(matches!(
- err,
- EventParseError::InvalidKind {
- expected: "31923",
- got: KIND_POST
- }
+ time_to_wire_parts_with_kind(&sample_time_event(), KIND_POST),
+ Err(EventEncodeError::InvalidKind(KIND_POST))
));
- let time_parsed = time_parsed_from_event(
+ let parts = date_to_wire_parts(&sample_date_event()).unwrap();
+ let data = nip52_date_data_from_event(
EVENT_ID.to_string(),
EVENT_AUTHOR.to_string(),
- 8,
- time_parts.kind,
- time_parts.content,
- time_parts.tags,
- EVENT_SIG.to_string(),
- )
- .unwrap();
- assert_eq!(time_parsed.event.created_at_u64(), 8);
-
- let calendar = sample_calendar_collection();
- let calendar_parts = calendar_to_wire_parts(&calendar).unwrap();
- let calendar_data = calendar_data_from_event(
- "calendar_id".to_string(),
- "author".to_string(),
- 9,
- calendar_parts.kind,
- calendar_parts.content.clone(),
- calendar_parts.tags.clone(),
+ 7,
+ parts.kind,
+ parts.content.clone(),
+ parts.tags.clone(),
)
.unwrap();
- assert_eq!(calendar_data.kind, KIND_CALENDAR);
- assert_eq!(calendar_data.data.title, "Farm calendar");
-
- let err = calendar_parsed_from_event(
- "calendar_id".to_string(),
- "author".to_string(),
- 9,
- KIND_POST,
- calendar_parts.content.clone(),
- calendar_parts.tags.clone(),
- "sig".to_string(),
- )
- .unwrap_err();
- assert!(matches!(
- err,
- EventParseError::InvalidKind {
- expected: "31924",
- got: KIND_POST
- }
- ));
+ assert_eq!(data.data.common().title(), "CSA pickup");
- let calendar_parsed = calendar_parsed_from_event(
+ let parsed = nip52_date_parsed_from_event(
EVENT_ID.to_string(),
EVENT_AUTHOR.to_string(),
- 9,
- calendar_parts.kind,
- calendar_parts.content,
- calendar_parts.tags,
+ 7,
+ parts.kind,
+ parts.content,
+ parts.tags,
EVENT_SIG.to_string(),
)
.unwrap();
- assert_eq!(calendar_parsed.event.sig_str(), EVENT_SIG);
-
- let rsvp = sample_rsvp();
- let rsvp_parts = rsvp_to_wire_parts(&rsvp).unwrap();
- let rsvp_data = rsvp_data_from_event(
- "rsvp_id".to_string(),
- "author".to_string(),
- 10,
- rsvp_parts.kind,
- rsvp_parts.content.clone(),
- rsvp_parts.tags.clone(),
- )
- .unwrap();
- assert_eq!(rsvp_data.kind, KIND_CALENDAR_EVENT_RSVP);
- assert_eq!(rsvp_data.data.event_id.as_deref(), Some(EVENT_ID));
-
- let err = rsvp_parsed_from_event(
- "rsvp_id".to_string(),
- "author".to_string(),
- 10,
- KIND_POST,
- rsvp_parts.content.clone(),
- rsvp_parts.tags.clone(),
- "sig".to_string(),
- )
- .unwrap_err();
- assert!(matches!(
- err,
- EventParseError::InvalidKind {
- expected: "31925",
- got: KIND_POST
- }
- ));
+ assert_eq!(parsed.event.sig_str(), EVENT_SIG);
+ assert_eq!(parsed.data.data.common().d_tag(), DATE_D_TAG);
+}
- let rsvp_parsed = rsvp_parsed_from_event(
- EVENT_ID.to_string(),
- EVENT_AUTHOR.to_string(),
- 10,
- rsvp_parts.kind,
- rsvp_parts.content,
- rsvp_parts.tags,
- EVENT_SIG.to_string(),
- )
- .unwrap();
- assert_eq!(rsvp_parsed.event.created_at_u64(), 10);
+#[test]
+fn exclusive_end_day_math_is_bounded() {
+ assert_eq!(
+ covered_utc_days(86_399, Some(86_400))
+ .unwrap()
+ .collect::<Vec<_>>(),
+ vec![0]
+ );
+ assert_eq!(
+ covered_utc_days(86_399, Some(86_401))
+ .unwrap()
+ .collect::<Vec<_>>(),
+ vec![0, 1]
+ );
+ assert_eq!(
+ covered_utc_days(10, Some(10)),
+ Err(RadrootsCalendarEventError::InvalidRange)
+ );
}
diff --git a/crates/event_codec/tests/calendar_conformance.rs b/crates/event_codec/tests/calendar_conformance.rs
@@ -0,0 +1,872 @@
+use std::{borrow::Cow, fs, path::Path};
+
+use radroots_blossom::{RadrootsBlossomBlobDescriptor, RadrootsBlossomByteVerifiedDescriptor};
+use radroots_event::{
+ RadrootsAuthoredImage,
+ calendar::{
+ RADROOTS_CALENDAR_MAX_COVERED_UTC_DAYS, RadrootsAuthoredCalendarDateEvent,
+ RadrootsAuthoredCalendarTimeEvent, RadrootsCalendarAdmissionError, RadrootsCalendarDate,
+ RadrootsCalendarEventError, RadrootsCalendarRequest, RadrootsCalendarUri,
+ RadrootsIanaTimeZoneId, RadrootsParsedNip52CalendarCommon,
+ },
+ 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,
+ },
+ encode::{
+ calendar_date_event_build_tags, calendar_time_event_build_tags, date_to_wire_parts,
+ time_to_wire_parts,
+ },
+ },
+ error::EventParseError,
+};
+use serde_json::{Map, Value};
+
+const PACKAGED_BASELINE: &str = include_str!("fixtures/calendar_nip52_baseline.v1.json");
+const PACKAGED_PROFILE: &str = include_str!("fixtures/calendar_radroots_profile.v1.json");
+const WORKSPACE_BASELINE_PATH: &str =
+ "../../contracts/conformance/vectors/calendar/nip52_baseline.v1.json";
+const WORKSPACE_PROFILE_PATH: &str =
+ "../../contracts/conformance/vectors/calendar/radroots_profile.v1.json";
+const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml";
+
+#[test]
+fn checked_in_baseline_vectors_execute_against_tolerant_nip52_parsers() {
+ let suite = conformance_suite(WORKSPACE_BASELINE_PATH, PACKAGED_BASELINE);
+ 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());
+ for vector in vectors {
+ execute_baseline(vector);
+ }
+}
+
+#[test]
+fn checked_in_profile_vectors_execute_against_authored_and_admission_apis() {
+ let suite = conformance_suite(WORKSPACE_PROFILE_PATH, PACKAGED_PROFILE);
+ 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());
+ for vector in vectors {
+ execute_profile(vector);
+ }
+}
+
+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) {
+ Ok(canonical) => {
+ assert_eq!(
+ canonical,
+ packaged,
+ "packaged Calendar vectors must match {}",
+ workspace_path.display()
+ );
+ Cow::Owned(canonical)
+ }
+ Err(error)
+ if error.kind() == std::io::ErrorKind::NotFound
+ && !Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join(WORKSPACE_CONTRACT_MARKER_PATH)
+ .is_file() =>
+ {
+ Cow::Borrowed(packaged)
+ }
+ Err(error) => panic!("failed to read {}: {error}", workspace_path.display()),
+ };
+ serde_json::from_str(&vectors).expect("Calendar vectors must parse")
+}
+
+fn execute_baseline(vector: &Value) {
+ match vector_kind(vector) {
+ "calendar.baseline.date.valid" => baseline_date_valid(vector),
+ "calendar.baseline.date.invalid" => baseline_date_invalid(vector),
+ "calendar.baseline.time.valid" => baseline_time_valid(vector),
+ "calendar.baseline.time.invalid" => baseline_time_invalid(vector),
+ kind => panic!(
+ "{} uses unsupported baseline kind {kind}",
+ vector_id(vector)
+ ),
+ }
+}
+
+fn execute_profile(vector: &Value) {
+ match vector_kind(vector) {
+ "calendar.profile.date.parse.valid" => profile_date_parse_valid(vector),
+ "calendar.profile.date.parse.invalid" => profile_date_parse_invalid(vector),
+ "calendar.profile.authored.date.valid" => profile_authored_date_valid(vector),
+ "calendar.profile.authored.date.invalid" => profile_authored_date_invalid(vector),
+ "calendar.profile.authored.time.valid" => profile_authored_time_valid(vector),
+ "calendar.profile.authored.time.coverage.valid" => {
+ profile_authored_time_coverage_valid(vector)
+ }
+ "calendar.profile.authored.time.invalid" => profile_authored_time_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),
+ kind => panic!("{} uses unsupported profile kind {kind}", vector_id(vector)),
+ }
+}
+
+fn baseline_date_valid(vector: &Value) {
+ let (kind, tags, content) = inbound_parts(vector);
+ let parsed = parse_nip52_calendar_date_event(kind, &tags, content)
+ .unwrap_or_else(|error| panic!("{} failed: {error}", vector_id(vector)));
+ let expected = expected(vector);
+ assert_common(parsed.common(), expected, vector);
+ assert_eq!(parsed.start().as_str(), value_str(expected, "start"));
+ assert_optional_str(
+ parsed.end().map(RadrootsCalendarDate::as_str),
+ expected,
+ "end",
+ vector,
+ );
+ assert_eq!(
+ serde_json::to_value(parsed.extension_day_tags()).unwrap(),
+ expected["date_day_extensions"],
+ "{}",
+ vector_id(vector)
+ );
+}
+
+fn baseline_time_valid(vector: &Value) {
+ let (kind, tags, content) = inbound_parts(vector);
+ let parsed = parse_nip52_calendar_time_event(kind, &tags, content)
+ .unwrap_or_else(|error| panic!("{} failed: {error}", vector_id(vector)));
+ let expected = expected(vector);
+ assert_common(parsed.common(), expected, vector);
+ assert_eq!(parsed.start_wire(), value_str(expected, "start_wire"));
+ assert_eq!(parsed.start(), value_u64(expected, "start"));
+ assert_optional_str(parsed.end_wire(), expected, "end_wire", vector);
+ assert_eq!(
+ parsed.end(),
+ expected.get("end").and_then(Value::as_u64),
+ "{}",
+ vector_id(vector)
+ );
+ let actual_days = parsed
+ .observed_day_indices()
+ .iter()
+ .map(|day| {
+ serde_json::json!({
+ "wire": day.wire_value(),
+ "index": day.index(),
+ "canonical": day.is_canonical(),
+ })
+ })
+ .collect::<Vec<_>>();
+ assert_eq!(
+ Value::Array(actual_days),
+ expected["observed_days"],
+ "{}",
+ vector_id(vector)
+ );
+ assert_optional_str(
+ parsed.start_tzid().map(RadrootsIanaTimeZoneId::as_str),
+ expected,
+ "start_tzid",
+ vector,
+ );
+ assert_optional_str(
+ parsed.end_tzid().map(RadrootsIanaTimeZoneId::as_str),
+ expected,
+ "end_tzid",
+ vector,
+ );
+ assert_optional_str(
+ parsed
+ .effective_end_tzid()
+ .map(RadrootsIanaTimeZoneId::as_str),
+ expected,
+ "effective_end_tzid",
+ vector,
+ );
+}
+
+fn baseline_date_invalid(vector: &Value) {
+ let (kind, tags, content) = inbound_parts(vector);
+ let error = parse_nip52_calendar_date_event(kind, &tags, content)
+ .expect_err("invalid baseline date vector must fail");
+ assert_parse_error(vector, &error);
+}
+
+fn baseline_time_invalid(vector: &Value) {
+ let (kind, tags, content) = inbound_parts(vector);
+ let error = parse_nip52_calendar_time_event(kind, &tags, content)
+ .expect_err("invalid baseline time vector must fail");
+ assert_parse_error(vector, &error);
+}
+
+fn assert_common(common: &RadrootsParsedNip52CalendarCommon, expected: &Value, vector: &Value) {
+ assert_eq!(
+ common.d_tag(),
+ value_str(expected, "d"),
+ "{}",
+ vector_id(vector)
+ );
+ assert_eq!(
+ common.title(),
+ value_str(expected, "title"),
+ "{}",
+ vector_id(vector)
+ );
+ assert_optional_str(common.description(), expected, "description", vector);
+ assert_eq!(
+ serde_json::to_value(common.locations()).unwrap(),
+ expected["locations"],
+ "{}",
+ vector_id(vector)
+ );
+ assert_optional_str(common.geohash(), expected, "geohash", vector);
+ assert_optional_str(common.summary(), expected, "summary", vector);
+ assert_optional_str(
+ common.image().map(RadrootsCalendarUri::as_str),
+ expected,
+ "image",
+ vector,
+ );
+ assert_participants(common, expected, vector);
+ assert_eq!(
+ serde_json::to_value(common.categories()).unwrap(),
+ expected["categories"],
+ "{}",
+ vector_id(vector)
+ );
+ assert_eq!(
+ common
+ .references()
+ .iter()
+ .map(RadrootsCalendarUri::as_str)
+ .collect::<Vec<_>>(),
+ expected["references"]
+ .as_array()
+ .expect("expected references")
+ .iter()
+ .map(|value| value.as_str().expect("reference"))
+ .collect::<Vec<_>>(),
+ "{}",
+ vector_id(vector)
+ );
+ assert_calendar_requests(common, expected, vector);
+ assert_optional_str(common.legacy_name(), expected, "legacy_name", vector);
+}
+
+fn assert_participants(
+ common: &RadrootsParsedNip52CalendarCommon,
+ expected: &Value,
+ vector: &Value,
+) {
+ if let Some(count) = expected["participants"].as_u64() {
+ assert_eq!(
+ common.participants().len() as u64,
+ count,
+ "{}",
+ vector_id(vector)
+ );
+ return;
+ }
+ let actual = common
+ .participants()
+ .iter()
+ .map(|participant| {
+ serde_json::json!({
+ "pubkey": participant.pubkey,
+ "relay": participant.relay,
+ "role": participant.role,
+ })
+ })
+ .collect::<Vec<_>>();
+ assert_eq!(
+ Value::Array(actual),
+ expected["participants"],
+ "{}",
+ vector_id(vector)
+ );
+}
+
+fn assert_calendar_requests(
+ common: &RadrootsParsedNip52CalendarCommon,
+ expected: &Value,
+ vector: &Value,
+) {
+ if let Some(count) = expected["calendar_requests"].as_u64() {
+ assert_eq!(
+ common.calendar_requests().len() as u64,
+ count,
+ "{}",
+ vector_id(vector)
+ );
+ return;
+ }
+ let actual = common
+ .calendar_requests()
+ .iter()
+ .map(|request| {
+ serde_json::json!({
+ "calendar": request.calendar().as_str(),
+ "relay": request.relay(),
+ })
+ })
+ .collect::<Vec<_>>();
+ assert_eq!(
+ Value::Array(actual),
+ expected["calendar_requests"],
+ "{}",
+ vector_id(vector)
+ );
+}
+
+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)));
+ assert_eq!(parsed.as_str(), expected_str(vector, "value"));
+}
+
+fn profile_date_parse_invalid(vector: &Value) {
+ let error = RadrootsCalendarDate::parse(input_str(vector, "value"))
+ .expect_err("invalid profile date must fail");
+ assert_calendar_event_error(vector, &error);
+}
+
+fn profile_authored_date_valid(vector: &Value) {
+ let event = authored_date_event(input(vector), vector_id(vector))
+ .unwrap_or_else(|error| panic!("{} failed: {error}", vector_id(vector)));
+ let wire = date_to_wire_parts(&event)
+ .unwrap_or_else(|error| panic!("{} failed: {error}", vector_id(vector)));
+ assert_eq!(calendar_date_event_build_tags(&event).unwrap(), wire.tags);
+ assert!(
+ wire.tags
+ .iter()
+ .all(|tag| tag.first().map(String::as_str) != Some("D")),
+ "{}",
+ vector_id(vector)
+ );
+ assert_wire_parts(&wire, &expected(vector)["wire_parts"], vector_id(vector));
+ assert_authored_media_claims(vector, event.image().is_some());
+ assert_registry_accepts(
+ wire.kind,
+ &wire.tags,
+ &wire.content,
+ "radroots.calendar.date_event.v1",
+ vector,
+ );
+
+ let parsed = parse_nip52_calendar_date_event(wire.kind, &wire.tags, &wire.content).unwrap();
+ admit_radroots_calendar_date_event(parsed)
+ .unwrap_or_else(|error| panic!("{} admission failed: {error}", vector_id(vector)));
+}
+
+fn profile_authored_date_invalid(vector: &Value) {
+ let error = authored_date_event(input(vector), vector_id(vector))
+ .expect_err("invalid authored date event must fail");
+ assert_calendar_event_error(vector, &error);
+}
+
+fn profile_authored_time_valid(vector: &Value) {
+ let event = authored_time_event(input(vector), vector_id(vector))
+ .unwrap_or_else(|error| panic!("{} failed: {error}", vector_id(vector)));
+ assert_optional_str(
+ event
+ .effective_end_tzid()
+ .map(RadrootsIanaTimeZoneId::as_str),
+ expected(vector),
+ "effective_end_tzid",
+ vector,
+ );
+ let wire = time_to_wire_parts(&event)
+ .unwrap_or_else(|error| panic!("{} failed: {error}", vector_id(vector)));
+ assert_eq!(calendar_time_event_build_tags(&event).unwrap(), wire.tags);
+ assert_wire_parts(&wire, &expected(vector)["wire_parts"], vector_id(vector));
+ assert_authored_media_claims(vector, event.image().is_some());
+ assert_registry_accepts(
+ wire.kind,
+ &wire.tags,
+ &wire.content,
+ "radroots.calendar.time_event.v1",
+ vector,
+ );
+
+ let parsed = parse_nip52_calendar_time_event(wire.kind, &wire.tags, &wire.content).unwrap();
+ admit_radroots_calendar_time_event(parsed)
+ .unwrap_or_else(|error| panic!("{} admission failed: {error}", vector_id(vector)));
+}
+
+fn profile_authored_time_coverage_valid(vector: &Value) {
+ let event = authored_time_event(input(vector), vector_id(vector))
+ .unwrap_or_else(|error| panic!("{} failed: {error}", vector_id(vector)));
+ let wire = time_to_wire_parts(&event).unwrap();
+ assert_registry_accepts(
+ wire.kind,
+ &wire.tags,
+ &wire.content,
+ "radroots.calendar.time_event.v1",
+ vector,
+ );
+ let covered = wire
+ .tags
+ .iter()
+ .filter(|tag| tag.first().map(String::as_str) == Some("D"))
+ .collect::<Vec<_>>();
+ assert_eq!(
+ covered.len() as u64,
+ expected_u64(vector, "count"),
+ "{}",
+ vector_id(vector)
+ );
+ assert_eq!(
+ covered.len(),
+ RADROOTS_CALENDAR_MAX_COVERED_UTC_DAYS as usize
+ );
+ assert_eq!(covered.first().unwrap()[1], expected_str(vector, "first"));
+ assert_eq!(covered.last().unwrap()[1], expected_str(vector, "last"));
+ for (index, tag) in covered.iter().enumerate() {
+ assert_eq!(tag[0], "D", "{}", vector_id(vector));
+ assert_eq!(tag[1], index.to_string(), "{}", vector_id(vector));
+ }
+}
+
+fn profile_authored_time_invalid(vector: &Value) {
+ let error = authored_time_event(input(vector), vector_id(vector))
+ .expect_err("invalid authored time event 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();
+ let admitted = admit_radroots_calendar_date_event(parsed)
+ .unwrap_or_else(|error| panic!("{} failed: {error}", vector_id(vector)));
+ assert_eq!(admitted.d_tag().as_str(), expected_str(vector, "d"));
+ assert_eq!(
+ admitted.blossom_image().is_some(),
+ expected(vector)["blossom_image"].as_bool().unwrap()
+ );
+ assert_registry_accepts(
+ kind,
+ &tags,
+ content,
+ "radroots.calendar.date_event.v1",
+ vector,
+ );
+}
+
+fn profile_admit_date_invalid(vector: &Value) {
+ let (kind, tags, content) = inbound_parts(vector);
+ let parsed = parse_nip52_calendar_date_event(kind, &tags, content).unwrap();
+ let error = admit_radroots_calendar_date_event(parsed)
+ .expect_err("invalid date admission vector must fail");
+ assert_admission_error(vector, &error);
+}
+
+fn profile_admit_time_valid(vector: &Value) {
+ let (kind, tags, content) = inbound_parts(vector);
+ let parsed = parse_nip52_calendar_time_event(kind, &tags, content).unwrap();
+ let admitted = admit_radroots_calendar_time_event(parsed)
+ .unwrap_or_else(|error| panic!("{} failed: {error}", vector_id(vector)));
+ assert_eq!(admitted.d_tag().as_str(), expected_str(vector, "d"));
+ assert_eq!(
+ serde_json::to_value(admitted.covered_utc_days()).unwrap(),
+ expected(vector)["covered_days"]
+ );
+ assert_eq!(
+ admitted.blossom_image().is_some(),
+ expected(vector)["blossom_image"].as_bool().unwrap()
+ );
+ assert_registry_accepts(
+ kind,
+ &tags,
+ content,
+ "radroots.calendar.time_event.v1",
+ vector,
+ );
+}
+
+fn assert_registry_accepts(
+ kind: u32,
+ tags: &[Vec<String>],
+ content: &str,
+ contract_id: &str,
+ vector: &Value,
+) {
+ validate_event_contract_parts(kind, tags, content, contract_id)
+ .unwrap_or_else(|error| panic!("{} registry drift: {error:?}", 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);
+}
+
+fn authored_date_event(
+ input: &Map<String, Value>,
+ vector_id: &str,
+) -> Result<RadrootsAuthoredCalendarDateEvent, RadrootsCalendarEventError> {
+ let start = RadrootsCalendarDate::parse(map_str(input, "start"))?;
+ let mut event = RadrootsAuthoredCalendarDateEvent::new(
+ map_str(input, "d"),
+ map_str(input, "title"),
+ start,
+ )?;
+ if let Some(value) = map_optional_str(input, "end") {
+ event = event.with_end(RadrootsCalendarDate::parse(value)?)?;
+ }
+ event = apply_authored_date_common(event, input, vector_id)?;
+ Ok(event)
+}
+
+fn apply_authored_date_common(
+ mut event: RadrootsAuthoredCalendarDateEvent,
+ input: &Map<String, Value>,
+ vector_id: &str,
+) -> Result<RadrootsAuthoredCalendarDateEvent, RadrootsCalendarEventError> {
+ if let Some(value) = map_optional_str(input, "description") {
+ event = event.with_description(value)?;
+ }
+ if let Some(value) = map_optional_string_vec(input, "locations") {
+ event = event.with_locations(value)?;
+ }
+ if let Some(value) = map_optional_str(input, "geohash") {
+ event = event.with_geohash(value)?;
+ }
+ if let Some(value) = map_optional_str(input, "summary") {
+ event = event.with_summary(value)?;
+ }
+ if let Some(value) = input.get("image") {
+ event = event.with_image(authored_image(value, vector_id))?;
+ }
+ if let Some(value) = input.get("participants") {
+ event = event.with_participants(participants(value, vector_id))?;
+ }
+ if let Some(value) = map_optional_string_vec(input, "categories") {
+ event = event.with_categories(value)?;
+ }
+ if let Some(value) = map_optional_string_vec(input, "references") {
+ event = event.with_references(
+ value
+ .into_iter()
+ .map(RadrootsCalendarUri::parse)
+ .collect::<Result<Vec<_>, _>>()?,
+ )?;
+ }
+ if let Some(value) = input.get("calendar_requests") {
+ event = event.with_calendar_requests(calendar_requests(value, vector_id))?;
+ }
+ Ok(event)
+}
+
+fn authored_time_event(
+ input: &Map<String, Value>,
+ vector_id: &str,
+) -> Result<RadrootsAuthoredCalendarTimeEvent, RadrootsCalendarEventError> {
+ let mut event = RadrootsAuthoredCalendarTimeEvent::new(
+ map_str(input, "d"),
+ map_str(input, "title"),
+ map_u64(input, "start"),
+ )?;
+ if let Some(value) = input.get("end").and_then(Value::as_u64) {
+ event = event.with_end(value)?;
+ }
+ if let Some(value) = map_optional_str(input, "description") {
+ event = event.with_description(value)?;
+ }
+ if let Some(value) = map_optional_str(input, "start_tzid") {
+ event = event.with_start_tzid(value)?;
+ }
+ if let Some(value) = map_optional_str(input, "end_tzid") {
+ event = event.with_end_tzid(value)?;
+ }
+ if let Some(value) = map_optional_string_vec(input, "locations") {
+ event = event.with_locations(value)?;
+ }
+ if let Some(value) = map_optional_str(input, "geohash") {
+ event = event.with_geohash(value)?;
+ }
+ if let Some(value) = map_optional_str(input, "summary") {
+ event = event.with_summary(value)?;
+ }
+ if let Some(value) = input.get("image") {
+ event = event.with_image(authored_image(value, vector_id))?;
+ }
+ if let Some(value) = input.get("participants") {
+ event = event.with_participants(participants(value, vector_id))?;
+ }
+ if let Some(value) = map_optional_string_vec(input, "categories") {
+ event = event.with_categories(value)?;
+ }
+ if let Some(value) = map_optional_string_vec(input, "references") {
+ event = event.with_references(
+ value
+ .into_iter()
+ .map(RadrootsCalendarUri::parse)
+ .collect::<Result<Vec<_>, _>>()?,
+ )?;
+ }
+ if let Some(value) = input.get("calendar_requests") {
+ event = event.with_calendar_requests(calendar_requests(value, vector_id))?;
+ }
+ Ok(event)
+}
+
+fn authored_image(input: &Value, vector_id: &str) -> RadrootsAuthoredImage {
+ RadrootsAuthoredImage::try_from(verified_descriptor(input, vector_id))
+ .unwrap_or_else(|error| panic!("{vector_id} image failed: {error}"))
+}
+
+fn verified_descriptor(input: &Value, vector_id: &str) -> RadrootsBlossomByteVerifiedDescriptor {
+ let descriptor: RadrootsBlossomBlobDescriptor =
+ serde_json::from_value(input["descriptor"].clone())
+ .unwrap_or_else(|error| panic!("{vector_id} descriptor failed: {error}"));
+ 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}"))
+}
+
+fn participants(input: &Value, vector_id: &str) -> Vec<RadrootsCalendarParticipant> {
+ input
+ .as_array()
+ .unwrap_or_else(|| panic!("{vector_id} participants must be an array"))
+ .iter()
+ .map(|value| RadrootsCalendarParticipant {
+ pubkey: value["pubkey"]
+ .as_str()
+ .expect("participant.pubkey")
+ .to_string(),
+ relay: value
+ .get("relay")
+ .and_then(Value::as_str)
+ .map(str::to_string),
+ role: value
+ .get("role")
+ .and_then(Value::as_str)
+ .map(str::to_string),
+ })
+ .collect()
+}
+
+fn calendar_requests(input: &Value, vector_id: &str) -> Vec<RadrootsCalendarRequest> {
+ input
+ .as_array()
+ .unwrap_or_else(|| panic!("{vector_id} calendar_requests must be an array"))
+ .iter()
+ .map(|value| {
+ RadrootsCalendarRequest::new(
+ value["calendar"].as_str().expect("request.calendar"),
+ value.get("relay").and_then(Value::as_str),
+ )
+ .unwrap_or_else(|error| panic!("{vector_id} request failed: {error}"))
+ })
+ .collect()
+}
+
+fn assert_authored_media_claims(vector: &Value, image_expected: bool) {
+ let expected = expected(vector);
+ let Some(verification) = expected.get("media_verification") else {
+ assert!(
+ !image_expected,
+ "{} omits its media claim",
+ vector_id(vector)
+ );
+ return;
+ };
+ assert!(image_expected, "{} claims absent media", vector_id(vector));
+ assert_eq!(verification, "byte_verified", "{}", vector_id(vector));
+ assert_eq!(
+ expected_str(vector, "upload_completion"),
+ "not_attested_by_codec",
+ "{}",
+ vector_id(vector)
+ );
+}
+
+fn assert_calendar_event_error(vector: &Value, error: &RadrootsCalendarEventError) {
+ assert_eq!(
+ error.code(),
+ expected_str(vector, "error"),
+ "{}",
+ vector_id(vector)
+ );
+ assert!(!error.to_string().is_empty());
+ if let RadrootsCalendarEventError::CoveredDayLimitExceeded { max, actual } = error {
+ assert_eq!(*max, expected_u64(vector, "max"));
+ assert_eq!(*actual, expected_u64(vector, "actual"));
+ }
+}
+
+fn assert_admission_error(vector: &Value, error: &RadrootsCalendarAdmissionError) {
+ assert_eq!(
+ error.code(),
+ expected_str(vector, "error"),
+ "{}",
+ vector_id(vector)
+ );
+ assert!(!error.to_string().is_empty());
+ if let RadrootsCalendarAdmissionError::CoveredDayLimitExceeded { max, actual } = error {
+ assert_eq!(*max, expected_u64(vector, "max"));
+ assert_eq!(*actual, expected_u64(vector, "actual"));
+ }
+}
+
+fn assert_parse_error(vector: &Value, error: &EventParseError) {
+ assert_eq!(
+ error.code(),
+ expected_str(vector, "error"),
+ "{}",
+ vector_id(vector)
+ );
+ assert!(!error.to_string().is_empty());
+ if let Some(expected_tag) = expected(vector).get("tag").and_then(Value::as_str) {
+ assert_eq!(
+ parse_error_tag(error),
+ Some(expected_tag),
+ "{}",
+ vector_id(vector)
+ );
+ }
+}
+
+fn parse_error_tag(error: &EventParseError) -> Option<&'static str> {
+ match error {
+ EventParseError::MissingTag(tag)
+ | EventParseError::InvalidTag(tag)
+ | EventParseError::DuplicateTag(tag)
+ | EventParseError::InvalidNumber(tag, _)
+ | EventParseError::InvalidJson(tag) => Some(tag),
+ EventParseError::InvalidEnvelope | EventParseError::InvalidKind { .. } => None,
+ }
+}
+
+fn assert_wire_parts(
+ wire: &radroots_event::wire::RadrootsNip01EventWireParts,
+ expected: &Value,
+ vector_id: &str,
+) {
+ assert_eq!(
+ u64::from(wire.kind),
+ expected["kind"].as_u64().unwrap(),
+ "{vector_id}"
+ );
+ assert_eq!(
+ wire.content,
+ expected["content"].as_str().unwrap(),
+ "{vector_id}"
+ );
+ assert_eq!(
+ serde_json::to_value(&wire.tags).unwrap(),
+ expected["tags"],
+ "{vector_id}"
+ );
+}
+
+fn inbound_parts(vector: &Value) -> (u32, Vec<Vec<String>>, &str) {
+ let input = input(vector);
+ let kind = map_u64(input, "kind") as u32;
+ let tags = serde_json::from_value(input["tags"].clone())
+ .unwrap_or_else(|error| panic!("{} tags failed: {error}", vector_id(vector)));
+ let content = map_str(input, "content");
+ (kind, tags, content)
+}
+
+fn assert_optional_str(actual: Option<&str>, expected: &Value, key: &str, vector: &Value) {
+ assert_eq!(
+ actual,
+ expected.get(key).and_then(Value::as_str),
+ "{} expected.{key}",
+ vector_id(vector)
+ );
+}
+
+fn input(vector: &Value) -> &Map<String, Value> {
+ vector["input"].as_object().expect("vector.input")
+}
+
+fn expected(vector: &Value) -> &Value {
+ &vector["expected"]
+}
+
+fn vector_id(vector: &Value) -> &str {
+ vector["id"].as_str().expect("vector.id")
+}
+
+fn vector_kind(vector: &Value) -> &str {
+ vector["kind"].as_str().expect("vector.kind")
+}
+
+fn input_str<'a>(vector: &'a Value, field: &str) -> &'a str {
+ map_str(input(vector), field)
+}
+
+fn expected_str<'a>(vector: &'a Value, field: &str) -> &'a str {
+ expected(vector)[field]
+ .as_str()
+ .unwrap_or_else(|| panic!("{} expected.{field}", vector_id(vector)))
+}
+
+fn expected_u64(vector: &Value, field: &str) -> u64 {
+ expected(vector)[field]
+ .as_u64()
+ .unwrap_or_else(|| panic!("{} expected.{field}", vector_id(vector)))
+}
+
+fn value_str<'a>(value: &'a Value, field: &str) -> &'a str {
+ value[field]
+ .as_str()
+ .unwrap_or_else(|| panic!("expected.{field} must be a string"))
+}
+
+fn value_u64(value: &Value, field: &str) -> u64 {
+ value[field]
+ .as_u64()
+ .unwrap_or_else(|| panic!("expected.{field} must be an integer"))
+}
+
+fn map_str<'a>(map: &'a Map<String, Value>, field: &str) -> &'a str {
+ map[field]
+ .as_str()
+ .unwrap_or_else(|| panic!("input.{field} must be a string"))
+}
+
+fn map_optional_str<'a>(map: &'a Map<String, Value>, field: &str) -> Option<&'a str> {
+ map.get(field).and_then(Value::as_str)
+}
+
+fn map_u64(map: &Map<String, Value>, field: &str) -> u64 {
+ map[field]
+ .as_u64()
+ .unwrap_or_else(|| panic!("input.{field} must be an integer"))
+}
+
+fn map_optional_string_vec(map: &Map<String, Value>, field: &str) -> Option<Vec<String>> {
+ map.get(field).map(|value| {
+ value
+ .as_array()
+ .unwrap_or_else(|| panic!("input.{field} must be an array"))
+ .iter()
+ .map(|value| {
+ value
+ .as_str()
+ .unwrap_or_else(|| panic!("input.{field} values must be strings"))
+ .to_string()
+ })
+ .collect()
+ })
+}
diff --git a/crates/event_codec/tests/fixtures/calendar_nip52_baseline.v1.json b/crates/event_codec/tests/fixtures/calendar_nip52_baseline.v1.json
@@ -0,0 +1,333 @@
+{
+ "suite": "calendar_nip52_baseline",
+ "contract_version": "0.1.0",
+ "vectors": [
+ {
+ "id": "baseline_date_minimal_001",
+ "kind": "calendar.baseline.date.valid",
+ "input": {
+ "kind": 31922,
+ "content": "",
+ "tags": [
+ ["d", "moss-street-market"],
+ ["title", "Moss Street Market"],
+ ["start", "2026-06-20"]
+ ]
+ },
+ "expected": {
+ "d": "moss-street-market",
+ "title": "Moss Street Market",
+ "start": "2026-06-20",
+ "locations": [],
+ "participants": 0,
+ "categories": [],
+ "references": [],
+ "calendar_requests": 0,
+ "date_day_extensions": []
+ }
+ },
+ {
+ "id": "baseline_date_complete_nip52_metadata_001",
+ "kind": "calendar.baseline.date.valid",
+ "input": {
+ "kind": 31922,
+ "content": "Bring reusable bags.",
+ "tags": [
+ ["d", "fernwood-csa-pickup"],
+ ["title", " CSA pickup "],
+ ["start", "2026-06-20"],
+ ["end", "2026-06-22"],
+ ["location", "Farm stand"],
+ ["location", "Moss Street Market"],
+ ["g", "C23NB62W20ST"],
+ ["summary", " Weekly pickup "],
+ ["image", "https://ordinary.example.test/images/market.jpg?size=large"],
+ ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"],
+ ["p", "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "wss://relay.example.test/events"],
+ ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "", " host "],
+ ["t", "vegetables"],
+ ["t", "CSA"],
+ ["r", "ipfs://bafybeigdyrzt/reference"],
+ ["a", "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-calendar", "wss://relay.example.test"],
+ ["name", "Deprecated display name"],
+ ["D", "extension", "preserved"]
+ ]
+ },
+ "expected": {
+ "d": "fernwood-csa-pickup",
+ "title": " CSA pickup ",
+ "description": "Bring reusable bags.",
+ "start": "2026-06-20",
+ "end": "2026-06-22",
+ "locations": ["Farm stand", "Moss Street Market"],
+ "geohash": "C23NB62W20ST",
+ "summary": " Weekly pickup ",
+ "image": "https://ordinary.example.test/images/market.jpg?size=large",
+ "participants": [
+ {"pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "relay": null, "role": null},
+ {"pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "relay": "wss://relay.example.test/events", "role": null},
+ {"pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "relay": null, "role": " host "}
+ ],
+ "categories": ["vegetables", "CSA"],
+ "references": ["ipfs://bafybeigdyrzt/reference"],
+ "calendar_requests": [
+ {"calendar": "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-calendar", "relay": "wss://relay.example.test"}
+ ],
+ "legacy_name": "Deprecated display name",
+ "date_day_extensions": [["D", "extension", "preserved"]]
+ }
+ },
+ {
+ "id": "baseline_date_exclusive_end_001",
+ "kind": "calendar.baseline.date.valid",
+ "input": {
+ "kind": 31922,
+ "content": "Two all-day sale dates.",
+ "tags": [
+ ["d", "seedling-sale"],
+ ["title", "Victoria Seedling Sale"],
+ ["start", "2024-02-29"],
+ ["end", "2024-03-02"]
+ ]
+ },
+ "expected": {
+ "d": "seedling-sale",
+ "title": "Victoria Seedling Sale",
+ "description": "Two all-day sale dates.",
+ "start": "2024-02-29",
+ "end": "2024-03-02",
+ "locations": [],
+ "participants": 0,
+ "categories": [],
+ "references": [],
+ "calendar_requests": 0,
+ "date_day_extensions": []
+ }
+ },
+ {
+ "id": "baseline_date_impossible_start_001",
+ "kind": "calendar.baseline.date.invalid",
+ "input": {
+ "kind": 31922,
+ "content": "",
+ "tags": [["d", "bad-date"], ["title", "Bad date"], ["start", "2026-02-30"]]
+ },
+ "expected": { "error": "invalid_tag", "tag": "start" }
+ },
+ {
+ "id": "baseline_date_equal_end_001",
+ "kind": "calendar.baseline.date.invalid",
+ "input": {
+ "kind": 31922,
+ "content": "",
+ "tags": [["d", "equal-date"], ["title", "Equal date"], ["start", "2026-06-20"], ["end", "2026-06-20"]]
+ },
+ "expected": { "error": "invalid_tag", "tag": "end" }
+ },
+ {
+ "id": "baseline_date_duplicate_title_001",
+ "kind": "calendar.baseline.date.invalid",
+ "input": {
+ "kind": 31922,
+ "content": "",
+ "tags": [["d", "duplicate"], ["title", "First"], ["title", "Second"], ["start", "2026-06-20"]]
+ },
+ "expected": { "error": "duplicate_tag", "tag": "title" }
+ },
+ {
+ "id": "baseline_date_relative_image_001",
+ "kind": "calendar.baseline.date.invalid",
+ "input": {
+ "kind": 31922,
+ "content": "",
+ "tags": [["d", "relative-image"], ["title", "Relative image"], ["start", "2026-06-20"], ["image", "/market.jpg"]]
+ },
+ "expected": { "error": "invalid_tag", "tag": "image" }
+ },
+ {
+ "id": "baseline_date_participant_empty_relay_without_role_001",
+ "kind": "calendar.baseline.date.invalid",
+ "input": {
+ "kind": 31922,
+ "content": "",
+ "tags": [["d", "bad-participant"], ["title", "Bad participant"], ["start", "2026-06-20"], ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", ""]]
+ },
+ "expected": { "error": "invalid_tag", "tag": "p" }
+ },
+ {
+ "id": "baseline_date_calendar_request_wrong_kind_001",
+ "kind": "calendar.baseline.date.invalid",
+ "input": {
+ "kind": 31922,
+ "content": "",
+ "tags": [["d", "bad-request"], ["title", "Bad request"], ["start", "2026-06-20"], ["a", "31923:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:not-a-calendar"]]
+ },
+ "expected": { "error": "invalid_tag", "tag": "a" }
+ },
+ {
+ "id": "baseline_time_should_level_day_variance_001",
+ "kind": "calendar.baseline.time.valid",
+ "input": {
+ "kind": 31923,
+ "content": "Harvest across UTC day boundaries.",
+ "tags": [
+ ["d", "harvest-window"],
+ ["title", "Harvest window"],
+ ["start", "086399"],
+ ["end", "172801"],
+ ["D", "000"],
+ ["D", "2"],
+ ["start_tzid", "America/Vancouver"],
+ ["t", "harvest"]
+ ]
+ },
+ "expected": {
+ "d": "harvest-window",
+ "title": "Harvest window",
+ "description": "Harvest across UTC day boundaries.",
+ "start_wire": "086399",
+ "start": 86399,
+ "end_wire": "172801",
+ "end": 172801,
+ "observed_days": [
+ {"wire": "000", "index": 0, "canonical": false},
+ {"wire": "2", "index": 2, "canonical": true}
+ ],
+ "start_tzid": "America/Vancouver",
+ "effective_end_tzid": "America/Vancouver",
+ "locations": [],
+ "participants": 0,
+ "categories": ["harvest"],
+ "references": [],
+ "calendar_requests": 0
+ }
+ },
+ {
+ "id": "baseline_time_single_start_day_001",
+ "kind": "calendar.baseline.time.valid",
+ "input": {
+ "kind": 31923,
+ "content": "",
+ "tags": [["d", "instant"], ["title", "Instant"], ["start", "86400"], ["D", "1"]]
+ },
+ "expected": {
+ "d": "instant",
+ "title": "Instant",
+ "start_wire": "86400",
+ "start": 86400,
+ "observed_days": [{"wire": "1", "index": 1, "canonical": true}],
+ "locations": [],
+ "participants": 0,
+ "categories": [],
+ "references": [],
+ "calendar_requests": 0
+ }
+ },
+ {
+ "id": "baseline_time_over_366_days_001",
+ "kind": "calendar.baseline.time.valid",
+ "input": {
+ "kind": 31923,
+ "content": "",
+ "tags": [["d", "long-range"], ["title", "Long range"], ["start", "0"], ["end", "31708800"], ["D", "0"]]
+ },
+ "expected": {
+ "d": "long-range",
+ "title": "Long range",
+ "start_wire": "0",
+ "start": 0,
+ "end_wire": "31708800",
+ "end": 31708800,
+ "observed_days": [{"wire": "0", "index": 0, "canonical": true}],
+ "locations": [],
+ "participants": 0,
+ "categories": [],
+ "references": [],
+ "calendar_requests": 0
+ }
+ },
+ {
+ "id": "baseline_time_non_start_in_range_day_001",
+ "kind": "calendar.baseline.time.valid",
+ "input": {
+ "kind": 31923,
+ "content": "",
+ "tags": [["d", "non-start-day"], ["title", "Non-start in-range day"], ["start", "86399"], ["end", "172801"], ["D", "1"]]
+ },
+ "expected": {
+ "d": "non-start-day",
+ "title": "Non-start in-range day",
+ "start_wire": "86399",
+ "start": 86399,
+ "end_wire": "172801",
+ "end": 172801,
+ "observed_days": [{"wire": "1", "index": 1, "canonical": true}],
+ "locations": [],
+ "participants": 0,
+ "categories": [],
+ "references": [],
+ "calendar_requests": 0
+ }
+ },
+ {
+ "id": "baseline_time_invalid_day_decimal_001",
+ "kind": "calendar.baseline.time.invalid",
+ "input": {
+ "kind": 31923,
+ "content": "",
+ "tags": [["d", "bad-day"], ["title", "Bad day"], ["start", "0"], ["D", "+0"]]
+ },
+ "expected": { "error": "invalid_tag", "tag": "D" }
+ },
+ {
+ "id": "baseline_time_missing_day_001",
+ "kind": "calendar.baseline.time.invalid",
+ "input": {
+ "kind": 31923,
+ "content": "",
+ "tags": [["d", "missing-day"], ["title", "Missing day"], ["start", "0"]]
+ },
+ "expected": { "error": "missing_tag", "tag": "D" }
+ },
+ {
+ "id": "baseline_time_out_of_range_day_001",
+ "kind": "calendar.baseline.time.invalid",
+ "input": {
+ "kind": 31923,
+ "content": "",
+ "tags": [["d", "out-of-range-day"], ["title", "Out-of-range day"], ["start", "0"], ["D", "1"]]
+ },
+ "expected": { "error": "invalid_tag", "tag": "D" }
+ },
+ {
+ "id": "baseline_time_invalid_iana_timezone_001",
+ "kind": "calendar.baseline.time.invalid",
+ "input": {
+ "kind": 31923,
+ "content": "",
+ "tags": [["d", "bad-zone"], ["title", "Bad zone"], ["start", "0"], ["D", "0"], ["start_tzid", "america/vancouver"]]
+ },
+ "expected": { "error": "invalid_tag", "tag": "start_tzid" }
+ },
+ {
+ "id": "baseline_date_duplicate_legacy_name_001",
+ "kind": "calendar.baseline.date.invalid",
+ "input": {
+ "kind": 31922,
+ "content": "",
+ "tags": [["d", "duplicate-name"], ["title", "Canonical title"], ["name", "Legacy one"], ["name", "Legacy two"], ["start", "2026-06-20"]]
+ },
+ "expected": { "error": "duplicate_tag", "tag": "name" }
+ },
+ {
+ "id": "baseline_wrong_event_kind_001",
+ "kind": "calendar.baseline.date.invalid",
+ "input": {
+ "kind": 1,
+ "content": "",
+ "tags": [["d", "wrong-kind"], ["title", "Wrong kind"], ["start", "2026-06-20"]]
+ },
+ "expected": { "error": "invalid_kind" }
+ }
+ ]
+}
diff --git a/crates/event_codec/tests/fixtures/calendar_radroots_profile.v1.json b/crates/event_codec/tests/fixtures/calendar_radroots_profile.v1.json
@@ -0,0 +1,256 @@
+{
+ "suite": "calendar_radroots_profile",
+ "contract_version": "0.1.0",
+ "vectors": [
+ {
+ "id": "profile_date_parse_minimum_gregorian_001",
+ "kind": "calendar.profile.date.parse.valid",
+ "input": { "value": "0001-01-01" },
+ "expected": { "value": "0001-01-01" }
+ },
+ {
+ "id": "profile_date_parse_leap_day_001",
+ "kind": "calendar.profile.date.parse.valid",
+ "input": { "value": "2000-02-29" },
+ "expected": { "value": "2000-02-29" }
+ },
+ {
+ "id": "profile_date_parse_year_zero_001",
+ "kind": "calendar.profile.date.parse.invalid",
+ "input": { "value": "0000-01-01" },
+ "expected": { "error": "invalid_date" }
+ },
+ {
+ "id": "profile_date_parse_noncanonical_width_001",
+ "kind": "calendar.profile.date.parse.invalid",
+ "input": { "value": "2026-6-20" },
+ "expected": { "error": "invalid_date" }
+ },
+ {
+ "id": "profile_authored_date_complete_001",
+ "kind": "calendar.profile.authored.date.valid",
+ "input": {
+ "d": "fernwood-csa-pickup",
+ "title": "CSA pickup",
+ "start": "2026-06-20",
+ "end": "2026-06-21",
+ "description": "Bring clean bins to the farm stand.",
+ "locations": ["Farm stand", "Moss Street Market"],
+ "geohash": "c23nb62w20st",
+ "summary": "Weekly pickup",
+ "image": {
+ "bytes_utf8": "victoria-calendar",
+ "descriptor": {
+ "url": "https://media.example/aba72b48bb536e341ab81c5f86053981c443029766a4374ee86a4f56577ebc84.webp",
+ "sha256": "aba72b48bb536e341ab81c5f86053981c443029766a4374ee86a4f56577ebc84",
+ "size": 17,
+ "type": "image/webp",
+ "uploaded": 1784347200
+ }
+ },
+ "participants": [
+ {"pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "relay": "wss://relay.example.test", "role": "host"}
+ ],
+ "categories": ["csa", "vegetables"],
+ "references": ["https://example.test/pickup-details"],
+ "calendar_requests": [
+ {"calendar": "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-calendar", "relay": "wss://relay.example.test"}
+ ]
+ },
+ "expected": {
+ "wire_parts": {
+ "kind": 31922,
+ "content": "Bring clean bins to the farm stand.",
+ "tags": [
+ ["d", "fernwood-csa-pickup"],
+ ["title", "CSA pickup"],
+ ["start", "2026-06-20"],
+ ["end", "2026-06-21"],
+ ["location", "Farm stand"],
+ ["location", "Moss Street Market"],
+ ["g", "c23nb62w20st"],
+ ["summary", "Weekly pickup"],
+ ["image", "https://media.example/aba72b48bb536e341ab81c5f86053981c443029766a4374ee86a4f56577ebc84.webp"],
+ ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "wss://relay.example.test", "host"],
+ ["t", "csa"],
+ ["t", "vegetables"],
+ ["r", "https://example.test/pickup-details"],
+ ["a", "31924:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:farm-calendar", "wss://relay.example.test"]
+ ]
+ },
+ "media_verification": "byte_verified",
+ "upload_completion": "not_attested_by_codec"
+ }
+ },
+ {
+ "id": "profile_authored_date_equal_end_001",
+ "kind": "calendar.profile.authored.date.invalid",
+ "input": {"d": "equal-date", "title": "Equal date", "start": "2026-06-20", "end": "2026-06-20"},
+ "expected": { "error": "invalid_range" }
+ },
+ {
+ "id": "profile_authored_date_noncanonical_title_001",
+ "kind": "calendar.profile.authored.date.invalid",
+ "input": {"d": "bad-title", "title": " Bad title ", "start": "2026-06-20"},
+ "expected": { "error": "invalid_title" }
+ },
+ {
+ "id": "profile_authored_date_uppercase_geohash_001",
+ "kind": "calendar.profile.authored.date.invalid",
+ "input": {"d": "bad-geohash", "title": "Bad geohash", "start": "2026-06-20", "geohash": "C23NB62W20ST"},
+ "expected": { "error": "invalid_geohash" }
+ },
+ {
+ "id": "profile_authored_time_canonical_001",
+ "kind": "calendar.profile.authored.time.valid",
+ "input": {
+ "d": "wash-pack-shift",
+ "title": "Wash pack shift",
+ "start": 86399,
+ "end": 172801,
+ "start_tzid": "America/Vancouver",
+ "locations": ["Pack shed"],
+ "geohash": "c23nb62w20st",
+ "categories": ["farm-work"]
+ },
+ "expected": {
+ "effective_end_tzid": "America/Vancouver",
+ "wire_parts": {
+ "kind": 31923,
+ "content": "",
+ "tags": [
+ ["d", "wash-pack-shift"],
+ ["title", "Wash pack shift"],
+ ["start", "86399"],
+ ["end", "172801"],
+ ["D", "0"],
+ ["D", "1"],
+ ["D", "2"],
+ ["start_tzid", "America/Vancouver"],
+ ["location", "Pack shed"],
+ ["g", "c23nb62w20st"],
+ ["t", "farm-work"]
+ ]
+ }
+ }
+ },
+ {
+ "id": "profile_authored_time_maximum_coverage_001",
+ "kind": "calendar.profile.authored.time.coverage.valid",
+ "input": {"d": "annual-farm-plan", "title": "Annual farm plan", "start": 0, "end": 31622400},
+ "expected": {"count": 366, "first": "0", "last": "365"}
+ },
+ {
+ "id": "profile_authored_time_over_coverage_limit_001",
+ "kind": "calendar.profile.authored.time.invalid",
+ "input": {"d": "overlong-plan", "title": "Overlong plan", "start": 0, "end": 31708800},
+ "expected": {"error": "covered_day_limit_exceeded", "max": 366, "actual": 367}
+ },
+ {
+ "id": "profile_authored_time_invalid_iana_timezone_001",
+ "kind": "calendar.profile.authored.time.invalid",
+ "input": {"d": "bad-zone", "title": "Bad zone", "start": 0, "start_tzid": "america/vancouver"},
+ "expected": {"error": "invalid_time_zone"}
+ },
+ {
+ "id": "profile_admit_date_canonical_001",
+ "kind": "calendar.profile.admit.date.valid",
+ "input": {
+ "kind": 31922,
+ "content": "",
+ "tags": [["d", "farm-tour"], ["title", "Urban farm tour"], ["start", "2026-07-18"], ["image", "https://media.example/aba72b48bb536e341ab81c5f86053981c443029766a4374ee86a4f56577ebc84.webp"]]
+ },
+ "expected": {"d": "farm-tour", "blossom_image": true}
+ },
+ {
+ "id": "profile_admit_date_forbidden_day_extension_001",
+ "kind": "calendar.profile.admit.date.invalid",
+ "input": {
+ "kind": 31922,
+ "content": "",
+ "tags": [["d", "farm-tour"], ["title", "Urban farm tour"], ["start", "2026-07-18"], ["D", "20620"]]
+ },
+ "expected": {"error": "forbidden_date_day_index"}
+ },
+ {
+ "id": "profile_admit_date_noncanonical_metadata_001",
+ "kind": "calendar.profile.admit.date.invalid",
+ "input": {
+ "kind": 31922,
+ "content": "",
+ "tags": [["d", "farm-tour"], ["title", " Urban farm tour "], ["start", "2026-07-18"]]
+ },
+ "expected": {"error": "non_canonical_field"}
+ },
+ {
+ "id": "profile_admit_date_uppercase_relay_hint_001",
+ "kind": "calendar.profile.admit.date.invalid",
+ "input": {
+ "kind": 31922,
+ "content": "",
+ "tags": [["d", "farm-tour"], ["title", "Urban farm tour"], ["start", "2026-07-18"], ["p", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "WSS://relay.example.test"]]
+ },
+ "expected": {"error": "non_canonical_field"}
+ },
+ {
+ "id": "profile_admit_date_non_blossom_image_001",
+ "kind": "calendar.profile.admit.date.invalid",
+ "input": {
+ "kind": 31922,
+ "content": "",
+ "tags": [["d", "farm-tour"], ["title", "Urban farm tour"], ["start", "2026-07-18"], ["image", "https://ordinary.example.test/farm-tour.jpg"]]
+ },
+ "expected": {"error": "non_blossom_image"}
+ },
+ {
+ "id": "profile_admit_time_canonical_001",
+ "kind": "calendar.profile.admit.time.valid",
+ "input": {
+ "kind": 31923,
+ "content": "",
+ "tags": [["d", "harvest-window"], ["title", "Harvest window"], ["start", "86399"], ["end", "172801"], ["D", "0"], ["D", "1"], ["D", "2"]]
+ },
+ "expected": {"d": "harvest-window", "covered_days": [0, 1, 2], "blossom_image": false}
+ },
+ {
+ "id": "profile_admit_time_incomplete_coverage_001",
+ "kind": "calendar.profile.admit.time.invalid",
+ "input": {
+ "kind": 31923,
+ "content": "",
+ "tags": [["d", "harvest-window"], ["title", "Harvest window"], ["start", "86399"], ["end", "172801"], ["D", "0"], ["D", "2"]]
+ },
+ "expected": {"error": "incomplete_day_coverage"}
+ },
+ {
+ "id": "profile_admit_time_noncanonical_timestamp_001",
+ "kind": "calendar.profile.admit.time.invalid",
+ "input": {
+ "kind": 31923,
+ "content": "",
+ "tags": [["d", "harvest-window"], ["title", "Harvest window"], ["start", "086399"], ["end", "172801"], ["D", "0"], ["D", "1"], ["D", "2"]]
+ },
+ "expected": {"error": "non_canonical_field"}
+ },
+ {
+ "id": "profile_admit_time_noncanonical_day_001",
+ "kind": "calendar.profile.admit.time.invalid",
+ "input": {
+ "kind": 31923,
+ "content": "",
+ "tags": [["d", "harvest-window"], ["title", "Harvest window"], ["start", "86399"], ["end", "172801"], ["D", "00"], ["D", "1"], ["D", "2"]]
+ },
+ "expected": {"error": "incomplete_day_coverage"}
+ },
+ {
+ "id": "profile_admit_time_over_coverage_limit_001",
+ "kind": "calendar.profile.admit.time.invalid",
+ "input": {
+ "kind": 31923,
+ "content": "",
+ "tags": [["d", "long-range"], ["title", "Long range"], ["start", "0"], ["end", "31708800"], ["D", "0"]]
+ },
+ "expected": {"error": "covered_day_limit_exceeded", "max": 366, "actual": 367}
+ }
+ ]
+}
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::CalendarInclusionRequest => "calendar_inclusion_request",
+ RadrootsTagSemantic::CalendarEnd => "calendar_end",
+ RadrootsTagSemantic::CalendarStart => "calendar_start",
RadrootsTagSemantic::Category => "category",
RadrootsTagSemantic::Citation => "citation",
RadrootsTagSemantic::Contract => "contract",
@@ -475,10 +478,12 @@ pub fn tag_semantic_name(value: RadrootsTagSemantic) -> &'static str {
RadrootsTagSemantic::ListingAddress => "listing_address",
RadrootsTagSemantic::ListingSnapshot => "listing_snapshot",
RadrootsTagSemantic::Location => "location",
+ RadrootsTagSemantic::Participant => "participant",
RadrootsTagSemantic::PreviousEvent => "previous_event",
RadrootsTagSemantic::Price => "price",
RadrootsTagSemantic::PublishedAt => "published_at",
RadrootsTagSemantic::Relay => "relay",
+ RadrootsTagSemantic::Reference => "reference",
RadrootsTagSemantic::ReviewTarget => "review_target",
RadrootsTagSemantic::RootEvent => "root_event",
RadrootsTagSemantic::ServiceInput => "service_input",
@@ -488,25 +493,31 @@ pub fn tag_semantic_name(value: RadrootsTagSemantic) -> &'static str {
RadrootsTagSemantic::Summary => "summary",
RadrootsTagSemantic::Title => "title",
RadrootsTagSemantic::Topic => "topic",
+ RadrootsTagSemantic::TimeZone => "time_zone",
RadrootsTagSemantic::Url => "url",
+ RadrootsTagSemantic::UtcDayCoverage => "utc_day_coverage",
}
}
pub fn tag_value_type_name(value: RadrootsTagValueType) -> &'static str {
match value {
RadrootsTagValueType::AddressableCoordinate => "addressable_coordinate",
+ RadrootsTagValueType::CalendarDate => "calendar_date",
RadrootsTagValueType::ContractId => "contract_id",
RadrootsTagValueType::DTag => "d_tag",
RadrootsTagValueType::EventId => "event_id",
RadrootsTagValueType::EventPointer => "event_pointer",
RadrootsTagValueType::Geohash => "geohash",
+ RadrootsTagValueType::IanaTimeZoneId => "iana_time_zone_id",
RadrootsTagValueType::Kind => "kind",
RadrootsTagValueType::PublicKey => "public_key",
RadrootsTagValueType::RelayUrl => "relay_url",
RadrootsTagValueType::Sha256 => "sha256",
RadrootsTagValueType::Text => "text",
RadrootsTagValueType::UnixTimestamp => "unix_timestamp",
+ RadrootsTagValueType::Uri => "uri",
RadrootsTagValueType::Url => "url",
+ RadrootsTagValueType::UtcDayIndex => "utc_day_index",
RadrootsTagValueType::Uuid => "uuid",
}
}
@@ -691,6 +702,12 @@ mod tests {
RadrootsTagSemantic::AddressableCoordinate,
"addressable_coordinate",
),
+ (
+ RadrootsTagSemantic::CalendarInclusionRequest,
+ "calendar_inclusion_request",
+ ),
+ (RadrootsTagSemantic::CalendarEnd, "calendar_end"),
+ (RadrootsTagSemantic::CalendarStart, "calendar_start"),
(RadrootsTagSemantic::Category, "category"),
(RadrootsTagSemantic::Citation, "citation"),
(RadrootsTagSemantic::Contract, "contract"),
@@ -705,10 +722,12 @@ mod tests {
(RadrootsTagSemantic::ListingAddress, "listing_address"),
(RadrootsTagSemantic::ListingSnapshot, "listing_snapshot"),
(RadrootsTagSemantic::Location, "location"),
+ (RadrootsTagSemantic::Participant, "participant"),
(RadrootsTagSemantic::PreviousEvent, "previous_event"),
(RadrootsTagSemantic::Price, "price"),
(RadrootsTagSemantic::PublishedAt, "published_at"),
(RadrootsTagSemantic::Relay, "relay"),
+ (RadrootsTagSemantic::Reference, "reference"),
(RadrootsTagSemantic::ReviewTarget, "review_target"),
(RadrootsTagSemantic::RootEvent, "root_event"),
(RadrootsTagSemantic::ServiceInput, "service_input"),
@@ -718,7 +737,9 @@ mod tests {
(RadrootsTagSemantic::Summary, "summary"),
(RadrootsTagSemantic::Title, "title"),
(RadrootsTagSemantic::Topic, "topic"),
+ (RadrootsTagSemantic::TimeZone, "time_zone"),
(RadrootsTagSemantic::Url, "url"),
+ (RadrootsTagSemantic::UtcDayCoverage, "utc_day_coverage"),
] {
assert_eq!(tag_semantic_name(semantic), expected);
}
@@ -728,18 +749,22 @@ mod tests {
RadrootsTagValueType::AddressableCoordinate,
"addressable_coordinate",
),
+ (RadrootsTagValueType::CalendarDate, "calendar_date"),
(RadrootsTagValueType::ContractId, "contract_id"),
(RadrootsTagValueType::DTag, "d_tag"),
(RadrootsTagValueType::EventId, "event_id"),
(RadrootsTagValueType::EventPointer, "event_pointer"),
(RadrootsTagValueType::Geohash, "geohash"),
+ (RadrootsTagValueType::IanaTimeZoneId, "iana_time_zone_id"),
(RadrootsTagValueType::Kind, "kind"),
(RadrootsTagValueType::PublicKey, "public_key"),
(RadrootsTagValueType::RelayUrl, "relay_url"),
(RadrootsTagValueType::Sha256, "sha256"),
(RadrootsTagValueType::Text, "text"),
(RadrootsTagValueType::UnixTimestamp, "unix_timestamp"),
+ (RadrootsTagValueType::Uri, "uri"),
(RadrootsTagValueType::Url, "url"),
+ (RadrootsTagValueType::UtcDayIndex, "utc_day_index"),
(RadrootsTagValueType::Uuid, "uuid"),
] {
assert_eq!(tag_value_type_name(value_type), expected);
diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs
@@ -46,12 +46,158 @@ const DTO_TOOLING_DEPENDENCIES: [&str; 4] = [
"dto_bindgen_core",
"dto_bindgen_macros",
];
-const RETIRED_OPERATION_EVENT_NAMES: [&str; 5] = [
+const RETIRED_OPERATION_EVENT_NAMES: [&str; 10] = [
"WireEventParts",
"RadrootsFrozenEventDraft",
"RadrootsNostrEvent",
"RadrootsNostrEventRef",
"RadrootsNostrEventPtr",
+ "RadrootsCalendarDateEvent",
+ "RadrootsCalendarTimeEvent",
+ "RadrootsCalendarDateValue",
+ "RadrootsInboundCalendarDateEvent",
+ "RadrootsInboundCalendarTimeEvent",
+];
+const REQUIRED_CALENDAR_PUBLIC_TYPES: [&str; 18] = [
+ "RadrootsNip01EventWireParts",
+ "RadrootsAuthoredImage",
+ "RadrootsAuthoredImageError",
+ "RadrootsIanaTimeZoneId",
+ "RadrootsCalendarUri",
+ "RadrootsCalendarRequest",
+ "RadrootsCalendarEventError",
+ "RadrootsCalendarDate",
+ "RadrootsAuthoredCalendarDateEvent",
+ "RadrootsAuthoredCalendarTimeEvent",
+ "RadrootsParsedNip52CalendarCommon",
+ "RadrootsParsedNip52CalendarCommonParts",
+ "RadrootsParsedNip52CalendarDateEvent",
+ "RadrootsObservedUtcDay",
+ "RadrootsParsedNip52CalendarTimeEvent",
+ "RadrootsCalendarAdmissionError",
+ "RadrootsAdmittedCalendarDateEvent",
+ "RadrootsAdmittedCalendarTimeEvent",
+];
+const CALENDAR_OPERATION_EXPECTATIONS: [CalendarOperationExpectation; 6] = [
+ CalendarOperationExpectation {
+ key: "social_calendar_date_event_build_authored_draft",
+ id: "social.calendar_date_event.build_authored_draft",
+ inputs: &["RadrootsAuthoredCalendarDateEvent"],
+ 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::RadrootsAuthoredCalendarDateEvent",
+ "radroots_event::calendar::RadrootsCalendarDate",
+ "radroots_event::calendar::RadrootsCalendarEventError",
+ "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_date_event_parse_nip52",
+ id: "social.calendar_date_event.parse_nip52",
+ inputs: &["u32", "NostrTags", "String"],
+ outputs: &["RadrootsParsedNip52CalendarDateEvent"],
+ error_class: "parse_error",
+ rust_modules: &[
+ "crates/event/src/calendar.rs",
+ "crates/event_codec/src/calendar/decode.rs",
+ ],
+ rust_types: &[
+ "radroots_event::calendar::RadrootsCalendarDate",
+ "radroots_event::calendar::RadrootsCalendarRequest",
+ "radroots_event::calendar::RadrootsCalendarUri",
+ "radroots_event::calendar::RadrootsParsedNip52CalendarCommon",
+ "radroots_event::calendar::RadrootsParsedNip52CalendarCommonParts",
+ "radroots_event::calendar::RadrootsParsedNip52CalendarDateEvent",
+ "radroots_event_codec::error::EventParseError",
+ ],
+ vector: "contracts/conformance/vectors/calendar/nip52_baseline.v1.json",
+ },
+ CalendarOperationExpectation {
+ key: "social_calendar_date_event_admit_radroots_profile",
+ id: "social.calendar_date_event.admit_radroots_profile",
+ inputs: &["RadrootsParsedNip52CalendarDateEvent"],
+ outputs: &["RadrootsAdmittedCalendarDateEvent"],
+ error_class: "admission_error",
+ rust_modules: &[
+ "crates/event/src/calendar.rs",
+ "crates/event_codec/src/calendar/decode.rs",
+ ],
+ rust_types: &[
+ "radroots_event::calendar::RadrootsAdmittedCalendarDateEvent",
+ "radroots_event::calendar::RadrootsCalendarAdmissionError",
+ "radroots_event::calendar::RadrootsParsedNip52CalendarDateEvent",
+ ],
+ vector: "contracts/conformance/vectors/calendar/radroots_profile.v1.json",
+ },
+ CalendarOperationExpectation {
+ key: "social_calendar_time_event_build_authored_draft",
+ id: "social.calendar_time_event.build_authored_draft",
+ inputs: &["RadrootsAuthoredCalendarTimeEvent"],
+ 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::RadrootsAuthoredCalendarTimeEvent",
+ "radroots_event::calendar::RadrootsCalendarEventError",
+ "radroots_event::calendar::RadrootsIanaTimeZoneId",
+ "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_time_event_parse_nip52",
+ id: "social.calendar_time_event.parse_nip52",
+ inputs: &["u32", "NostrTags", "String"],
+ outputs: &["RadrootsParsedNip52CalendarTimeEvent"],
+ error_class: "parse_error",
+ rust_modules: &[
+ "crates/event/src/calendar.rs",
+ "crates/event_codec/src/calendar/decode.rs",
+ ],
+ rust_types: &[
+ "radroots_event::calendar::RadrootsCalendarRequest",
+ "radroots_event::calendar::RadrootsCalendarUri",
+ "radroots_event::calendar::RadrootsIanaTimeZoneId",
+ "radroots_event::calendar::RadrootsObservedUtcDay",
+ "radroots_event::calendar::RadrootsParsedNip52CalendarCommon",
+ "radroots_event::calendar::RadrootsParsedNip52CalendarCommonParts",
+ "radroots_event::calendar::RadrootsParsedNip52CalendarTimeEvent",
+ "radroots_event_codec::error::EventParseError",
+ ],
+ vector: "contracts/conformance/vectors/calendar/nip52_baseline.v1.json",
+ },
+ CalendarOperationExpectation {
+ key: "social_calendar_time_event_admit_radroots_profile",
+ id: "social.calendar_time_event.admit_radroots_profile",
+ inputs: &["RadrootsParsedNip52CalendarTimeEvent"],
+ outputs: &["RadrootsAdmittedCalendarTimeEvent"],
+ error_class: "admission_error",
+ rust_modules: &[
+ "crates/event/src/calendar.rs",
+ "crates/event_codec/src/calendar/decode.rs",
+ ],
+ rust_types: &[
+ "radroots_event::calendar::RadrootsAdmittedCalendarTimeEvent",
+ "radroots_event::calendar::RadrootsCalendarAdmissionError",
+ "radroots_event::calendar::RadrootsParsedNip52CalendarTimeEvent",
+ ],
+ vector: "contracts/conformance/vectors/calendar/radroots_profile.v1.json",
+ },
];
const EVENT_BOUNDARY_MATRIX_RELATIVES: [&str; 2] = [
"contracts/event_boundary_matrix.md",
@@ -182,6 +328,18 @@ pub struct PublicOperationConformance {
pub vector: String,
}
+#[derive(Clone, Copy)]
+struct CalendarOperationExpectation {
+ key: &'static str,
+ id: &'static str,
+ inputs: &'static [&'static str],
+ outputs: &'static [&'static str],
+ error_class: &'static str,
+ rust_modules: &'static [&'static str],
+ rust_types: &'static [&'static str],
+ vector: &'static str,
+}
+
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct VersionPolicy {
@@ -559,10 +717,25 @@ const APP_HANDLER_WITNESSES: [EventBoundarySourceWitness; 2] = [
},
];
-const CALENDAR_DATE_WITNESSES: [EventBoundarySourceWitness; 2] = [
+const CALENDAR_DATE_WITNESSES: [EventBoundarySourceWitness; 4] = [
EventBoundarySourceWitness {
relative_path: "crates/event/src/calendar.rs",
- required_fragments: &["pub struct RadrootsCalendarDateEvent"],
+ required_fragments: &[
+ "pub struct RadrootsAuthoredCalendarDateEvent",
+ "pub struct RadrootsParsedNip52CalendarDateEvent",
+ "pub struct RadrootsAdmittedCalendarDateEvent",
+ ],
+ },
+ EventBoundarySourceWitness {
+ relative_path: "crates/event_codec/src/calendar/encode.rs",
+ required_fragments: &["pub fn date_to_wire_parts("],
+ },
+ EventBoundarySourceWitness {
+ relative_path: "crates/event_codec/src/calendar/decode.rs",
+ required_fragments: &[
+ "pub fn parse_nip52_calendar_date_event(",
+ "pub fn admit_radroots_calendar_date_event(",
+ ],
},
EventBoundarySourceWitness {
relative_path: "crates/event/src/kinds.rs",
@@ -570,10 +743,25 @@ const CALENDAR_DATE_WITNESSES: [EventBoundarySourceWitness; 2] = [
},
];
-const CALENDAR_TIME_WITNESSES: [EventBoundarySourceWitness; 2] = [
+const CALENDAR_TIME_WITNESSES: [EventBoundarySourceWitness; 4] = [
EventBoundarySourceWitness {
relative_path: "crates/event/src/calendar.rs",
- required_fragments: &["pub struct RadrootsCalendarTimeEvent"],
+ required_fragments: &[
+ "pub struct RadrootsAuthoredCalendarTimeEvent",
+ "pub struct RadrootsParsedNip52CalendarTimeEvent",
+ "pub struct RadrootsAdmittedCalendarTimeEvent",
+ ],
+ },
+ EventBoundarySourceWitness {
+ relative_path: "crates/event_codec/src/calendar/encode.rs",
+ required_fragments: &["pub fn time_to_wire_parts("],
+ },
+ EventBoundarySourceWitness {
+ relative_path: "crates/event_codec/src/calendar/decode.rs",
+ required_fragments: &[
+ "pub fn parse_nip52_calendar_time_event(",
+ "pub fn admit_radroots_calendar_time_event(",
+ ],
},
EventBoundarySourceWitness {
relative_path: "crates/event/src/kinds.rs",
@@ -1060,7 +1248,7 @@ const CANONICAL_EVENT_BOUNDARY_EXPECTATIONS: [EventBoundaryExpectation; 41] = [
EventBoundaryExpectation {
domain: "calendar_date",
kind: "31922",
- radroots_type: "RadrootsCalendarDateEvent",
+ radroots_type: "RadrootsAuthoredCalendarDateEvent / RadrootsParsedNip52CalendarDateEvent / RadrootsAdmittedCalendarDateEvent",
rpc_methods: &[
"events.calendar_date.publish",
"events.calendar_date.list",
@@ -1071,7 +1259,7 @@ const CANONICAL_EVENT_BOUNDARY_EXPECTATIONS: [EventBoundaryExpectation; 41] = [
EventBoundaryExpectation {
domain: "calendar_time",
kind: "31923",
- radroots_type: "RadrootsCalendarTimeEvent",
+ radroots_type: "RadrootsAuthoredCalendarTimeEvent / RadrootsParsedNip52CalendarTimeEvent / RadrootsAdmittedCalendarTimeEvent",
rpc_methods: &[
"events.calendar_time.publish",
"events.calendar_time.list",
@@ -2894,6 +3082,153 @@ fn validate_operations_contract(
validate_conformance_vector_file(&vector_path, &operations_manifest.contract.version)?;
}
+ if domains.contains("social") {
+ validate_calendar_operation_authority(operations_manifest, &shared_types)?;
+ }
+
+ Ok(())
+}
+
+fn validate_calendar_operation_authority(
+ manifest: &OperationsContractManifest,
+ shared_types: &BTreeSet<String>,
+) -> Result<(), String> {
+ for required in REQUIRED_CALENDAR_PUBLIC_TYPES {
+ if !shared_types.contains(required) {
+ return Err(format!(
+ "calendar operation authority requires shared public type {required}"
+ ));
+ }
+ }
+
+ let expected_keys = CALENDAR_OPERATION_EXPECTATIONS
+ .iter()
+ .map(|expectation| expectation.key.to_string())
+ .collect::<BTreeSet<_>>();
+ let actual_keys = manifest
+ .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.")
+ })
+ .map(|(key, _)| key.clone())
+ .collect::<BTreeSet<_>>();
+ if actual_keys != expected_keys {
+ let missing = expected_keys
+ .difference(&actual_keys)
+ .cloned()
+ .collect::<BTreeSet<_>>();
+ let unexpected = actual_keys
+ .difference(&expected_keys)
+ .cloned()
+ .collect::<BTreeSet<_>>();
+ return Err(format!(
+ "calendar operation authority drift: missing {}; unexpected {}",
+ join_set(&missing),
+ join_set(&unexpected)
+ ));
+ }
+
+ for expected in CALENDAR_OPERATION_EXPECTATIONS {
+ let operation = manifest
+ .operations
+ .get(expected.key)
+ .ok_or_else(|| format!("calendar operation {} is required", expected.key))?;
+ validate_calendar_operation_scalar(expected.key, "domain", &operation.domain, "social")?;
+ validate_calendar_operation_scalar(expected.key, "id", &operation.id, expected.id)?;
+ validate_calendar_operation_scalar(
+ expected.key,
+ "stability",
+ &operation.stability,
+ "beta",
+ )?;
+ validate_calendar_operation_scalar(
+ expected.key,
+ "error_class",
+ &operation.error_class,
+ expected.error_class,
+ )?;
+ validate_calendar_operation_scalar(expected.key, "signing", &operation.signing, "none")?;
+ validate_calendar_operation_scalar(
+ expected.key,
+ "transport",
+ &operation.transport,
+ "none",
+ )?;
+ if !operation.deterministic {
+ return Err(format!(
+ "calendar operation {} deterministic drift: expected true, got false",
+ expected.key
+ ));
+ }
+ validate_calendar_operation_sequence(
+ expected.key,
+ "inputs",
+ &operation.inputs,
+ expected.inputs,
+ )?;
+ validate_calendar_operation_sequence(
+ expected.key,
+ "outputs",
+ &operation.outputs,
+ expected.outputs,
+ )?;
+ validate_calendar_operation_sequence(
+ expected.key,
+ "implementation.rust_modules",
+ &operation.implementation.rust_modules,
+ expected.rust_modules,
+ )?;
+ validate_calendar_operation_sequence(
+ expected.key,
+ "implementation.rust_types",
+ &operation.implementation.rust_types,
+ expected.rust_types,
+ )?;
+ validate_calendar_operation_scalar(
+ expected.key,
+ "conformance.vector",
+ &operation.conformance.vector,
+ expected.vector,
+ )?;
+ }
+
+ Ok(())
+}
+
+fn validate_calendar_operation_scalar(
+ operation_key: &str,
+ field: &str,
+ actual: &str,
+ expected: &str,
+) -> Result<(), String> {
+ if actual != expected {
+ return Err(format!(
+ "calendar operation {operation_key} {field} drift: expected {expected}, got {actual}"
+ ));
+ }
+ Ok(())
+}
+
+fn validate_calendar_operation_sequence(
+ operation_key: &str,
+ field: &str,
+ actual: &[String],
+ expected: &[&str],
+) -> Result<(), String> {
+ if !actual
+ .iter()
+ .map(String::as_str)
+ .eq(expected.iter().copied())
+ {
+ return Err(format!(
+ "calendar operation {operation_key} {field} drift: expected {:?}, got {:?}",
+ expected, actual
+ ));
+ }
Ok(())
}
@@ -4531,6 +4866,54 @@ crates = ["radroots_a", "radroots_b", "radroots_c", "radroots_d", "radroots_e"]
}
#[test]
+ fn calendar_operation_authority_reports_exact_signature_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");
+ manifest
+ .operations
+ .get_mut("social_calendar_date_event_build_authored_draft")
+ .expect("calendar authored operation")
+ .outputs = vec!["NostrTags".to_string()];
+
+ let error = validate_calendar_operation_authority(manifest, &shared_types)
+ .expect_err("calendar operation signature drift");
+ assert!(error.contains("outputs drift"));
+ }
+
+ #[test]
+ fn calendar_operation_authority_reports_obsolete_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_date_event_build_authored_draft")
+ .expect("calendar authored operation");
+ manifest.operations.insert(
+ "social_calendar_date_event_build_tags".to_string(),
+ obsolete,
+ );
+
+ let error = validate_calendar_operation_authority(manifest, &shared_types)
+ .expect_err("obsolete calendar operation drift");
+ assert!(error.contains("calendar operation authority drift"));
+ assert!(error.contains("social_calendar_date_event_build_tags"));
+ }
+
+ #[test]
fn validate_current_canonical_event_boundary() {
let root = workspace_root();
validate_canonical_event_boundary(&root).expect("validate canonical event boundary");
@@ -5982,6 +6365,18 @@ rust_package = "radroots_sdk"
},
);
assert_bundle_error(
+ "shared_types.public uses retired event type RadrootsInboundCalendarDateEvent",
+ |bundle| {
+ bundle
+ .operations_manifest
+ .as_mut()
+ .expect("operations manifest")
+ .shared_types
+ .public
+ .push("RadrootsInboundCalendarDateEvent".to_string());
+ },
+ );
+ assert_bundle_error(
"operation profile.build_draft inputs uses retired event type RadrootsNostrEvent",
|bundle| {
bundle