lib

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

commit 58404f92986945ffd7e5c25fa5d9dc9145c7df8d
parent 5c085d9952a1284ced21911e5930e18e1d82bc7e
Author: triesap <tyson@radroots.org>
Date:   Fri,  7 Aug 2026 09:02:06 +0000

feat: add typed phase 1 authoring profiles

- Register strict NIP-52 date and time authoring contracts.
- Bind all five mobile Add commands to immutable authored plans.
- Add sealed calendar signing and external-host completion paths.
- Freeze exact wire vectors and adversarial historical validation.

Diffstat:
MCargo.lock | 1+
Mcontracts/conformance/vectors/event/authored_operations.v1.json | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/event_boundary_matrix.md | 4++--
Mcontracts/event_store/event_contract_registry_v7.inventory.json | 8++++----
Mcontracts/event_store/event_contract_registry_v7.inventory.sha256 | 2+-
Mcontracts/events/social-events.md | 14++++++++++++--
Mcrates/event/src/contract/registry_v7.rs | 10++++++----
Mcrates/event/src/contract/registry_v7/tests.rs | 12++++++++++--
Mcrates/event_codec/src/authoring/typed.rs | 175++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/event_codec/src/error.rs | 2+-
Mcrates/event_codec/tests/authored_typed_plans.rs | 85++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/mobile_core/Cargo.toml | 4++++
Mcrates/mobile_core/src/runtime/product_surface.rs | 5+++++
Acrates/mobile_core/src/runtime/product_surface/authoring.rs | 310+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/nostr/README.md | 4++--
Mcrates/nostr/src/event.rs | 4++++
Acrates/nostr/src/events/calendar.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/nostr/src/events/mod.rs | 2++
Mcrates/nostr/tests/authored_wire_corpus.rs | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Acrates/nostr/tests/calendar_event_builder.rs | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/nostr/tests/fixtures/authored_operations.v1.json | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/package_boundary.rs | 2+-
22 files changed, 988 insertions(+), 27 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3568,6 +3568,7 @@ dependencies = [ "chrono", "hex", "nostr 0.44.7", + "radroots_blossom", "radroots_event", "radroots_event_codec", "radroots_sdk", diff --git a/contracts/conformance/vectors/event/authored_operations.v1.json b/contracts/conformance/vectors/event/authored_operations.v1.json @@ -285,6 +285,98 @@ } }, { + "id": "typed_calendar_date_event_011", + "kind": "authored_operations.wire", + "input": { + "contract_id": "radroots.calendar.date_event.v1", + "authoring": "typed" + }, + "expected": { + "kind": 31922, + "created_at": 1784347200, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "tags": [ + [ + "d", + "market-day" + ], + [ + "title", + "Saturday Market" + ], + [ + "start", + "2026-08-08" + ], + [ + "end", + "2026-08-09" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "summary", + "Local food and seeds" + ] + ], + "content": "Farmers market and seed swap.", + "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,31922,[[\"d\",\"market-day\"],[\"title\",\"Saturday Market\"],[\"start\",\"2026-08-08\"],[\"end\",\"2026-08-09\"],[\"location\",\"Central Saanich, BC\"],[\"summary\",\"Local food and seeds\"]],\"Farmers market and seed swap.\"]", + "event_id": "b63f56e5a5a8ec22d084fbcafbaa03f3ffd2580dc9df50bb18d5df8c64ddcbe6", + "signature": "0de7de628fb564be7b5d34e49cfe0d217476d8cca4a6e2cdf06b351401cd6e2476a30d7ad87a815bf74cd7ae47987379341487d71dc70913674dd7fc792a4469", + "raw_json": "{\"id\":\"b63f56e5a5a8ec22d084fbcafbaa03f3ffd2580dc9df50bb18d5df8c64ddcbe6\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":31922,\"tags\":[[\"d\",\"market-day\"],[\"title\",\"Saturday Market\"],[\"start\",\"2026-08-08\"],[\"end\",\"2026-08-09\"],[\"location\",\"Central Saanich, BC\"],[\"summary\",\"Local food and seeds\"]],\"content\":\"Farmers market and seed swap.\",\"sig\":\"0de7de628fb564be7b5d34e49cfe0d217476d8cca4a6e2cdf06b351401cd6e2476a30d7ad87a815bf74cd7ae47987379341487d71dc70913674dd7fc792a4469\"}" + } + }, + { + "id": "typed_calendar_time_event_012", + "kind": "authored_operations.wire", + "input": { + "contract_id": "radroots.calendar.time_event.v1", + "authoring": "typed" + }, + "expected": { + "kind": 31923, + "created_at": 1784347200, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "tags": [ + [ + "d", + "farm-tour" + ], + [ + "title", + "Farm Tour" + ], + [ + "start", + "1784380800" + ], + [ + "end", + "1784387900" + ], + [ + "D", + "20652" + ], + [ + "start_tzid", + "America/Vancouver" + ], + [ + "location", + "Saanich Peninsula" + ] + ], + "content": "Walk the fields and meet the growers.", + "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,31923,[[\"d\",\"farm-tour\"],[\"title\",\"Farm Tour\"],[\"start\",\"1784380800\"],[\"end\",\"1784387900\"],[\"D\",\"20652\"],[\"start_tzid\",\"America/Vancouver\"],[\"location\",\"Saanich Peninsula\"]],\"Walk the fields and meet the growers.\"]", + "event_id": "22d1538c18614f9c089cbccd9de91dab5dcd55d7a41bce07a7282f49ce757433", + "signature": "ddac1ac03aed8b888bde2fae0915e07cab85a1f1bcd2f1ce2812befb0456f320a03bea94e1469f62aa1ecaceb241bada4449c3707f34d42cb378299c6a4793f5", + "raw_json": "{\"id\":\"22d1538c18614f9c089cbccd9de91dab5dcd55d7a41bce07a7282f49ce757433\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":31923,\"tags\":[[\"d\",\"farm-tour\"],[\"title\",\"Farm Tour\"],[\"start\",\"1784380800\"],[\"end\",\"1784387900\"],[\"D\",\"20652\"],[\"start_tzid\",\"America/Vancouver\"],[\"location\",\"Saanich Peninsula\"]],\"content\":\"Walk the fields and meet the growers.\",\"sig\":\"ddac1ac03aed8b888bde2fae0915e07cab85a1f1bcd2f1ce2812befb0456f320a03bea94e1469f62aa1ecaceb241bada4449c3707f34d42cb378299c6a4793f5\"}" + } + }, + { "id": "generic_operational_listing_009", "kind": "authored_operations.wire", "input": { diff --git a/contracts/event_boundary_matrix.md b/contracts/event_boundary_matrix.md @@ -295,8 +295,8 @@ not weaken or add effect fields to the request corpus. | 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 | AppData | 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 | AuthoredCalendarDateEvent / ParsedNip52CalendarDateEvent / AdmittedCalendarDateEvent | 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 | AuthoredCalendarTimeEvent / ParsedNip52CalendarTimeEvent / AdmittedCalendarTimeEvent | 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_date | 31922 | AuthoredCalendarDateEvent / ParsedNip52CalendarDateEvent / AdmittedCalendarDateEvent | events.calendar_date.publish, events.calendar_date.list, events.calendar_date.get | registry-v7 typed-only NIP-52 date authoring through `CalendarEventBuilder`; baseline retains uppercase-`D` extensions, strict admission rejects them | +| calendar_time | 31923 | AuthoredCalendarTimeEvent / ParsedNip52CalendarTimeEvent / AdmittedCalendarTimeEvent | events.calendar_time.publish, events.calendar_time.list, events.calendar_time.get | registry-v7 typed-only NIP-52 time authoring through `CalendarEventBuilder`; baseline applies required day anchoring, strict admission requires exact bounded UTC-day coverage | | calendar | 31924 | AuthoredCalendar / ParsedNip52Calendar / AdmittedCalendar | events.calendar.publish, events.calendar.list, events.calendar.get | NIP-52 calendar collection with separate authored, baseline parse, and strict Radroots admission boundaries | | calendar_rsvp | 31925 | AuthoredCalendarEventRsvp / ParsedNip52CalendarEventRsvp / AdmittedCalendarEventRsvp | events.calendar_rsvp.publish, events.calendar_rsvp.list, events.calendar_rsvp.get | NIP-52 calendar RSVP with separate authored, baseline parse, and strict Radroots admission boundaries | | farm | 30340 | Farm | events.farm.publish, events.farm.list, events.farm.get | addressable; canonical JSON; `g` tag only when a geohash exists | diff --git a/contracts/event_store/event_contract_registry_v7.inventory.json b/contracts/event_store/event_contract_registry_v7.inventory.json @@ -3998,13 +3998,13 @@ "contract_id": "radroots.calendar.date_event.v1", "kind": 31922, "name": "Calendar Date Event", - "payload_type": "RadrootsAdmittedCalendarDateEvent", + "payload_type": "RadrootsAuthoredCalendarDateEvent / RadrootsParsedNip52CalendarDateEvent / RadrootsAdmittedCalendarDateEvent", "class": "addressable", "stability": "stable", "privacy": "public", "author_role": "any", "content_schema": "plain_text", - "authoring_policy": "generic_draft", + "authoring_policy": "typed_only", "discriminator": { "type": "kind_only" }, @@ -4110,13 +4110,13 @@ "contract_id": "radroots.calendar.time_event.v1", "kind": 31923, "name": "Calendar Time Event", - "payload_type": "RadrootsAdmittedCalendarTimeEvent", + "payload_type": "RadrootsAuthoredCalendarTimeEvent / RadrootsParsedNip52CalendarTimeEvent / RadrootsAdmittedCalendarTimeEvent", "class": "addressable", "stability": "stable", "privacy": "public", "author_role": "any", "content_schema": "plain_text", - "authoring_policy": "generic_draft", + "authoring_policy": "typed_only", "discriminator": { "type": "kind_only" }, diff --git a/contracts/event_store/event_contract_registry_v7.inventory.sha256 b/contracts/event_store/event_contract_registry_v7.inventory.sha256 @@ -1 +1 @@ -91595544310f865bdef064ee760c227c870417a95b87b3e27278f8da74fdddea +ea434c925d773fdf7848a1795d1699701f178132439b28a275f8ffca4d42a52e diff --git a/contracts/events/social-events.md b/contracts/events/social-events.md @@ -171,7 +171,8 @@ by the verified projection/admission boundary. The event-contract registry assigns an explicit authoring policy to every contract. Strict Profile, Update, PhotoUpdate, Ask, NIP-10 Reply, NIP-22 -Comment, and NIP-09 Deletion Request contracts are `TypedOnly`; +Comment, NIP-09 Deletion Request, NIP-52 date Event, NIP-52 time Event, and +FoodAvailability contracts are `TypedOnly`; `radroots.social.post.v1` is `ReadOnly`; ordinary generic-draft contracts remain `GenericDraft`. `GenericEventDraft::new` therefore rejects the strict Profile contract and @@ -181,7 +182,8 @@ and are accepted only after deserialization revalidates the registry version, contract, kind, shape, policy, recomputed event id, and known fields. The authored-plan signing boundary repeats that validation, so stale version-`1` through version-`6` drafts must be rebuilt. Typed root posts, Replies, -Comments, and Deletion Requests enter Nostr signing only through opaque typed +Comments, Deletion Requests, date/time Events, and FoodAvailability values +enter Nostr signing only through opaque typed builders that expose timestamp selection and signing, but no raw tag/content mutation or public conversion to the upstream builder. Publication remains a transport concern operating on signed events. The opaque generic builder @@ -820,6 +822,14 @@ index covered by the interval, where `D = floor(unix_seconds / 86400)` and `end` Authoring derives this sequence rather than accepting it from callers. Strict authored and admitted time events cover at most 366 UTC days. +Registry-v7 marks both Event contracts `TypedOnly`. Their authored models +convert through the same immutable authored-plan boundary as every other +strict product profile, and sealed Nostr builders allow local or opaque-host +signing without exposing kind or tag mutation. Historical plan decoding +re-runs tolerant parsing, strict admission, and exact authored tag-order +validation; deprecated `name`, reordered tags, incomplete day coverage, and +other inbound-tolerated spellings cannot become strict outbound plans. + Strict kind-`31924` and kind-`31925` identifiers are syntax-valid 128-bit values encoded as exactly 22 unpadded base64url characters. This shape does not prove uniqueness; an authoring runtime must generate a fresh identifier for every collection or RSVP identity. Strict collection authoring and diff --git a/crates/event/src/contract/registry_v7.rs b/crates/event/src/contract/registry_v7.rs @@ -3191,28 +3191,30 @@ static EVENT_CONTRACTS_REGISTRY_V7: &[EventContract] = &[ WIKI_REDIRECT_TAGS, KNOWLEDGE_REDUCERS ), - event_contract!( + event_contract_with_authoring_policy!( "radroots.calendar.date_event.v1", KIND_CALENDAR_DATE_EVENT, "Calendar Date Event", - "RadrootsAdmittedCalendarDateEvent", + "RadrootsAuthoredCalendarDateEvent / RadrootsParsedNip52CalendarDateEvent / RadrootsAdmittedCalendarDateEvent", EventClass::Addressable, EventPrivacy::Public, AuthorRole::Any, ContentSchema::PlainText, + EventAuthoringPolicy::TypedOnly, EventDiscriminator::KindOnly, CALENDAR_DATE_EVENT_TAGS, CALENDAR_REDUCERS ), - event_contract!( + event_contract_with_authoring_policy!( "radroots.calendar.time_event.v1", KIND_CALENDAR_TIME_EVENT, "Calendar Time Event", - "RadrootsAdmittedCalendarTimeEvent", + "RadrootsAuthoredCalendarTimeEvent / RadrootsParsedNip52CalendarTimeEvent / RadrootsAdmittedCalendarTimeEvent", EventClass::Addressable, EventPrivacy::Public, AuthorRole::Any, ContentSchema::PlainText, + EventAuthoringPolicy::TypedOnly, EventDiscriminator::KindOnly, CALENDAR_TIME_EVENT_TAGS, CALENDAR_REDUCERS diff --git a/crates/event/src/contract/registry_v7/tests.rs b/crates/event/src/contract/registry_v7/tests.rs @@ -259,8 +259,12 @@ fn every_event_contract_is_listed_by_its_kind_contract() { #[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.payload_type, + "RadrootsAuthoredCalendarDateEvent / RadrootsParsedNip52CalendarDateEvent / RadrootsAdmittedCalendarDateEvent" + ); assert_eq!(date.content_schema, ContentSchema::PlainText); + assert_eq!(date.authoring_policy(), EventAuthoringPolicy::TypedOnly); assert!( date.tags .iter() @@ -279,8 +283,12 @@ fn calendar_contracts_expose_current_nip52_types_content_and_tags() { 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.payload_type, + "RadrootsAuthoredCalendarTimeEvent / RadrootsParsedNip52CalendarTimeEvent / RadrootsAdmittedCalendarTimeEvent" + ); assert_eq!(time.content_schema, ContentSchema::PlainText); + assert_eq!(time.authoring_policy(), EventAuthoringPolicy::TypedOnly); assert!(time.tags.iter().any(|tag| { tag.name == "D" && tag.cardinality == TagCardinality::RequiredMany diff --git a/crates/event_codec/src/authoring/typed.rs b/crates/event_codec/src/authoring/typed.rs @@ -13,6 +13,7 @@ use core::fmt; #[cfg(feature = "json")] use radroots_event::profile::AuthoredProfile; use radroots_event::{ + calendar::{AuthoredCalendarDateEvent, AuthoredCalendarTimeEvent}, contract::{ContractIdentityError, ContractKey, EventAuthoringPolicy}, envelope::{EventEnvelopeError, EventTags}, food::availability::FoodAvailabilityDetails, @@ -32,6 +33,7 @@ use crate::profile::authored::{ RadrootsAuthoredProfileEncodeError, authored_profile_to_wire_parts, }; use crate::{ + calendar::encode::{date_to_wire_parts, time_to_wire_parts}, comment::authored::authored_nip22_comment_to_wire_parts, deletion::authored::authored_nip09_deletion_request_to_wire_parts, food_availability::authored::{ @@ -56,7 +58,7 @@ use crate::{ use super::{AuthoredEventBody, AuthoredEventPlan}; -pub const REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS: [&str; 8] = [ +pub const REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS: [&str; 10] = [ "radroots.profile.metadata.v1", "radroots.social.update.v1", "radroots.social.photo_update.v1", @@ -64,6 +66,8 @@ pub const REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS: [&str; 8] = [ "radroots.social.reply.v1", "radroots.social.deletion_request.v1", "radroots.social.comment.v1", + "radroots.calendar.date_event.v1", + "radroots.calendar.time_event.v1", "radroots.food.availability.v1", ]; @@ -88,6 +92,7 @@ pub enum AuthoredPlanError { #[cfg(feature = "json")] Profile(RadrootsAuthoredProfileEncodeError), FoodAvailability(RadrootsFoodAvailabilityEncodeError), + Calendar(crate::encode::EventEncodeError), } impl AuthoredPlanError { @@ -104,6 +109,7 @@ impl AuthoredPlanError { #[cfg(feature = "json")] Self::Profile(error) => error.code(), Self::FoodAvailability(error) => error.code(), + Self::Calendar(error) => error.code(), } } } @@ -134,6 +140,7 @@ impl fmt::Display for AuthoredPlanError { #[cfg(feature = "json")] Self::Profile(error) => write!(formatter, "{error}"), Self::FoodAvailability(error) => write!(formatter, "{error}"), + Self::Calendar(error) => write!(formatter, "{error}"), } } } @@ -197,6 +204,20 @@ impl AuthoredEventBody { .map_err(AuthoredPlanError::FoodAvailability)?; build_typed_body("radroots.food.availability.v1", wire) } + + pub fn from_calendar_date_event( + event: &AuthoredCalendarDateEvent, + ) -> Result<Self, AuthoredPlanError> { + let wire = date_to_wire_parts(event).map_err(AuthoredPlanError::Calendar)?; + build_typed_body("radroots.calendar.date_event.v1", wire) + } + + pub fn from_calendar_time_event( + event: &AuthoredCalendarTimeEvent, + ) -> Result<Self, AuthoredPlanError> { + let wire = time_to_wire_parts(event).map_err(AuthoredPlanError::Calendar)?; + build_typed_body("radroots.calendar.time_event.v1", wire) + } } impl AuthoredEventPlan { @@ -319,6 +340,30 @@ impl AuthoredEventPlan { expected_author, ) } + + pub fn from_calendar_date_event( + event: &AuthoredCalendarDateEvent, + created_at: u64, + expected_author: impl AsRef<str>, + ) -> Result<Self, AuthoredPlanError> { + Self::bind( + AuthoredEventBody::from_calendar_date_event(event)?, + created_at, + expected_author, + ) + } + + pub fn from_calendar_time_event( + event: &AuthoredCalendarTimeEvent, + created_at: u64, + expected_author: impl AsRef<str>, + ) -> Result<Self, AuthoredPlanError> { + Self::bind( + AuthoredEventBody::from_calendar_time_event(event)?, + created_at, + expected_author, + ) + } } fn build_typed_body( @@ -376,6 +421,8 @@ pub(super) fn validate_historical_typed_profile( "radroots.social.deletion_request.v1" => validate_deletion(created_at, kind, tags, content), "radroots.social.comment.v1" => validate_comment(created_at, kind, tags, content), "radroots.food.availability.v1" => validate_food(created_at, kind, tags, content), + "radroots.calendar.date_event.v1" => validate_calendar_date(kind, tags, content), + "radroots.calendar.time_event.v1" => validate_calendar_time(kind, tags, content), _ => Err("historical_typed_profile_unavailable".to_string()), } } @@ -757,6 +804,132 @@ fn validate_food( } #[cfg(feature = "json")] +fn validate_calendar_date(kind: u32, tags: &[Vec<String>], content: &str) -> Result<(), String> { + let parsed = crate::calendar::decode::parse_nip52_calendar_date_event(kind, tags, content) + .map_err(|error| error.to_string())?; + crate::calendar::decode::admit_radroots_calendar_date_event(parsed.clone()) + .map_err(|error| error.code().to_string())?; + if canonical_calendar_date_tags(&parsed) != tags { + return Err("calendar_date_tags_noncanonical".to_string()); + } + Ok(()) +} + +#[cfg(feature = "json")] +fn validate_calendar_time(kind: u32, tags: &[Vec<String>], content: &str) -> Result<(), String> { + let parsed = crate::calendar::decode::parse_nip52_calendar_time_event(kind, tags, content) + .map_err(|error| error.to_string())?; + crate::calendar::decode::admit_radroots_calendar_time_event(parsed.clone()) + .map_err(|error| error.code().to_string())?; + if canonical_calendar_time_tags(&parsed) != tags { + return Err("calendar_time_tags_noncanonical".to_string()); + } + Ok(()) +} + +#[cfg(feature = "json")] +fn canonical_calendar_date_tags( + event: &radroots_event::calendar::ParsedNip52CalendarDateEvent, +) -> Vec<Vec<String>> { + let common = event.common(); + let mut tags = vec![ + tag("d", common.d_tag()), + tag("title", common.title()), + tag("start", event.start().as_str()), + ]; + if let Some(end) = event.end() { + tags.push(tag("end", end.as_str())); + } + push_canonical_calendar_common_tags(&mut tags, common); + tags +} + +#[cfg(feature = "json")] +fn canonical_calendar_time_tags( + event: &radroots_event::calendar::ParsedNip52CalendarTimeEvent, +) -> Vec<Vec<String>> { + let common = event.common(); + let mut tags = vec![ + tag("d", common.d_tag()), + tag("title", common.title()), + tag("start", &event.start().to_string()), + ]; + if let Some(end) = event.end() { + tags.push(tag("end", &end.to_string())); + } + tags.extend( + event + .observed_day_indices() + .iter() + .map(|day| tag("D", &day.index().to_string())), + ); + if let Some(tzid) = event.start_tzid() { + tags.push(tag("start_tzid", tzid.as_str())); + } + if let Some(tzid) = event.end_tzid() { + tags.push(tag("end_tzid", tzid.as_str())); + } + push_canonical_calendar_common_tags(&mut tags, common); + tags +} + +#[cfg(feature = "json")] +fn push_canonical_calendar_common_tags( + tags: &mut Vec<Vec<String>>, + common: &radroots_event::calendar::ParsedNip52CalendarCommon, +) { + tags.extend( + common + .locations() + .iter() + .map(|location| tag("location", location)), + ); + if let Some(geohash) = common.geohash() { + tags.push(tag("g", geohash)); + } + if let Some(summary) = common.summary() { + tags.push(tag("summary", summary)); + } + if let Some(image) = common.image() { + tags.push(tag("image", image.as_str())); + } + for participant in common.participants() { + let mut participant_tag = vec!["p".to_string(), participant.pubkey.clone()]; + if let Some(role) = participant.role.as_ref() { + participant_tag.push(participant.relay.clone().unwrap_or_default()); + participant_tag.push(role.clone()); + } else if let Some(relay) = participant.relay.as_ref() { + participant_tag.push(relay.clone()); + } + tags.push(participant_tag); + } + tags.extend( + common + .categories() + .iter() + .map(|category| tag("t", category)), + ); + tags.extend( + common + .references() + .iter() + .map(|reference| tag("r", reference.as_str())), + ); + for request in common.calendar_requests() { + let mut request_tag = tag("a", request.calendar().as_str()); + if let Some(relay) = request.relay() { + request_tag.push(relay.to_string()); + } + tags.push(request_tag); + } +} + +#[cfg(feature = "json")] +fn tag(key: &str, value: &str) -> Vec<String> { + vec![key.to_string(), value.to_string()] +} + +#[cfg(feature = "json")] fn decimal_digits(mut value: u64) -> usize { let mut digits = 1usize; while value >= 10 { diff --git a/crates/event_codec/src/error.rs b/crates/event_codec/src/error.rs @@ -57,7 +57,7 @@ impl From<radroots_event::envelope::EventEnvelopeError> for EventParseError { } } -#[derive(Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum EventEncodeError { InvalidKind(u32), EmptyRequiredField(&'static str), diff --git a/crates/event_codec/tests/authored_typed_plans.rs b/crates/event_codec/tests/authored_typed_plans.rs @@ -8,6 +8,7 @@ use std::{ use radroots_blossom::{BlobDescriptor, BlobUrl, MediaType, Sha256}; use radroots_event::{ + calendar::{AuthoredCalendarDateEvent, AuthoredCalendarTimeEvent, CalendarDate}, contract::{EventAuthoringPolicy, all_event_contracts}, envelope::kind::KIND_CLASSIFIED_LISTING, food::availability::{ @@ -26,9 +27,10 @@ use radroots_event::{ profile::AuthoredProfile, }; use radroots_event_codec::authoring::{ - AuthoredEventPlan, PlanWireV1, REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS, + AuthoredEventPlan, PlanDecodeError, PlanWireV1, REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS, }; use serde::Deserialize; +use serde_json::Value; const AUTHOR: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; const OTHER_AUTHOR: &str = "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; @@ -133,6 +135,51 @@ fn every_typed_plan_is_byte_identical_to_the_frozen_authored_wire_corpus() { } } +#[test] +fn calendar_plan_history_rejects_tolerated_or_noncanonical_inbound_shapes() { + let plans = typed_plans(); + + let date = plans + .get("radroots.calendar.date_event.v1") + .expect("date plan"); + let reordered = mutate_plan_wire(date, |value| { + value["tags"].as_array_mut().expect("date tags").swap(0, 1); + }); + assert!(matches!( + PlanWireV1::from_json(&reordered), + Err(PlanDecodeError::HistoricalShape(_)) + )); + let deprecated_name = mutate_plan_wire(date, |value| { + value["tags"][1][0] = Value::String("name".to_owned()); + }); + assert!(matches!( + PlanWireV1::from_json(&deprecated_name), + Err(PlanDecodeError::HistoricalShape(_)) + )); + + let time = plans + .get("radroots.calendar.time_event.v1") + .expect("time plan"); + let incomplete_day_coverage = mutate_plan_wire(time, |value| { + value["tags"] + .as_array_mut() + .expect("time tags") + .retain(|tag| tag[0] != "D"); + }); + assert!(matches!( + PlanWireV1::from_json(&incomplete_day_coverage), + Err(PlanDecodeError::HistoricalShape(_)) + )); +} + +fn mutate_plan_wire(plan: &AuthoredEventPlan, mutator: impl FnOnce(&mut Value)) -> Vec<u8> { + let mut value = + serde_json::from_slice::<Value>(&PlanWireV1::from_plan(plan).to_json().expect("plan wire")) + .expect("plan wire value"); + mutator(&mut value); + serde_json::to_vec(&value).expect("mutated plan wire") +} + fn typed_plans() -> BTreeMap<&'static str, AuthoredEventPlan> { let mut plans = BTreeMap::new(); let profile = AuthoredProfile::new("Alice \"Sprout\"") @@ -209,6 +256,42 @@ fn typed_plans() -> BTreeMap<&'static str, AuthoredEventPlan> { AuthoredEventPlan::from_nip22_comment(&comment, CREATED_AT, AUTHOR).expect("comment plan"), ); + let date = AuthoredCalendarDateEvent::new( + "market-day", + "Saturday Market", + CalendarDate::parse("2026-08-08").expect("calendar date"), + ) + .expect("date event") + .with_end(CalendarDate::parse("2026-08-09").expect("calendar end")) + .expect("date end") + .with_description("Farmers market and seed swap.") + .expect("date description") + .with_locations(vec!["Central Saanich, BC".to_owned()]) + .expect("date location") + .with_summary("Local food and seeds") + .expect("date summary"); + plans.insert( + "radroots.calendar.date_event.v1", + AuthoredEventPlan::from_calendar_date_event(&date, CREATED_AT, AUTHOR) + .expect("calendar date plan"), + ); + + let time = AuthoredCalendarTimeEvent::new("farm-tour", "Farm Tour", 1_784_380_800) + .expect("time event") + .with_end(1_784_387_900) + .expect("time end") + .with_description("Walk the fields and meet the growers.") + .expect("time description") + .with_start_tzid("America/Vancouver") + .expect("time zone") + .with_locations(vec!["Saanich Peninsula".to_owned()]) + .expect("time location"); + plans.insert( + "radroots.calendar.time_event.v1", + AuthoredEventPlan::from_calendar_time_event(&time, CREATED_AT, AUTHOR) + .expect("calendar time plan"), + ); + plans.insert( "radroots.food.availability.v1", AuthoredEventPlan::from_food_availability(&food_details(), CREATED_AT, AUTHOR) diff --git a/crates/mobile_core/Cargo.toml b/crates/mobile_core/Cargo.toml @@ -39,4 +39,8 @@ thiserror = { workspace = true } [dev-dependencies] nostr = { workspace = true, features = ["std"] } +radroots_blossom = { workspace = true, default-features = false, features = [ + "serde", + "std", +] } tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates/mobile_core/src/runtime/product_surface.rs b/crates/mobile_core/src/runtime/product_surface.rs @@ -4,6 +4,7 @@ //! and admission remain in lower event crates; persistence and live query //! composition remain in the runtime slices that consume these types. +mod authoring; mod context; mod cursor; mod identity; @@ -11,6 +12,10 @@ mod model; mod projection; mod ranking; +pub use authoring::{ + CreateAsk, CreateEvent, CreateFoodAvailability, CreatePhotoUpdate, CreateUpdate, + Phase1AddCommand, Phase1ReplacementPolicy, phase1_retraction_plan, +}; pub use context::{ ContextAdmission, ContextRank, LocalNetwork, LocalNetworkAdmission, LocalNetworkError, LocalityEvidence, diff --git a/crates/mobile_core/src/runtime/product_surface/authoring.rs b/crates/mobile_core/src/runtime/product_surface/authoring.rs @@ -0,0 +1,310 @@ +use radroots_event::{ + calendar::{AuthoredCalendarDateEvent, AuthoredCalendarTimeEvent}, + food::availability::FoodAvailabilityDetails, + post::{ + AuthoredAsk, AuthoredPhotoUpdate, AuthoredPostError, AuthoredPostImage, AuthoredUpdate, + deletion::AuthoredNip09DeletionRequest, + }, +}; +use radroots_event_codec::authoring::{AuthoredEventPlan, AuthoredPlanError}; + +use super::AddCommandType; + +/// A strict `CreateUpdate` command. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreateUpdate(AuthoredUpdate); + +impl CreateUpdate { + pub fn new(content: impl Into<String>) -> Result<Self, AuthoredPostError> { + AuthoredUpdate::new(content).map(Self) + } + + pub const fn authored(&self) -> &AuthoredUpdate { + &self.0 + } +} + +/// A strict `CreatePhotoUpdate` command. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreatePhotoUpdate(AuthoredPhotoUpdate); + +impl CreatePhotoUpdate { + pub fn new( + content: impl Into<String>, + images: Vec<AuthoredPostImage>, + ) -> Result<Self, AuthoredPostError> { + AuthoredPhotoUpdate::new(content, images).map(Self) + } + + pub const fn authored(&self) -> &AuthoredPhotoUpdate { + &self.0 + } +} + +/// A strict `CreateAsk` command. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreateAsk(AuthoredAsk); + +impl CreateAsk { + pub fn new( + question: impl Into<String>, + images: Vec<AuthoredPostImage>, + ) -> Result<Self, AuthoredPostError> { + AuthoredAsk::new(question, images).map(Self) + } + + pub const fn authored(&self) -> &AuthoredAsk { + &self.0 + } +} + +/// A strict `CreateEvent` command with an explicit all-day or timed profile. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreateEvent(Box<CreateEventProfile>); + +#[derive(Clone, Debug, PartialEq, Eq)] +enum CreateEventProfile { + Date(AuthoredCalendarDateEvent), + Time(AuthoredCalendarTimeEvent), +} + +impl CreateEvent { + pub fn date(event: AuthoredCalendarDateEvent) -> Self { + Self(Box::new(CreateEventProfile::Date(event))) + } + + pub fn time(event: AuthoredCalendarTimeEvent) -> Self { + Self(Box::new(CreateEventProfile::Time(event))) + } +} + +/// A strict `CreateFoodAvailability` command. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CreateFoodAvailability(FoodAvailabilityDetails); + +impl CreateFoodAvailability { + pub const fn new(details: FoodAvailabilityDetails) -> Self { + Self(details) + } + + pub const fn authored(&self) -> &FoodAvailabilityDetails { + &self.0 + } +} + +/// The only five Phase 1 Add commands accepted by focused mobile authoring. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Phase1AddCommand { + CreateUpdate(CreateUpdate), + CreatePhotoUpdate(CreatePhotoUpdate), + CreateAsk(CreateAsk), + CreateEvent(CreateEvent), + CreateFoodAvailability(CreateFoodAvailability), +} + +/// Standard revision behavior for a Phase 1 authored profile. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Phase1ReplacementPolicy { + /// Regular kind-1 events have no edit convention; retract and create are + /// independent authored operations whose partial effects remain visible. + RetractThenCreate, + /// Addressable events replace the current head by reusing their stable `d`. + AddressableReplacement, +} + +impl Phase1AddCommand { + pub const fn command_type(&self) -> AddCommandType { + match self { + Self::CreateUpdate(_) => AddCommandType::CreateUpdate, + Self::CreatePhotoUpdate(_) => AddCommandType::CreatePhotoUpdate, + Self::CreateAsk(_) => AddCommandType::CreateAsk, + Self::CreateEvent(_) => AddCommandType::CreateEvent, + Self::CreateFoodAvailability(_) => AddCommandType::CreateFoodAvailability, + } + } + + pub const fn replacement_policy(&self) -> Phase1ReplacementPolicy { + match self { + Self::CreateUpdate(_) | Self::CreatePhotoUpdate(_) | Self::CreateAsk(_) => { + Phase1ReplacementPolicy::RetractThenCreate + } + Self::CreateEvent(_) | Self::CreateFoodAvailability(_) => { + Phase1ReplacementPolicy::AddressableReplacement + } + } + } + + /// Binds the validated command to one exact timestamp and expected author. + pub fn authored_plan( + &self, + created_at: u64, + expected_author: impl AsRef<str>, + ) -> Result<AuthoredEventPlan, AuthoredPlanError> { + let expected_author = expected_author.as_ref(); + match self { + Self::CreateUpdate(command) => { + AuthoredEventPlan::from_update(command.authored(), created_at, expected_author) + } + Self::CreatePhotoUpdate(command) => AuthoredEventPlan::from_photo_update( + command.authored(), + created_at, + expected_author, + ), + Self::CreateAsk(command) => { + AuthoredEventPlan::from_ask(command.authored(), created_at, expected_author) + } + Self::CreateEvent(command) => match command.0.as_ref() { + CreateEventProfile::Date(event) => { + AuthoredEventPlan::from_calendar_date_event(event, created_at, expected_author) + } + CreateEventProfile::Time(event) => { + AuthoredEventPlan::from_calendar_time_event(event, created_at, expected_author) + } + }, + Self::CreateFoodAvailability(command) => AuthoredEventPlan::from_food_availability( + command.authored(), + created_at, + expected_author, + ), + } + } +} + +/// Builds the independent strict NIP-09 plan used for retraction or withdrawal. +/// +/// This function intentionally does not combine retraction and replacement +/// into one purportedly atomic operation. +pub fn phase1_retraction_plan( + request: &AuthoredNip09DeletionRequest, + created_at: u64, + expected_author: impl AsRef<str>, +) -> Result<AuthoredEventPlan, AuthoredPlanError> { + AuthoredEventPlan::from_nip09_deletion_request(request, created_at, expected_author) +} + +#[cfg(test)] +mod tests { + use super::*; + use radroots_blossom::{BlobDescriptor, BlobUrl, MediaType, Sha256}; + use radroots_event::{ + calendar::CalendarDate, + envelope::kind::{ + KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_TIME_EVENT, KIND_CLASSIFIED_LISTING, KIND_POST, + }, + food::availability::{ + FoodAvailabilityDetailsParts, FoodAvailabilityStatus, FoodContent, FoodCurrency, + FoodIdentifier, FoodPrice, FoodPublishedAt, FoodText, FoodUnit, + }, + media::AuthoredImage, + post::PostImageDimensions, + post::deletion::{AuthoredNip09DeletionRequest, Nip09DeletionEventTarget}, + }; + + const AUTHOR: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + #[test] + fn focused_commands_bind_only_locked_wire_profiles() { + let date = AuthoredCalendarDateEvent::new( + "market-day", + "Saturday Market", + CalendarDate::parse("2026-08-08").unwrap(), + ) + .unwrap(); + let time = AuthoredCalendarTimeEvent::new("farm-tour", "Farm Tour", 1_784_380_800).unwrap(); + let image = post_image(); + let commands = [ + Phase1AddCommand::CreateUpdate(CreateUpdate::new("Harvest update").unwrap()), + Phase1AddCommand::CreatePhotoUpdate( + CreatePhotoUpdate::new(format!("Harvest photo {}", image.url()), vec![image]) + .unwrap(), + ), + Phase1AddCommand::CreateAsk(CreateAsk::new("Who has basil?", Vec::new()).unwrap()), + Phase1AddCommand::CreateEvent(CreateEvent::date(date)), + Phase1AddCommand::CreateEvent(CreateEvent::time(time)), + Phase1AddCommand::CreateFoodAvailability(CreateFoodAvailability::new(food())), + ]; + let expected = [ + (AddCommandType::CreateUpdate, KIND_POST), + (AddCommandType::CreatePhotoUpdate, KIND_POST), + (AddCommandType::CreateAsk, KIND_POST), + (AddCommandType::CreateEvent, KIND_CALENDAR_DATE_EVENT), + (AddCommandType::CreateEvent, KIND_CALENDAR_TIME_EVENT), + ( + AddCommandType::CreateFoodAvailability, + KIND_CLASSIFIED_LISTING, + ), + ]; + for (command, (command_type, kind)) in commands.iter().zip(expected) { + let plan = command.authored_plan(1_784_347_200, AUTHOR).unwrap(); + assert_eq!(command.command_type(), command_type); + assert_eq!(plan.body().kind(), kind); + assert_ne!(plan.body().kind(), 20); + } + } + + #[test] + fn revision_and_retraction_semantics_are_explicit() { + let update = Phase1AddCommand::CreateUpdate(CreateUpdate::new("new post").unwrap()); + assert_eq!( + update.replacement_policy(), + Phase1ReplacementPolicy::RetractThenCreate + ); + let event = Phase1AddCommand::CreateEvent(CreateEvent::time( + AuthoredCalendarTimeEvent::new("farm-tour", "Farm Tour", 1_784_380_800).unwrap(), + )); + assert_eq!( + event.replacement_policy(), + Phase1ReplacementPolicy::AddressableReplacement + ); + + let request = AuthoredNip09DeletionRequest::new( + "retracted", + vec![Nip09DeletionEventTarget::parse("b".repeat(64), KIND_POST).unwrap()], + Vec::new(), + ) + .unwrap(); + let plan = phase1_retraction_plan(&request, 1_784_347_201, AUTHOR).unwrap(); + assert_eq!(plan.body().kind(), 5); + } + + fn food() -> FoodAvailabilityDetails { + FoodAvailabilityDetails::new(FoodAvailabilityDetailsParts { + content: FoodContent::new("Carrots available this week.").unwrap(), + identifier: FoodIdentifier::parse("nantes-carrots").unwrap(), + title: FoodText::new("Nantes Carrots").unwrap(), + summary: FoodText::new("Fresh bunches").unwrap(), + published_at: FoodPublishedAt::new(1_784_347_100).unwrap(), + location: FoodText::new("Central Saanich, BC").unwrap(), + price: FoodPrice::new("3", FoodCurrency::parse("CAD").unwrap(), FoodUnit::Pound) + .unwrap(), + quantity: None, + status: FoodAvailabilityStatus::Active, + images: Vec::new(), + }) + .unwrap() + } + + fn post_image() -> AuthoredPostImage { + let bytes = b"harvest-photo"; + let hash = Sha256::digest(bytes); + let media_type = MediaType::parse("image/webp").unwrap(); + let descriptor = BlobDescriptor::new( + BlobUrl::parse(&format!("https://media.example/{hash}.webp")).unwrap(), + hash, + bytes.len() as u64, + media_type.clone(), + 1_784_347_100, + ) + .unwrap() + .approve_reference() + .unwrap() + .verify_bytes(bytes, &media_type) + .unwrap(); + AuthoredPostImage::new( + AuthoredImage::try_from(descriptor).unwrap(), + PostImageDimensions::new(1200, 900).unwrap(), + "Harvest", + ) + .unwrap() + } +} diff --git a/crates/nostr/README.md b/crates/nostr/README.md @@ -62,8 +62,8 @@ projection belong to `radroots_event_codec`. This crate performs the explicit translation to or from Nostr protocol values. With `events`, typed builders cover the supported Profile, Update, -PhotoUpdate, Ask, Reply, Comment, deletion-request, and FoodAvailability -authoring profiles. Sealed focused builders fix the event kind and canonical +PhotoUpdate, Ask, Reply, Comment, deletion-request, NIP-52 date/time Event, and +FoodAvailability authoring profiles. Sealed focused builders fix the event kind and canonical tag model before signer access. Generic builders reject reserved typed kinds; relaying an already signed event is a transport operation and does not grant a typed Radroots authoring claim. diff --git a/crates/nostr/src/event.rs b/crates/nostr/src/event.rs @@ -26,6 +26,10 @@ pub use crate::events::{ ApplicationHandlerSpec, build_application_handler_event as build_application_handler, metadata_has_fields, }, + calendar::{ + CalendarEventBuilder, build_calendar_date_event as build_calendar_date, + build_calendar_time_event as build_calendar_time, + }, comment::{Nip22CommentBuilder, build_nip22_comment_event as build_nip22_comment}, deletion::{ Nip09DeletionRequestBuilder, diff --git a/crates/nostr/src/events/calendar.rs b/crates/nostr/src/events/calendar.rs @@ -0,0 +1,60 @@ +//! Sealed authoring for strict NIP-52 Event profiles. + +use crate::{ + error::Error, + events::sealed::SealedBuilderCore, + types::{ + ExternalSigningRequest, RadrootsNostrEvent, RadrootsNostrKeys, RadrootsNostrPublicKey, + RadrootsNostrTimestamp, + }, +}; +use radroots_event::calendar::{AuthoredCalendarDateEvent, AuthoredCalendarTimeEvent}; +use radroots_event_codec::authoring::AuthoredEventBody; + +/// A sealed builder for one validated NIP-52 date or time Event. +/// +/// Callers cannot change the event kind, coordinate, content, or tags after +/// the typed calendar model has been converted into the authored body. +#[must_use = "calendar event builders must be signed or published"] +pub struct CalendarEventBuilder { + inner: SealedBuilderCore, +} + +impl CalendarEventBuilder { + /// Sets the event timestamp without changing the validated NIP-52 profile. + pub fn custom_created_at(mut self, created_at: RadrootsNostrTimestamp) -> Self { + self.inner = self.inner.custom_created_at(created_at); + self + } + + /// Signs the exact authored Event directly with local keys. + pub fn sign_with_keys(self, keys: &RadrootsNostrKeys) -> Result<RadrootsNostrEvent, Error> { + self.inner.sign_with_keys(keys) + } + + /// Finalizes the exact authored plan for an opaque host signer. + pub fn into_external_signing_request( + self, + public_key: RadrootsNostrPublicKey, + ) -> Result<ExternalSigningRequest, Error> { + self.inner.into_external_signing_request(public_key) + } +} + +/// Builds a sealed kind-31922 all-day Event. +pub fn build_calendar_date_event( + event: &AuthoredCalendarDateEvent, +) -> Result<CalendarEventBuilder, Error> { + Ok(CalendarEventBuilder { + inner: SealedBuilderCore::new(AuthoredEventBody::from_calendar_date_event(event)?), + }) +} + +/// Builds a sealed kind-31923 timed Event. +pub fn build_calendar_time_event( + event: &AuthoredCalendarTimeEvent, +) -> Result<CalendarEventBuilder, Error> { + Ok(CalendarEventBuilder { + inner: SealedBuilderCore::new(AuthoredEventBody::from_calendar_time_event(event)?), + }) +} diff --git a/crates/nostr/src/events/mod.rs b/crates/nostr/src/events/mod.rs @@ -7,6 +7,8 @@ #[cfg(feature = "events")] pub mod application_handler; #[cfg(feature = "events")] +pub mod calendar; +#[cfg(feature = "events")] pub mod comment; #[cfg(feature = "events")] pub mod deletion; diff --git a/crates/nostr/tests/authored_wire_corpus.rs b/crates/nostr/tests/authored_wire_corpus.rs @@ -9,6 +9,7 @@ use nostr::{ use radroots_blossom::{BlobDescriptor, BlobUrl, MediaType, Sha256}; use radroots_event::{ GenericEventDraft, + calendar::{AuthoredCalendarDateEvent, AuthoredCalendarTimeEvent, CalendarDate}, envelope::kind::KIND_CLASSIFIED_LISTING, food::availability::{ FoodAvailabilityDetails, FoodAvailabilityDetailsParts, FoodAvailabilityStatus, FoodContent, @@ -38,9 +39,9 @@ use radroots_event_codec::encode::trade::trade_mutation_event_build; use radroots_identity::PublicKey; use radroots_nostr::{ event::{ - GenericBuilder, Kind, Timestamp, build_ask, build_food_availability, - build_nip09_deletion_request, build_nip10_reply, build_nip22_comment, build_photo_update, - build_profile, build_update, + GenericBuilder, Kind, Timestamp, build_ask, build_calendar_date, build_calendar_time, + build_food_availability, build_nip09_deletion_request, build_nip10_reply, + build_nip22_comment, build_photo_update, build_profile, build_update, }, tag::Tag, }; @@ -114,7 +115,7 @@ fn checked_in_authored_wire_corpus_is_exact_and_executable() { ); } assert_eq!(actual, expected); - assert_eq!(actual.vectors.len(), 10); + assert_eq!(actual.vectors.len(), 12); assert_eq!(APPROVED_FIXTURE_NAMESPACE, "radroots-approved-fixture-v1"); assert_eq!( actual @@ -122,7 +123,7 @@ fn checked_in_authored_wire_corpus_is_exact_and_executable() { .iter() .filter(|vector| vector.input.authoring == "typed") .count(), - 8 + 10 ); assert!(actual.vectors.iter().all(|vector| { !vector @@ -146,7 +147,7 @@ fn authored_post_content_boundary_is_exact_without_bloating_the_corpus() { fn authored_wire_vectors() -> Vec<WireVector> { let keys = fixture_keys(); let created_at = Timestamp::from_secs(CREATED_AT); - let mut vectors = Vec::with_capacity(10); + let mut vectors = Vec::with_capacity(12); let profile = AuthoredProfile::new("Alice \"Sprout\"") .expect("profile name") @@ -292,6 +293,36 @@ fn authored_wire_vectors() -> Vec<WireVector> { ), )); + let date = authored_calendar_date_event(); + vectors.push(vector( + "typed_calendar_date_event_011", + "radroots.calendar.date_event.v1", + "typed", + deterministic_event( + build_calendar_date(&date) + .expect("calendar date builder") + .custom_created_at(created_at) + .sign_with_keys(&keys) + .expect("calendar date event"), + &keys, + ), + )); + + let time = authored_calendar_time_event(); + vectors.push(vector( + "typed_calendar_time_event_012", + "radroots.calendar.time_event.v1", + "typed", + deterministic_event( + build_calendar_time(&time) + .expect("calendar time builder") + .custom_created_at(created_at) + .sign_with_keys(&keys) + .expect("calendar time event"), + &keys, + ), + )); + vectors.push(generic_vector( "generic_operational_listing_009", "radroots.operational_listing.published.v1", @@ -498,6 +529,36 @@ fn food_details() -> FoodAvailabilityDetails { .expect("food details") } +fn authored_calendar_date_event() -> AuthoredCalendarDateEvent { + AuthoredCalendarDateEvent::new( + "market-day", + "Saturday Market", + CalendarDate::parse("2026-08-08").expect("calendar date"), + ) + .expect("date event") + .with_end(CalendarDate::parse("2026-08-09").expect("calendar end")) + .expect("date end") + .with_description("Farmers market and seed swap.") + .expect("date description") + .with_locations(vec!["Central Saanich, BC".to_owned()]) + .expect("date location") + .with_summary("Local food and seeds") + .expect("date summary") +} + +fn authored_calendar_time_event() -> AuthoredCalendarTimeEvent { + AuthoredCalendarTimeEvent::new("farm-tour", "Farm Tour", 1_784_380_800) + .expect("time event") + .with_end(1_784_387_900) + .expect("time end") + .with_description("Walk the fields and meet the growers.") + .expect("time description") + .with_start_tzid("America/Vancouver") + .expect("time zone") + .with_locations(vec!["Saanich Peninsula".to_owned()]) + .expect("time location") +} + fn operational_listing_tags() -> Vec<Vec<String>> { [ &["d", "AAAAAAAAAAAAAAAAAAAAAg"][..], diff --git a/crates/nostr/tests/calendar_event_builder.rs b/crates/nostr/tests/calendar_event_builder.rs @@ -0,0 +1,54 @@ +#[path = "../src/test_fixtures.rs"] +mod test_fixtures; + +use nostr::{Keys, SecretKey, UnsignedEvent}; +use radroots_event::calendar::{ + AuthoredCalendarDateEvent, AuthoredCalendarTimeEvent, CalendarDate, +}; +use radroots_nostr::event::{Timestamp, build_calendar_date, build_calendar_time}; + +#[test] +fn typed_calendar_builders_finalize_exact_plans_for_an_external_host_signer() { + let keys = Keys::new( + SecretKey::from_hex(test_fixtures::FIXTURE_ALICE_SECRET_KEY_HEX).expect("fixture key"), + ); + let created_at = Timestamp::from_secs(1_784_347_200); + let date = AuthoredCalendarDateEvent::new( + "market-day", + "Saturday Market", + CalendarDate::parse("2026-08-08").expect("date"), + ) + .expect("date event"); + let time = AuthoredCalendarTimeEvent::new("farm-tour", "Farm Tour", 1_784_380_800) + .expect("time event"); + + let builders = [ + ( + "radroots.calendar.date_event.v1", + 31_922, + build_calendar_date(&date).expect("date builder"), + ), + ( + "radroots.calendar.time_event.v1", + 31_923, + build_calendar_time(&time).expect("time builder"), + ), + ]; + for (contract_id, kind, builder) in builders { + let request = builder + .custom_created_at(created_at) + .into_external_signing_request(keys.public_key()) + .expect("external signing request"); + let plan = request.authored_plan().expect("typed plan").clone(); + assert_eq!(plan.body().contract().contract_id().as_str(), contract_id); + assert_eq!(plan.body().kind(), kind); + assert_eq!(plan.created_at(), created_at.as_secs()); + + let unsigned: UnsignedEvent = + serde_json::from_value(serde_json::to_value(&request).expect("request JSON")) + .expect("unsigned event"); + let signed = unsigned.sign_with_keys(&keys).expect("host signature"); + let completed = request.complete(signed).expect("exact completion"); + assert_eq!(completed.id.to_hex(), plan.expected_event_id().to_hex()); + } +} diff --git a/crates/nostr/tests/fixtures/authored_operations.v1.json b/crates/nostr/tests/fixtures/authored_operations.v1.json @@ -285,6 +285,98 @@ } }, { + "id": "typed_calendar_date_event_011", + "kind": "authored_operations.wire", + "input": { + "contract_id": "radroots.calendar.date_event.v1", + "authoring": "typed" + }, + "expected": { + "kind": 31922, + "created_at": 1784347200, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "tags": [ + [ + "d", + "market-day" + ], + [ + "title", + "Saturday Market" + ], + [ + "start", + "2026-08-08" + ], + [ + "end", + "2026-08-09" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "summary", + "Local food and seeds" + ] + ], + "content": "Farmers market and seed swap.", + "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,31922,[[\"d\",\"market-day\"],[\"title\",\"Saturday Market\"],[\"start\",\"2026-08-08\"],[\"end\",\"2026-08-09\"],[\"location\",\"Central Saanich, BC\"],[\"summary\",\"Local food and seeds\"]],\"Farmers market and seed swap.\"]", + "event_id": "b63f56e5a5a8ec22d084fbcafbaa03f3ffd2580dc9df50bb18d5df8c64ddcbe6", + "signature": "0de7de628fb564be7b5d34e49cfe0d217476d8cca4a6e2cdf06b351401cd6e2476a30d7ad87a815bf74cd7ae47987379341487d71dc70913674dd7fc792a4469", + "raw_json": "{\"id\":\"b63f56e5a5a8ec22d084fbcafbaa03f3ffd2580dc9df50bb18d5df8c64ddcbe6\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":31922,\"tags\":[[\"d\",\"market-day\"],[\"title\",\"Saturday Market\"],[\"start\",\"2026-08-08\"],[\"end\",\"2026-08-09\"],[\"location\",\"Central Saanich, BC\"],[\"summary\",\"Local food and seeds\"]],\"content\":\"Farmers market and seed swap.\",\"sig\":\"0de7de628fb564be7b5d34e49cfe0d217476d8cca4a6e2cdf06b351401cd6e2476a30d7ad87a815bf74cd7ae47987379341487d71dc70913674dd7fc792a4469\"}" + } + }, + { + "id": "typed_calendar_time_event_012", + "kind": "authored_operations.wire", + "input": { + "contract_id": "radroots.calendar.time_event.v1", + "authoring": "typed" + }, + "expected": { + "kind": 31923, + "created_at": 1784347200, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "tags": [ + [ + "d", + "farm-tour" + ], + [ + "title", + "Farm Tour" + ], + [ + "start", + "1784380800" + ], + [ + "end", + "1784387900" + ], + [ + "D", + "20652" + ], + [ + "start_tzid", + "America/Vancouver" + ], + [ + "location", + "Saanich Peninsula" + ] + ], + "content": "Walk the fields and meet the growers.", + "preimage": "[0,\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",1784347200,31923,[[\"d\",\"farm-tour\"],[\"title\",\"Farm Tour\"],[\"start\",\"1784380800\"],[\"end\",\"1784387900\"],[\"D\",\"20652\"],[\"start_tzid\",\"America/Vancouver\"],[\"location\",\"Saanich Peninsula\"]],\"Walk the fields and meet the growers.\"]", + "event_id": "22d1538c18614f9c089cbccd9de91dab5dcd55d7a41bce07a7282f49ce757433", + "signature": "ddac1ac03aed8b888bde2fae0915e07cab85a1f1bcd2f1ce2812befb0456f320a03bea94e1469f62aa1ecaceb241bada4449c3707f34d42cb378299c6a4793f5", + "raw_json": "{\"id\":\"22d1538c18614f9c089cbccd9de91dab5dcd55d7a41bce07a7282f49ce757433\",\"pubkey\":\"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df\",\"created_at\":1784347200,\"kind\":31923,\"tags\":[[\"d\",\"farm-tour\"],[\"title\",\"Farm Tour\"],[\"start\",\"1784380800\"],[\"end\",\"1784387900\"],[\"D\",\"20652\"],[\"start_tzid\",\"America/Vancouver\"],[\"location\",\"Saanich Peninsula\"]],\"content\":\"Walk the fields and meet the growers.\",\"sig\":\"ddac1ac03aed8b888bde2fae0915e07cab85a1f1bcd2f1ce2812befb0456f320a03bea94e1469f62aa1ecaceb241bada4449c3707f34d42cb378299c6a4793f5\"}" + } + }, + { "id": "generic_operational_listing_009", "kind": "authored_operations.wire", "input": { diff --git a/crates/sdk/tests/package_boundary.rs b/crates/sdk/tests/package_boundary.rs @@ -347,7 +347,7 @@ fn sync_operations_only_delegate_to_the_canonical_engine() { fn authored_submission_is_one_protocol_neutral_boundary_for_all_typed_contracts() { use radroots_event_codec::authoring::REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS; - assert_eq!(REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS.len(), 8); + assert_eq!(REGISTRY_V7_TYPED_AUTHORING_CONTRACT_IDS.len(), 10); assert!(SYNC.contains("pub async fn submit_push")); assert!(SYNC.contains("request: PushRequest")); assert!(SYNC.contains("Result<PushStatus, Error>"));