lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

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:
MCargo.lock | 8++++++++
MCargo.toml | 1+
Acontracts/conformance/vectors/calendar/nip52_baseline.v1.json | 333+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acontracts/conformance/vectors/calendar/radroots_profile.v1.json | 256+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/conformance/vectors/social/mvp.v1.json | 114-------------------------------------------------------------------------------
Mcontracts/event_boundary_matrix.md | 13+++++++++++--
Mcontracts/events/social-events.md | 114++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcontracts/operations.toml | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcrates/event/Cargo.toml | 4+++-
Mcrates/event/README | 32++++++++++++++++++++++++++++++++
Mcrates/event/src/calendar.rs | 2132++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/event/src/contract.rs | 685++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/event/src/ids.rs | 68+++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/event/src/lib.rs | 2++
Acrates/event/src/media.rs | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event/src/social.rs | 9---------
Mcrates/event_codec/README | 44++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_codec/src/calendar/decode.rs | 427+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mcrates/event_codec/src/calendar/encode.rs | 166+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mcrates/event_codec/src/manifest.rs | 11+++++++++++
Mcrates/event_codec/src/social_helpers.rs | 95+------------------------------------------------------------------------------
Mcrates/event_codec/tests/calendar.rs | 1193+++++++++++++++++++++++++++++++------------------------------------------------
Acrates/event_codec/tests/calendar_conformance.rs | 872+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_codec/tests/fixtures/calendar_nip52_baseline.v1.json | 333+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_codec/tests/fixtures/calendar_radroots_profile.v1.json | 256+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_store/src/model.rs | 25+++++++++++++++++++++++++
Mtools/xtask/src/contract.rs | 409+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
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