field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

commit 6e0e0ec524d53b3396357f18211d5af266889c6d
parent f20d082255ccdd73074673edea41c9ad15e126be
Author: triesap <tyson@radroots.org>
Date:   Fri,  7 Aug 2026 12:24:10 +0000

feat(mobile): materialize Today ingest and query

- persist deterministic Today documents and immutable snapshots
- enrich ranked cards with profiles, threads, media, and author overlays
- add SQLite schema v12 and backend-neutral projection storage
- cover rebuild, cursor, corruption, concurrency, and downstream contracts

Diffstat:
Mcore/crates/tera_core/Cargo.toml | 1+
Mcore/crates/tera_core/src/runtime/product_surface.rs | 10++++++++--
Mcore/crates/tera_core/src/runtime/product_surface/cursor.rs | 97+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mcore/crates/tera_core/src/runtime/product_surface/model.rs | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcore/crates/tera_core/src/runtime/product_surface/projection.rs | 42++++++++++++++++++++++++++++++++++++++++++
Acore/crates/tera_core/src/runtime/product_surface/today.rs | 2114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 2297 insertions(+), 41 deletions(-)

diff --git a/core/crates/tera_core/Cargo.toml b/core/crates/tera_core/Cargo.toml @@ -46,5 +46,6 @@ radroots_blossom = { workspace = true, default-features = false, features = [ "std", ] } radroots_sdk = { workspace = true, features = ["memory", "sqlite"] } +radroots_transport = { workspace = true, default-features = false, features = ["std"] } tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/core/crates/tera_core/src/runtime/product_surface.rs b/core/crates/tera_core/src/runtime/product_surface.rs @@ -11,6 +11,7 @@ mod identity; mod model; mod projection; mod ranking; +mod today; pub use authoring::{ CreateAsk, CreateEvent, CreateFoodAvailability, CreatePhotoUpdate, CreateUpdate, @@ -24,11 +25,16 @@ pub use cursor::{CursorError, CursorScope, TodayCursor, TodayCursorPosition}; pub use identity::{CARD_ID_SCHEMA_VERSION, CardId, CardIdError, CardSourceIdentity}; pub use model::{ AddCommandType, CANONICAL_ADD_COMMAND_TYPES, CANONICAL_CARD_ADD_PARITY, - CANONICAL_TODAY_CARD_TYPES, CardAddParity, CardLifecycleState, ClassifiedCard, MediaReference, - MediaVerificationState, ProfileSummary, SupportingProfile, ThreadReference, TodayCardType, + CANONICAL_TODAY_CARD_TYPES, CardAddParity, CardLifecycleState, ClassifiedCard, + LocalAuthorOverlay, MeSnapshot, MediaReference, MediaVerificationState, ProfileSummary, + SearchResult, SearchResultType, SupportingProfile, ThreadEntry, ThreadReference, TodayCard, + TodayCardType, TodayPage, }; pub use projection::{ProductEventClassification, ProductEventExclusion, classify_admitted_event}; pub use ranking::{RankError, TODAY_RANK_SCHEMA_VERSION, TimeRelevance, TodayRank, TodayRankInput}; +pub use today::{ + TodayError, TodayIngestReceipt, TodayPageRequest, TodayProjectionUpdate, TodayRefreshReceipt, +}; use super::RadrootsRuntime; diff --git a/core/crates/tera_core/src/runtime/product_surface/cursor.rs b/core/crates/tera_core/src/runtime/product_surface/cursor.rs @@ -102,25 +102,26 @@ impl TodayCursor { } pub fn decode(value: &str, expected: &CursorScope) -> Result<TodayCursorPosition, CursorError> { - let encoded = value - .strip_prefix(CURSOR_PREFIX) - .ok_or(CursorError::Malformed)?; - if encoded.len() % 2 != 0 - || !encoded - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + let (scope, position) = decode_unbound(value)?; + if scope.context_id != expected.context_id + || scope.context_generation != expected.context_generation { - return Err(CursorError::Malformed); + return Err(CursorError::ContextMismatch); } - let bytes = hex::decode(encoded).map_err(|_| CursorError::Malformed)?; - if bytes.len() < FIXED_PAYLOAD_BYTES + DIGEST_BYTES { - return Err(CursorError::Malformed); + if scope.as_of != expected.as_of { + return Err(CursorError::SnapshotMismatch); } - let (payload, observed_digest) = bytes.split_at(bytes.len() - DIGEST_BYTES); - if cursor_digest(payload).as_slice() != observed_digest { - return Err(CursorError::Integrity); + if scope.store_generation != expected.store_generation + || scope.projection_generation != expected.projection_generation + { + return Err(CursorError::Stale); } - decode_payload(payload, expected) + Ok(position) + } + + /// Recovers the integrity-checked frozen scope carried by an opaque cursor. + pub fn scope(value: &str) -> Result<CursorScope, CursorError> { + decode_unbound(value).map(|(scope, _)| scope) } pub fn as_str(&self) -> &str { @@ -128,10 +129,29 @@ impl TodayCursor { } } -fn decode_payload( - payload: &[u8], - expected: &CursorScope, -) -> Result<TodayCursorPosition, CursorError> { +fn decode_unbound(value: &str) -> Result<(CursorScope, TodayCursorPosition), CursorError> { + let encoded = value + .strip_prefix(CURSOR_PREFIX) + .ok_or(CursorError::Malformed)?; + if encoded.len() % 2 != 0 + || !encoded + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(CursorError::Malformed); + } + let bytes = hex::decode(encoded).map_err(|_| CursorError::Malformed)?; + if bytes.len() < FIXED_PAYLOAD_BYTES + DIGEST_BYTES { + return Err(CursorError::Malformed); + } + let (payload, observed_digest) = bytes.split_at(bytes.len() - DIGEST_BYTES); + if cursor_digest(payload).as_slice() != observed_digest { + return Err(CursorError::Integrity); + } + decode_payload(payload) +} + +fn decode_payload(payload: &[u8]) -> Result<(CursorScope, TodayCursorPosition), CursorError> { let mut decoder = Decoder::new(payload); let cursor_version = decoder.u16()?; let rank_schema_version = decoder.u16()?; @@ -161,27 +181,25 @@ fn decode_payload( if !decoder.is_finished() { return Err(CursorError::Malformed); } - if context_id != expected.context_id || context_generation != expected.context_generation { - return Err(CursorError::ContextMismatch); - } - if as_of != expected.as_of { - return Err(CursorError::SnapshotMismatch); - } - if store_generation != expected.store_generation - || projection_generation != expected.projection_generation - { - return Err(CursorError::Stale); - } - Ok(TodayCursorPosition { - rank: TodayRank { - schema_version: rank_schema_version, - algorithm_version: rank_algorithm_version, - context_rank, - time_relevance_rank, - effective_at, - card_id, + Ok(( + CursorScope { + context_id: context_id.to_owned(), + context_generation, + as_of, + store_generation, + projection_generation, + }, + TodayCursorPosition { + rank: TodayRank { + schema_version: rank_schema_version, + algorithm_version: rank_algorithm_version, + context_rank, + time_relevance_rank, + effective_at, + card_id, + }, }, - }) + )) } fn validate_context_id(value: &str) -> Result<(), CursorError> { @@ -288,6 +306,7 @@ mod tests { TodayCursor::decode(cursor.as_str(), &scope()).expect("decode"), position() ); + assert_eq!(TodayCursor::scope(cursor.as_str()).expect("scope"), scope()); } #[test] diff --git a/core/crates/tera_core/src/runtime/product_surface/model.rs b/core/crates/tera_core/src/runtime/product_surface/model.rs @@ -124,7 +124,12 @@ pub struct ProfileSummary { pub author_pubkey: String, pub name: Option<String>, pub display_name: Option<String>, + pub about: Option<String>, pub picture: Option<MediaReference>, + pub banner: Option<MediaReference>, + pub nip05: Option<String>, + pub website: Option<String>, + pub lightning_address: Option<String>, } /// Thread enrichment identity; replies and comments never become top-level cards. @@ -136,6 +141,26 @@ pub struct ThreadReference { pub parent_event_id: String, } +/// One admitted reply or comment attached to its canonical root. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ThreadEntry { + pub event_id: String, + pub author_pubkey: String, + pub content: String, + pub authored_at: u64, + pub reference: ThreadReference, + pub author_profile: Option<ProfileSummary>, +} + +/// Durable local-only authored state overlaid without changing event truth. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalAuthorOverlay { + pub operation_id: String, + pub state: String, +} + /// Current rendering state derived from standard event semantics. #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "PascalCase")] @@ -156,8 +181,12 @@ pub struct ClassifiedCard { pub source_address: Option<String>, pub author_pubkey: String, pub contract_id: String, + pub title: Option<String>, pub content: String, pub authored_at: u64, + pub effective_at: u64, + pub event_start: Option<u64>, + pub event_end: Option<u64>, pub context_rank: ContextRank, pub inclusion_reason: String, pub media: Vec<MediaReference>, @@ -165,6 +194,51 @@ pub struct ClassifiedCard { pub rank: Option<TodayRank>, } +/// One fully enriched Today card returned to a host. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TodayCard { + pub card: ClassifiedCard, + pub author_profile: Option<ProfileSummary>, + pub thread: Vec<ThreadEntry>, + pub local_overlay: Option<LocalAuthorOverlay>, +} + +/// One frozen, cursor-addressable Today page. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TodayPage { + pub as_of: u64, + pub items: Vec<TodayCard>, + pub next_cursor: Option<String>, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum SearchResultType { + Card, + Profile, +} + +/// One local search result governed by the same current projection as Today. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchResult { + pub result_type: SearchResultType, + pub stable_id: String, + pub card: Option<TodayCard>, + pub profile: Option<ProfileSummary>, +} + +/// Active identity attribution and its current visible Phase 1 cards. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MeSnapshot { + pub public_key: String, + pub profile: Option<ProfileSummary>, + pub cards: Vec<TodayCard>, +} + #[cfg(test)] mod tests { use super::*; diff --git a/core/crates/tera_core/src/runtime/product_surface/projection.rs b/core/crates/tera_core/src/runtime/product_surface/projection.rs @@ -145,6 +145,22 @@ fn card( identifier, } => Some(format!("{kind}:{author_pubkey}:{identifier}")), }; + let tags = event.tags_as_vec(); + let title = tag_value(&tags, &["title", "name"]); + let (effective_at, event_start, event_end) = match card_type { + TodayCardType::Event => { + let start = tag_time(&tags, "start").unwrap_or_else(|| event.created_at_u64()); + (start, Some(start), tag_time(&tags, "end")) + } + TodayCardType::FoodAvailability => ( + tag_time(&tags, "published_at").unwrap_or_else(|| event.created_at_u64()), + None, + None, + ), + TodayCardType::Update | TodayCardType::PhotoUpdate | TodayCardType::Ask => { + (event.created_at_u64(), None, None) + } + }; ProductEventClassification::Card(Box::new(ClassifiedCard { schema_version: CLASSIFIED_CARD_SCHEMA_VERSION, card_id: CardId::derive(card_type, &source), @@ -153,8 +169,12 @@ fn card( source_address, author_pubkey: event.author().to_hex(), contract_id: admitted.contract_id().to_owned(), + title, content: event.content().to_owned(), authored_at: event.created_at_u64(), + effective_at, + event_start, + event_end, context_rank: context.rank, inclusion_reason: context.reason.to_owned(), media, @@ -163,6 +183,28 @@ fn card( })) } +fn tag_value(tags: &[Vec<String>], names: &[&str]) -> Option<String> { + tags.iter().find_map(|tag| { + names + .contains(&tag.first()?.as_str()) + .then(|| tag.get(1).cloned()) + .flatten() + }) +} + +fn tag_time(tags: &[Vec<String>], name: &str) -> Option<u64> { + let value = tag_value(tags, &[name])?; + value.parse().ok().or_else(|| { + chrono::NaiveDate::parse_from_str(&value, "%Y-%m-%d") + .ok()? + .and_hms_opt(0, 0, 0)? + .and_utc() + .timestamp() + .try_into() + .ok() + }) +} + fn post_media( projection: &radroots_event_codec::decode::post::RadrootsInboundPostProjection, ) -> Vec<MediaReference> { diff --git a/core/crates/tera_core/src/runtime/product_surface/today.rs b/core/crates/tera_core/src/runtime/product_surface/today.rs @@ -0,0 +1,2114 @@ +use std::collections::BTreeMap; + +use radroots_event_codec::{ + admission::{RadrootsAdmittedEvent, admit_verified_event}, + verify::verify_nip01_event, +}; +use radroots_storage::{ + EventStore, ProjectionStore, + event::{ + AdmissionReceipt, EventAdmission, EventPosition, EventQuery, EventQueryBounds, + EventSequence, + }, + projection::{ + ProjectionCheckpoint, ProjectionDocument, ProjectionGeneration, ProjectionId, + ProjectionSnapshot, + }, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use super::{ + CardId, CardLifecycleState, ClassifiedCard, CursorError, CursorScope, LocalAuthorOverlay, + LocalNetwork, LocalityEvidence, MeSnapshot, MediaReference, MediaVerificationState, + ProductEventClassification, ProfileSummary, SearchResult, SearchResultType, SupportingProfile, + ThreadEntry, ThreadReference, TimeRelevance, TodayCard, TodayCardType, TodayCursor, + TodayCursorPosition, TodayPage, TodayRank, TodayRankInput, classify_admitted_event, +}; +use crate::runtime::RadrootsRuntime; + +const TODAY_PROJECTION_ID: &str = "radroots.today.v1"; +const TODAY_PROJECTION_DOCUMENT_SCHEMA_VERSION: u16 = 1; +const TODAY_SNAPSHOT_SCHEMA_VERSION: u16 = 1; +const TODAY_PAGE_LIMIT_MAX: u16 = 100; +const TODAY_SEARCH_LIMIT_MAX: u16 = 100; +const PROJECTION_GENERATION_DOMAIN: &[u8] = b"radroots.today-projection.v1\0"; +const PROJECTION_CONTENT_DOMAIN: &[u8] = b"radroots.today-content-generation.v1\0"; +const PROJECTION_DOCUMENT_KEY_DOMAIN: &[u8] = b"radroots.today-document-key.v1\0"; +const SNAPSHOT_ID_DOMAIN: &[u8] = b"radroots.today-snapshot-id.v1\0"; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "PascalCase")] +pub enum TodayProjectionUpdate { + Incremental, + Rebuild, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TodayRefreshReceipt { + pub update: TodayProjectionUpdate, + pub source_events: u64, + pub visible_cards: u64, + pub profiles: u64, + pub thread_entries: u64, + pub content_generation: u64, + pub changed: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TodayIngestReceipt { + pub event_id: String, + pub disposition: String, + pub source_sequence: u64, + pub projection: TodayRefreshReceipt, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TodayPageRequest { + pub limit: u16, + pub as_of: Option<u64>, + pub cursor: Option<String>, +} + +impl TodayPageRequest { + pub const fn first(limit: u16, as_of: u64) -> Self { + Self { + limit, + as_of: Some(as_of), + cursor: None, + } + } + + pub fn after(limit: u16, cursor: String) -> Self { + Self { + limit, + as_of: None, + cursor: Some(cursor), + } + } +} + +#[derive(Debug, Error)] +pub enum TodayError { + #[error("today runtime is unavailable")] + RuntimeUnavailable, + #[error("today request is invalid")] + InvalidRequest, + #[error("today projection has not been refreshed")] + ProjectionMissing, + #[error("today frozen snapshot is unavailable")] + SnapshotMissing, + #[error("today cursor position is absent from its frozen snapshot")] + CursorPositionMissing, + #[error("today event was not admitted as visible")] + EventNotVisible, + #[error("today projection state is corrupt")] + CorruptProjection, + #[error(transparent)] + Cursor(#[from] CursorError), + #[error(transparent)] + Storage(#[from] radroots_storage::Error), + #[error("today projection serialization failed")] + Serialization, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct ProjectedCard { + card: ClassifiedCard, + locality: Vec<LocalityTag>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Ord, PartialOrd, Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalityTag { + kind: String, + value: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct TodayProjectionState { + schema_version: u16, + context_id: String, + context_generation: u64, + store_generation: [u8; 32], + source_events: u64, + content_generation: u64, + cards: Vec<ProjectedCard>, + profiles: BTreeMap<String, ProfileSummary>, + thread: Vec<ThreadEntry>, + overlays: BTreeMap<String, LocalAuthorOverlay>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct FrozenTodaySnapshot { + schema_version: u16, + context_id: String, + context_generation: u64, + as_of: u64, + store_generation: [u8; 32], + projection_generation: u64, + items: Vec<TodayCard>, +} + +impl RadrootsRuntime { + /// Durably admits one already verified and visibility-authorized relay observation, + /// then advances the selected LocalNetwork projection. + pub async fn phase1_ingest_visible( + &self, + admission: EventAdmission, + context: &LocalNetwork, + now_unix_seconds: u64, + ) -> Result<TodayIngestReceipt, TodayError> { + if admission.visible_event().is_none() { + return Err(TodayError::EventNotVisible); + } + let storage = self + .client + .storage() + .map_err(|_| TodayError::RuntimeUnavailable)?; + let receipt = EventStore::admit(storage, admission).await?; + let projection = self + .phase1_refresh_today( + context, + now_unix_seconds, + TodayProjectionUpdate::Incremental, + ) + .await?; + Ok(ingest_receipt(receipt, projection)) + } + + /// Materializes current visible event truth for one LocalNetwork. + pub async fn phase1_refresh_today( + &self, + context: &LocalNetwork, + now_unix_seconds: u64, + update: TodayProjectionUpdate, + ) -> Result<TodayRefreshReceipt, TodayError> { + if now_unix_seconds == 0 { + return Err(TodayError::InvalidRequest); + } + let requested_updated_at_unix_ms = now_unix_seconds + .checked_mul(1_000) + .ok_or(TodayError::InvalidRequest)?; + let storage = self + .client + .storage() + .map_err(|_| TodayError::RuntimeUnavailable)?; + if update == TodayProjectionUpdate::Rebuild { + EventStore::rebuild_visibility(storage).await?; + } + let event_status = EventStore::status(storage).await?; + let generation = projection_generation()?; + let projection_id = projection_id()?; + let key = projection_document_key(context); + let prior = ProjectionStore::projection_document( + storage, + projection_id.clone(), + generation, + key.clone(), + ) + .await? + .map(|document| decode_state(document.value())) + .transpose()?; + + if update == TodayProjectionUpdate::Incremental + && prior + .as_ref() + .is_some_and(|state| state.source_events == event_status.raw_events()) + { + let state = prior.expect("checked present"); + return Ok(refresh_receipt(update, &state, false)); + } + + let visible = query_all_visible(storage).await?; + let local_media = prior.as_ref().map(local_media_evidence).unwrap_or_default(); + let overlays = prior.map_or_else(BTreeMap::new, |state| state.overlays); + let mut state = project_state( + context, + event_status.generation().as_bytes(), + event_status.raw_events(), + visible, + overlays, + )?; + apply_local_media_evidence(&mut state, &local_media); + state.content_generation = content_generation(&state)?; + let encoded = encode(&state)?; + let changed = ProjectionStore::projection_document( + storage, + projection_id.clone(), + generation, + key.clone(), + ) + .await? + .is_none_or(|document| document.value() != encoded); + ProjectionStore::put_projection_document( + storage, + projection_id.clone(), + generation, + ProjectionDocument::new(key, encoded)?, + ) + .await?; + + let source_position = if event_status.raw_events() == 0 { + None + } else { + Some(EventPosition::new( + event_status.generation(), + EventSequence::new(event_status.raw_events())?, + )) + }; + let prior_updated_at = ProjectionStore::status(storage, projection_id.clone()) + .await? + .and_then(|status| { + status + .checkpoint() + .map(ProjectionCheckpoint::updated_at_unix_ms) + }) + .unwrap_or(0); + let updated_at_unix_ms = requested_updated_at_unix_ms.max(prior_updated_at); + ProjectionStore::checkpoint( + storage, + ProjectionCheckpoint::new( + projection_id, + generation, + source_position, + event_status.raw_events(), + updated_at_unix_ms, + )?, + ) + .await?; + Ok(refresh_receipt(update, &state, changed)) + } + + /// Returns one page from a durable frozen Today snapshot. + pub async fn phase1_today_page( + &self, + context: &LocalNetwork, + request: TodayPageRequest, + ) -> Result<TodayPage, TodayError> { + if request.limit == 0 || request.limit > TODAY_PAGE_LIMIT_MAX { + return Err(TodayError::InvalidRequest); + } + let storage = self + .client + .storage() + .map_err(|_| TodayError::RuntimeUnavailable)?; + let event_status = EventStore::status(storage).await?; + let algorithm_generation = projection_generation()?; + let projection_id = projection_id()?; + + let (scope, snapshot, after) = if let Some(cursor) = request.cursor.as_deref() { + let scope = TodayCursor::scope(cursor)?; + if scope.context_id != context.id || scope.context_generation != context.generation { + return Err(CursorError::ContextMismatch.into()); + } + if request.as_of.is_some_and(|as_of| as_of != scope.as_of) { + return Err(CursorError::SnapshotMismatch.into()); + } + if scope.store_generation != *event_status.generation().as_bytes() { + return Err(CursorError::Stale.into()); + } + let position = TodayCursor::decode(cursor, &scope)?; + let snapshot = load_snapshot(storage, projection_id, algorithm_generation, &scope) + .await? + .ok_or(TodayError::SnapshotMissing)?; + (scope, snapshot, Some(position.rank)) + } else { + let as_of = request + .as_of + .filter(|value| *value != 0) + .ok_or(TodayError::InvalidRequest)?; + let state = load_state(storage, context, algorithm_generation) + .await? + .ok_or(TodayError::ProjectionMissing)?; + if state.store_generation != *event_status.generation().as_bytes() { + return Err(CursorError::Stale.into()); + } + let scope = CursorScope::new( + context.id.clone(), + context.generation, + as_of, + state.store_generation, + state.content_generation, + )?; + let snapshot = frozen_snapshot(&state, context, as_of)?; + persist_snapshot(storage, algorithm_generation, &scope, &snapshot).await?; + (scope, snapshot, None) + }; + + page_from_snapshot(snapshot, scope, after, request.limit) + } + + /// Searches the current local projection using Today visibility and context rules. + pub async fn phase1_search( + &self, + context: &LocalNetwork, + query: &str, + limit: u16, + as_of: u64, + ) -> Result<Vec<SearchResult>, TodayError> { + if limit == 0 || limit > TODAY_SEARCH_LIMIT_MAX || as_of == 0 { + return Err(TodayError::InvalidRequest); + } + let needle = query.trim().to_lowercase(); + if needle.is_empty() || needle.len() > 256 || query.chars().any(char::is_control) { + return Err(TodayError::InvalidRequest); + } + let storage = self + .client + .storage() + .map_err(|_| TodayError::RuntimeUnavailable)?; + let state = load_state(storage, context, projection_generation()?) + .await? + .ok_or(TodayError::ProjectionMissing)?; + let cards = ranked_cards(&state, context, as_of)?; + let mut results = Vec::new(); + for card in cards { + let searchable = format!( + "{} {} {} {}", + card.card.title.as_deref().unwrap_or(""), + card.card.content, + card.card.author_pubkey, + card.author_profile + .as_ref() + .and_then(|profile| profile.display_name.as_deref().or(profile.name.as_deref())) + .unwrap_or("") + ) + .to_lowercase(); + if searchable.contains(&needle) { + results.push(SearchResult { + result_type: SearchResultType::Card, + stable_id: card.card.card_id.to_hex(), + card: Some(card), + profile: None, + }); + if results.len() == usize::from(limit) { + return Ok(results); + } + } + } + for profile in state.profiles.values() { + let searchable = format!( + "{} {} {} {} {}", + profile.name.as_deref().unwrap_or(""), + profile.display_name.as_deref().unwrap_or(""), + profile.about.as_deref().unwrap_or(""), + profile.website.as_deref().unwrap_or(""), + profile.lightning_address.as_deref().unwrap_or("") + ) + .to_lowercase(); + if searchable.contains(&needle) { + results.push(SearchResult { + result_type: SearchResultType::Profile, + stable_id: profile.author_pubkey.clone(), + card: None, + profile: Some(profile.clone()), + }); + if results.len() == usize::from(limit) { + break; + } + } + } + Ok(results) + } + + /// Returns current active-identity attribution and visible Phase 1 content. + pub async fn phase1_me( + &self, + context: &LocalNetwork, + public_key: &str, + as_of: u64, + ) -> Result<MeSnapshot, TodayError> { + if !valid_public_key(public_key) || as_of == 0 { + return Err(TodayError::InvalidRequest); + } + if self + .authenticated_store_public_key_hex() + .is_some_and(|store_key| store_key != public_key) + { + return Err(TodayError::InvalidRequest); + } + let storage = self + .client + .storage() + .map_err(|_| TodayError::RuntimeUnavailable)?; + let state = load_state(storage, context, projection_generation()?) + .await? + .ok_or(TodayError::ProjectionMissing)?; + let cards = ranked_cards(&state, context, as_of)? + .into_iter() + .filter(|card| card.card.author_pubkey == public_key) + .collect(); + Ok(MeSnapshot { + public_key: public_key.to_owned(), + profile: state.profiles.get(public_key).cloned(), + cards, + }) + } + + /// Updates local byte-verification evidence without changing card classification. + pub async fn phase1_set_media_verification( + &self, + context: &LocalNetwork, + url: &str, + verification: MediaVerificationState, + ) -> Result<bool, TodayError> { + if url.is_empty() || url.len() > 8_192 || url.chars().any(char::is_control) { + return Err(TodayError::InvalidRequest); + } + let storage = self + .client + .storage() + .map_err(|_| TodayError::RuntimeUnavailable)?; + let generation = projection_generation()?; + let mut state = load_state(storage, context, generation) + .await? + .ok_or(TodayError::ProjectionMissing)?; + let mut changed = false; + for projected in &mut state.cards { + for media in &mut projected.card.media { + if media.url == url && media.verification != verification { + media.verification = verification; + changed = true; + } + } + } + for profile in state.profiles.values_mut() { + for media in [&mut profile.picture, &mut profile.banner] + .into_iter() + .flatten() + { + if media.url == url && media.verification != verification { + media.verification = verification; + changed = true; + } + } + } + if changed { + refresh_thread_profiles(&mut state); + state.content_generation = content_generation(&state)?; + store_state(storage, context, generation, &state).await?; + } + Ok(changed) + } + + /// Persists active-author delivery state as a local-only Today overlay. + pub async fn phase1_set_local_author_overlay( + &self, + context: &LocalNetwork, + card_id: CardId, + overlay: Option<LocalAuthorOverlay>, + ) -> Result<(), TodayError> { + let storage = self + .client + .storage() + .map_err(|_| TodayError::RuntimeUnavailable)?; + let generation = projection_generation()?; + let mut state = load_state(storage, context, generation) + .await? + .ok_or(TodayError::ProjectionMissing)?; + if overlay.as_ref().is_some_and(|overlay| { + overlay.operation_id.is_empty() + || overlay.operation_id.len() > 256 + || overlay.state.is_empty() + || overlay.state.len() > 96 + || overlay.state.chars().any(char::is_control) + }) { + return Err(TodayError::InvalidRequest); + } + let key = card_id.to_hex(); + let card = state + .cards + .iter() + .find(|projected| projected.card.card_id == card_id) + .ok_or(TodayError::InvalidRequest)?; + if self + .authenticated_store_public_key_hex() + .is_some_and(|store_key| store_key != card.card.author_pubkey) + { + return Err(TodayError::InvalidRequest); + } + if let Some(overlay) = overlay { + state.overlays.insert(key, overlay); + } else { + state.overlays.remove(&key); + } + state.content_generation = content_generation(&state)?; + store_state(storage, context, generation, &state).await + } +} + +fn ingest_receipt( + receipt: AdmissionReceipt, + projection: TodayRefreshReceipt, +) -> TodayIngestReceipt { + TodayIngestReceipt { + event_id: receipt.event_id().to_hex(), + disposition: format!("{:?}", receipt.disposition()).to_lowercase(), + source_sequence: receipt.position().sequence().get(), + projection, + } +} + +fn refresh_receipt( + update: TodayProjectionUpdate, + state: &TodayProjectionState, + changed: bool, +) -> TodayRefreshReceipt { + TodayRefreshReceipt { + update, + source_events: state.source_events, + visible_cards: state.cards.len().try_into().unwrap_or(u64::MAX), + profiles: state.profiles.len().try_into().unwrap_or(u64::MAX), + thread_entries: state.thread.len().try_into().unwrap_or(u64::MAX), + content_generation: state.content_generation, + changed, + } +} + +fn local_media_evidence(state: &TodayProjectionState) -> BTreeMap<String, MediaVerificationState> { + let mut evidence = state + .cards + .iter() + .flat_map(|projected| projected.card.media.iter()) + .filter(|media| media.verification != MediaVerificationState::Unavailable) + .map(|media| (media.url.clone(), media.verification)) + .collect::<BTreeMap<_, _>>(); + for profile in state.profiles.values() { + for media in [&profile.picture, &profile.banner].into_iter().flatten() { + if media.verification != MediaVerificationState::Unavailable { + evidence.insert(media.url.clone(), media.verification); + } + } + } + evidence +} + +fn apply_local_media_evidence( + state: &mut TodayProjectionState, + evidence: &BTreeMap<String, MediaVerificationState>, +) { + for projected in &mut state.cards { + for media in &mut projected.card.media { + if let Some(verification) = evidence.get(&media.url) { + media.verification = *verification; + } + } + } + for profile in state.profiles.values_mut() { + for media in [&mut profile.picture, &mut profile.banner] + .into_iter() + .flatten() + { + if let Some(verification) = evidence.get(&media.url) { + media.verification = *verification; + } + } + } + refresh_thread_profiles(state); +} + +fn refresh_thread_profiles(state: &mut TodayProjectionState) { + for entry in &mut state.thread { + entry.author_profile = state.profiles.get(&entry.author_pubkey).cloned(); + } +} + +async fn query_all_visible( + storage: &dyn radroots_storage::Storage, +) -> Result<Vec<radroots_storage::event::StoredVisibleEvent>, TodayError> { + let mut items = Vec::new(); + let mut after = None; + loop { + let mut bounds = EventQueryBounds::first(radroots_storage::event::EVENT_QUERY_LIMIT_MAX)?; + if let Some(cursor) = after { + bounds = bounds.after(cursor); + } + let page = EventStore::query_visible(storage, EventQuery::all(bounds)).await?; + items.extend_from_slice(page.items()); + let Some(next) = page.next_cursor() else { + break; + }; + after = Some(next); + } + Ok(items) +} + +fn project_state( + context: &LocalNetwork, + store_generation: &[u8; 32], + source_events: u64, + visible: Vec<radroots_storage::event::StoredVisibleEvent>, + overlays: BTreeMap<String, LocalAuthorOverlay>, +) -> Result<TodayProjectionState, TodayError> { + let mut cards = Vec::new(); + let mut profiles = BTreeMap::new(); + let mut thread = Vec::new(); + for stored in visible { + let verified = verify_nip01_event(stored.event().envelope().clone()) + .map_err(|_| TodayError::CorruptProjection)?; + let admitted = admit_verified_event(verified).map_err(|_| TodayError::CorruptProjection)?; + match &admitted { + RadrootsAdmittedEvent::Profile(profile) => { + profiles.insert( + profile.event().author().to_hex(), + profile_summary(&admitted)?, + ); + } + RadrootsAdmittedEvent::Reply(_) => { + thread.push(reply_entry(&admitted)?); + } + RadrootsAdmittedEvent::Comment(_) => { + if let Some(entry) = comment_entry(&admitted) { + thread.push(entry); + } + } + _ => { + let locality = locality_tags(admitted.event().tags_as_vec()); + let evidence = locality_evidence(context.locality.as_deref(), &locality); + if let ProductEventClassification::Card(card) = + classify_admitted_event(&admitted, context.admit(evidence)) + { + cards.push(ProjectedCard { + card: *card, + locality, + }); + } + } + } + } + cards.sort_by_key(|projected| projected.card.card_id); + thread.sort_by(|left, right| left.event_id.cmp(&right.event_id)); + for entry in &mut thread { + entry.author_profile = profiles.get(&entry.author_pubkey).cloned(); + } + Ok(TodayProjectionState { + schema_version: TODAY_PROJECTION_DOCUMENT_SCHEMA_VERSION, + context_id: context.id.clone(), + context_generation: context.generation, + store_generation: *store_generation, + source_events, + content_generation: 0, + cards, + profiles, + thread, + overlays, + }) +} + +fn profile_summary(admitted: &RadrootsAdmittedEvent) -> Result<ProfileSummary, TodayError> { + let RadrootsAdmittedEvent::Profile(profile) = admitted else { + return Err(TodayError::CorruptProjection); + }; + let metadata = profile.metadata(); + Ok(ProfileSummary { + author_pubkey: profile.event().author().to_hex(), + name: metadata.name().map(str::to_owned), + display_name: metadata.display_name().map(str::to_owned), + about: metadata.about().map(str::to_owned), + picture: metadata + .picture() + .map(|value| unverified_media(value.as_str())), + banner: metadata + .banner() + .map(|value| unverified_media(value.as_str())), + nip05: metadata.nip05().map(|value| value.as_str().to_owned()), + website: typed_profile_extra(metadata.raw_fields(), "website"), + lightning_address: typed_profile_extra(metadata.raw_fields(), "lud16"), + }) +} + +fn typed_profile_extra(fields: &BTreeMap<String, serde_json::Value>, key: &str) -> Option<String> { + fields + .get(key) + .and_then(serde_json::Value::as_str) + .filter(|value| { + !value.is_empty() && value.len() <= 2_048 && !value.chars().any(char::is_control) + }) + .map(str::to_owned) +} + +fn unverified_media(url: &str) -> MediaReference { + MediaReference { + url: url.to_owned(), + sha256: blossom_digest(url), + media_type: None, + width: None, + height: None, + byte_size: None, + alt: None, + verification: MediaVerificationState::Unavailable, + } +} + +fn reply_entry(admitted: &RadrootsAdmittedEvent) -> Result<ThreadEntry, TodayError> { + let RadrootsAdmittedEvent::Reply(reply) = admitted else { + return Err(TodayError::CorruptProjection); + }; + Ok(ThreadEntry { + event_id: reply.event().id_hex(), + author_pubkey: reply.event().author().to_hex(), + content: reply.event().content().to_owned(), + authored_at: reply.event().created_at_u64(), + reference: ThreadReference { + profile: SupportingProfile::Reply, + root: reply.projection().root().event_id().to_hex(), + parent_event_id: reply.projection().parent().event_id().to_hex(), + }, + author_profile: None, + }) +} + +fn comment_entry(admitted: &RadrootsAdmittedEvent) -> Option<ThreadEntry> { + let RadrootsAdmittedEvent::Comment(comment) = admitted else { + return None; + }; + let tags = comment.event().tags_as_vec(); + let root = tag_value(&tags, &["E", "A"])?; + let parent = tag_value(&tags, &["e", "a"]).unwrap_or_else(|| root.clone()); + Some(ThreadEntry { + event_id: comment.event().id_hex(), + author_pubkey: comment.event().author().to_hex(), + content: comment.event().content().to_owned(), + authored_at: comment.event().created_at_u64(), + reference: ThreadReference { + profile: SupportingProfile::Comment, + root, + parent_event_id: parent, + }, + author_profile: None, + }) +} + +fn locality_tags(tags: Vec<Vec<String>>) -> Vec<LocalityTag> { + let mut locality = tags + .into_iter() + .filter_map(|tag| { + let kind = tag.first()?.as_str(); + if !matches!(kind, "g" | "location") { + return None; + } + let value = tag.get(1)?.trim().to_lowercase(); + (!value.is_empty()).then(|| LocalityTag { + kind: kind.to_owned(), + value, + }) + }) + .collect::<Vec<_>>(); + locality.sort(); + locality.dedup(); + locality +} + +fn locality_evidence(selected: Option<&str>, locality: &[LocalityTag]) -> LocalityEvidence { + let Some(selected) = selected else { + return LocalityEvidence::Missing; + }; + if locality.is_empty() { + return LocalityEvidence::Missing; + } + let selected = selected.trim().to_lowercase(); + if locality.iter().any(|tag| { + tag.value == selected + || (tag.kind == "g" + && (tag.value.starts_with(&selected) || selected.starts_with(&tag.value))) + }) { + LocalityEvidence::Match + } else { + LocalityEvidence::Nonmatch + } +} + +fn ranked_cards( + state: &TodayProjectionState, + context: &LocalNetwork, + as_of: u64, +) -> Result<Vec<TodayCard>, TodayError> { + let mut cards = Vec::new(); + for projected in &state.cards { + let evidence = locality_evidence(context.locality.as_deref(), &projected.locality); + let admission = match context.admit(evidence) { + super::LocalNetworkAdmission::Included(admission) => admission, + super::LocalNetworkAdmission::Excluded { .. } => continue, + }; + let mut card = projected.card.clone(); + card.context_rank = admission.rank; + card.inclusion_reason = admission.reason.to_owned(); + let time = match card.card_type { + TodayCardType::Update | TodayCardType::PhotoUpdate | TodayCardType::Ask => { + TimeRelevance::Published + } + TodayCardType::FoodAvailability => TimeRelevance::FoodAvailability { + active: card.lifecycle == CardLifecycleState::Active, + }, + TodayCardType::Event => TimeRelevance::Event { + start: card.event_start.unwrap_or(card.effective_at), + end: card.event_end, + }, + }; + let rank = TodayRank::derive(TodayRankInput { + card_type: card.card_type, + context_rank: card.context_rank, + as_of, + effective_at: card.effective_at, + time, + card_id: card.card_id, + }) + .map_err(|_| TodayError::CorruptProjection)?; + if card.card_type == TodayCardType::Event && rank.time_relevance_rank == 0 { + card.lifecycle = CardLifecycleState::Past; + } + card.rank = Some(rank); + let roots = [ + card.source_event_id.as_str(), + card.source_address.as_deref().unwrap_or(""), + ]; + let card_thread = state + .thread + .iter() + .filter(|entry| roots.contains(&entry.reference.root.as_str())) + .cloned() + .collect(); + cards.push(TodayCard { + author_profile: state.profiles.get(&card.author_pubkey).cloned(), + local_overlay: state.overlays.get(&card.card_id.to_hex()).cloned(), + card, + thread: card_thread, + }); + } + cards.sort_by_key(|card| card.card.rank.expect("assigned rank")); + Ok(cards) +} + +fn frozen_snapshot( + state: &TodayProjectionState, + context: &LocalNetwork, + as_of: u64, +) -> Result<FrozenTodaySnapshot, TodayError> { + Ok(FrozenTodaySnapshot { + schema_version: TODAY_SNAPSHOT_SCHEMA_VERSION, + context_id: context.id.clone(), + context_generation: context.generation, + as_of, + store_generation: state.store_generation, + projection_generation: state.content_generation, + items: ranked_cards(state, context, as_of)?, + }) +} + +fn page_from_snapshot( + snapshot: FrozenTodaySnapshot, + scope: CursorScope, + after: Option<TodayRank>, + limit: u16, +) -> Result<TodayPage, TodayError> { + validate_snapshot(&snapshot, &scope)?; + let start = if let Some(after) = after { + snapshot + .items + .iter() + .position(|card| card.card.rank == Some(after)) + .map(|index| index + 1) + .ok_or(TodayError::CursorPositionMissing)? + } else { + 0 + }; + let end = start + .saturating_add(usize::from(limit)) + .min(snapshot.items.len()); + let items = snapshot.items[start..end].to_vec(); + let next_cursor = if end < snapshot.items.len() { + items + .last() + .and_then(|card| card.card.rank) + .map(|rank| TodayCursor::encode(&scope, TodayCursorPosition { rank })) + .transpose()? + .map(|cursor| cursor.as_str().to_owned()) + } else { + None + }; + Ok(TodayPage { + as_of: snapshot.as_of, + items, + next_cursor, + }) +} + +fn validate_snapshot( + snapshot: &FrozenTodaySnapshot, + scope: &CursorScope, +) -> Result<(), TodayError> { + if snapshot.schema_version != TODAY_SNAPSHOT_SCHEMA_VERSION + || snapshot.context_id != scope.context_id + || snapshot.context_generation != scope.context_generation + || snapshot.as_of != scope.as_of + || snapshot.store_generation != scope.store_generation + || snapshot.projection_generation != scope.projection_generation + { + return Err(TodayError::CorruptProjection); + } + Ok(()) +} + +async fn load_state( + storage: &dyn radroots_storage::Storage, + context: &LocalNetwork, + generation: ProjectionGeneration, +) -> Result<Option<TodayProjectionState>, TodayError> { + ProjectionStore::projection_document( + storage, + projection_id()?, + generation, + projection_document_key(context), + ) + .await? + .map(|document| decode_state(document.value())) + .transpose() +} + +async fn store_state( + storage: &dyn radroots_storage::Storage, + context: &LocalNetwork, + generation: ProjectionGeneration, + state: &TodayProjectionState, +) -> Result<(), TodayError> { + ProjectionStore::put_projection_document( + storage, + projection_id()?, + generation, + ProjectionDocument::new(projection_document_key(context), encode(state)?)?, + ) + .await?; + Ok(()) +} + +async fn persist_snapshot( + storage: &dyn radroots_storage::Storage, + generation: ProjectionGeneration, + scope: &CursorScope, + snapshot: &FrozenTodaySnapshot, +) -> Result<(), TodayError> { + ProjectionStore::put_projection_snapshot( + storage, + ProjectionSnapshot::new( + projection_id()?, + snapshot_id(scope), + generation, + scope + .as_of + .checked_mul(1_000) + .ok_or(TodayError::InvalidRequest)?, + encode(snapshot)?, + )?, + ) + .await?; + Ok(()) +} + +async fn load_snapshot( + storage: &dyn radroots_storage::Storage, + projection_id: ProjectionId, + generation: ProjectionGeneration, + scope: &CursorScope, +) -> Result<Option<FrozenTodaySnapshot>, TodayError> { + ProjectionStore::projection_snapshot(storage, projection_id, snapshot_id(scope)) + .await? + .map(|snapshot| { + if snapshot.generation() != generation { + return Err(TodayError::CorruptProjection); + } + decode_snapshot(snapshot.value()) + }) + .transpose() +} + +fn decode_state(value: &[u8]) -> Result<TodayProjectionState, TodayError> { + let state: TodayProjectionState = decode(value)?; + if state.schema_version != TODAY_PROJECTION_DOCUMENT_SCHEMA_VERSION + || state.content_generation == 0 + || content_generation(&state)? != state.content_generation + { + return Err(TodayError::CorruptProjection); + } + Ok(state) +} + +fn decode_snapshot(value: &[u8]) -> Result<FrozenTodaySnapshot, TodayError> { + let snapshot: FrozenTodaySnapshot = decode(value)?; + if snapshot.schema_version != TODAY_SNAPSHOT_SCHEMA_VERSION { + return Err(TodayError::CorruptProjection); + } + Ok(snapshot) +} + +fn content_generation(state: &TodayProjectionState) -> Result<u64, TodayError> { + let mut canonical = state.clone(); + canonical.content_generation = 0; + let digest = + Sha256::digest([PROJECTION_CONTENT_DOMAIN, encode(&canonical)?.as_slice()].concat()); + let generation = u64::from_be_bytes(digest[..8].try_into().expect("digest prefix")); + Ok(generation.max(1)) +} + +fn projection_generation() -> Result<ProjectionGeneration, TodayError> { + ProjectionGeneration::new(Sha256::digest(PROJECTION_GENERATION_DOMAIN).into()) + .map_err(TodayError::from) +} + +fn projection_id() -> Result<ProjectionId, TodayError> { + ProjectionId::parse(TODAY_PROJECTION_ID).map_err(TodayError::from) +} + +fn projection_document_key(context: &LocalNetwork) -> String { + let mut digest = Sha256::new(); + digest.update(PROJECTION_DOCUMENT_KEY_DOMAIN); + digest.update(context.id.as_bytes()); + digest.update(context.generation.to_be_bytes()); + format!("context.{}", hex::encode(digest.finalize())) +} + +fn snapshot_id(scope: &CursorScope) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(SNAPSHOT_ID_DOMAIN); + digest.update(scope.context_id.as_bytes()); + digest.update(scope.context_generation.to_be_bytes()); + digest.update(scope.as_of.to_be_bytes()); + digest.update(scope.store_generation); + digest.update(scope.projection_generation.to_be_bytes()); + digest.finalize().into() +} + +fn tag_value(tags: &[Vec<String>], names: &[&str]) -> Option<String> { + tags.iter().find_map(|tag| { + names + .contains(&tag.first()?.as_str()) + .then(|| tag.get(1).cloned()) + .flatten() + }) +} + +fn blossom_digest(url: &str) -> Option<String> { + let path = url.split_once("://")?.1.split_once('/')?.1; + let candidate = path.split(['.', '/', '?', '#']).next()?; + (candidate.len() == 64 + && candidate + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))) + .then(|| candidate.to_owned()) +} + +fn valid_public_key(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn encode(value: &impl Serialize) -> Result<Vec<u8>, TodayError> { + serde_json::to_vec(value).map_err(|_| TodayError::Serialization) +} + +fn decode<T: for<'de> Deserialize<'de>>(value: &[u8]) -> Result<T, TodayError> { + serde_json::from_slice(value).map_err(|_| TodayError::CorruptProjection) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::secp256k1::Message; + use nostr::{Keys, SECP256K1}; + use radroots_event::{ + SignedEvent, + admission::{AdmissionPolicy, RawEvent, VisibilityPolicy}, + wire::{Nip01EventWire, compute_canonical_nip01_event_id}, + }; + use radroots_event_codec::verify::Nip01SignatureVerifier; + use radroots_transport::{ + Target, TransportId, + source::{EventProvenance, ObservedEvent}, + }; + + const SECRET: &str = "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; + + struct Allow; + + impl AdmissionPolicy for Allow { + type Error = core::convert::Infallible; + + fn policy_id(&self) -> &'static str { + "test.today.admission.v1" + } + + fn admit( + &self, + _event: &radroots_event::admission::ContractValidatedEvent, + ) -> Result<(), Self::Error> { + Ok(()) + } + } + + impl VisibilityPolicy for Allow { + type Error = core::convert::Infallible; + + fn policy_id(&self) -> &'static str { + "test.today.visibility.v1" + } + + fn make_visible( + &self, + _event: &radroots_event::admission::AdmittedEvent, + ) -> Result<(), Self::Error> { + Ok(()) + } + } + + fn context(locality: Option<&str>, generation: u64) -> LocalNetwork { + LocalNetwork::new( + "victoria".into(), + "Victoria".into(), + vec!["wss://relay.example".into()], + locality.map(str::to_owned), + Vec::new(), + generation, + ) + .expect("context") + } + + fn keys() -> Keys { + Keys::parse(SECRET).expect("keys") + } + + fn signed(kind: u32, tags: Vec<Vec<&str>>, content: &str, created_at: u64) -> SignedEvent { + signed_owned( + kind, + tags.into_iter() + .map(|tag| tag.into_iter().map(str::to_owned).collect()) + .collect(), + content, + created_at, + ) + } + + fn signed_owned( + kind: u32, + tags: Vec<Vec<String>>, + content: &str, + created_at: u64, + ) -> SignedEvent { + let keys = keys(); + let author = keys.public_key().to_string(); + let id = compute_canonical_nip01_event_id(&author, created_at, kind, &tags, content) + .expect("id"); + let message = Message::from_digest(*id.as_bytes()); + let signature = SECP256K1.sign_schnorr_no_aux_rand( + &message, + &nostr::secp256k1::Keypair::from_secret_key(SECP256K1, keys.secret_key()), + ); + let wire = Nip01EventWire { + id: id.to_hex(), + pubkey: author, + created_at, + kind, + tags, + content: content.to_owned(), + sig: signature.to_string(), + extra: Default::default(), + }; + let raw = serde_json::json!({ + "id": &wire.id, + "pubkey": &wire.pubkey, + "created_at": wire.created_at, + "kind": wire.kind, + "tags": &wire.tags, + "content": &wire.content, + "sig": &wire.sig, + }) + .to_string(); + SignedEvent::from_wire_verified_id(wire, raw).expect("signed event") + } + + fn visible_admission(event: SignedEvent, observed_at: u64) -> EventAdmission { + let selected = admit_verified_event( + verify_nip01_event(event.envelope().clone()).expect("codec verification"), + ) + .expect("codec admission") + .contract_id(); + let verified = RawEvent::new(event.envelope().clone()) + .verify_id() + .expect("id") + .verify_signature(&Nip01SignatureVerifier) + .expect("signature"); + let visible = verified + .validate_contract_for_admission(selected) + .expect("selected contract") + .admit_with(&Allow) + .expect("admission") + .make_visible_with(&Allow) + .expect("visibility"); + let target = Target::new(TransportId::NOSTR, "wss://relay.example").expect("target"); + let provenance = EventProvenance::new( + TransportId::NOSTR, + target.fingerprint().clone(), + observed_at, + ) + .expect("provenance"); + EventAdmission::visible(ObservedEvent::new(event, provenance), visible) + .expect("visible admission") + } + + fn raw_admission(event: SignedEvent, observed_at: u64) -> EventAdmission { + let target = Target::new(TransportId::NOSTR, "wss://relay.example").expect("target"); + let provenance = EventProvenance::new( + TransportId::NOSTR, + target.fingerprint().clone(), + observed_at, + ) + .expect("provenance"); + EventAdmission::raw(ObservedEvent::new(event, provenance)) + } + + fn admitted(event: &SignedEvent) -> RadrootsAdmittedEvent { + admit_verified_event( + verify_nip01_event(event.envelope().clone()).expect("codec verification"), + ) + .expect("codec admission") + } + + async fn ingest( + runtime: &RadrootsRuntime, + context: &LocalNetwork, + event: SignedEvent, + at: u64, + ) -> TodayIngestReceipt { + runtime + .phase1_ingest_visible(visible_admission(event, at * 1_000), context, at) + .await + .expect("ingest") + } + + #[tokio::test] + async fn equal_timestamp_pages_are_complete_and_remain_frozen_across_ingest() { + let runtime = RadrootsRuntime::test_memory().expect("runtime"); + let context = context(None, 1); + for content in ["alpha", "bravo", "charlie"] { + ingest( + &runtime, + &context, + signed(1, Vec::new(), content, 2_000_000_000), + 2_000_000_100, + ) + .await; + } + let first = runtime + .phase1_today_page(&context, TodayPageRequest::first(1, 2_000_000_200)) + .await + .expect("first page"); + assert_eq!(first.items.len(), 1); + let frozen_cursor = first.next_cursor.clone().expect("cursor"); + + ingest( + &runtime, + &context, + signed(1, Vec::new(), "delta", 2_000_000_001), + 2_000_000_101, + ) + .await; + + let mut ids = first + .items + .iter() + .map(|card| card.card.card_id.to_hex()) + .collect::<Vec<_>>(); + let mut cursor = Some(frozen_cursor); + while let Some(value) = cursor { + let page = runtime + .phase1_today_page(&context, TodayPageRequest::after(1, value)) + .await + .expect("continued frozen page"); + ids.extend(page.items.iter().map(|card| card.card.card_id.to_hex())); + cursor = page.next_cursor; + } + ids.sort(); + ids.dedup(); + assert_eq!(ids.len(), 3, "frozen snapshot has no loss or duplicates"); + + let current = runtime + .phase1_today_page(&context, TodayPageRequest::first(100, 2_000_000_201)) + .await + .expect("current page"); + assert_eq!(current.items.len(), 4); + } + + #[tokio::test] + async fn profile_thread_media_search_me_context_and_rebuild_share_one_projection() { + let runtime = RadrootsRuntime::test_memory().expect("runtime"); + let context = context(Some("victoria"), 7); + let author = keys().public_key().to_string(); + let profile_url = "https://blob.example/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.jpg"; + let profile = signed( + 0, + Vec::new(), + &format!( + r#"{{"name":"moss","display_name":"Moss Farm","about":"Local roots","picture":"{profile_url}","website":"https://moss.example","lud16":"moss@example.com"}}"# + ), + 2_000_000_000, + ); + ingest(&runtime, &context, profile, 2_000_000_100).await; + + let digest = "b".repeat(64); + let photo_url = format!("https://blob.example/{digest}.jpg"); + let photo_content = format!("Fresh field photo {photo_url}"); + let photo = signed_owned( + 1, + vec![ + vec!["location".into(), "Victoria".into()], + vec![ + "imeta".into(), + format!("url {photo_url}"), + format!("x {digest}"), + "m image/jpeg".into(), + "dim 120x80".into(), + "size 345".into(), + "alt Fresh field photo".into(), + ], + ], + &photo_content, + 2_000_000_001, + ); + let root_id = photo.id().to_hex(); + ingest(&runtime, &context, photo, 2_000_000_101).await; + + let nonmatch = signed( + 1, + vec![vec!["location", "Elsewhere"]], + "far away", + 2_000_000_002, + ); + ingest(&runtime, &context, nonmatch, 2_000_000_102).await; + + let reply = signed_owned( + 1, + vec![ + vec![ + "e".into(), + root_id.clone(), + "wss://relay.example".into(), + "root".into(), + ], + vec!["p".into(), author.clone()], + ], + "Looks good", + 2_000_000_003, + ); + ingest(&runtime, &context, reply, 2_000_000_103).await; + + let food = signed( + 30_402, + vec![ + vec!["d", "today-carrots"], + vec!["title", "Today carrots"], + vec!["summary", "Fresh"], + vec!["published_at", "2000000004"], + vec!["location", "Victoria"], + vec!["price", "3", "CAD"], + vec!["radroots:price_unit", "lb"], + vec!["status", "active"], + ], + "Fresh carrots", + 2_000_000_004, + ); + let food_id = food.id().to_hex(); + ingest(&runtime, &context, food, 2_000_000_104).await; + + let comment = signed_owned( + 1_111, + vec![ + vec![ + "E".into(), + food_id.clone(), + "wss://relay.example".into(), + author.clone(), + ], + vec!["K".into(), "30402".into()], + vec!["P".into(), author.clone(), "wss://relay.example".into()], + vec![ + "e".into(), + food_id, + "wss://relay.example".into(), + author.clone(), + ], + vec!["k".into(), "30402".into()], + vec!["p".into(), author.clone(), "wss://relay.example".into()], + ], + "A NIP-22 comment", + 2_000_000_005, + ); + ingest(&runtime, &context, comment, 2_000_000_105).await; + + assert!( + runtime + .phase1_set_media_verification( + &context, + &photo_url, + MediaVerificationState::Verified, + ) + .await + .expect("media evidence") + ); + assert!( + runtime + .phase1_set_media_verification( + &context, + profile_url, + MediaVerificationState::Verified, + ) + .await + .expect("profile media evidence") + ); + let live = runtime + .phase1_today_page(&context, TodayPageRequest::first(100, 2_000_000_200)) + .await + .expect("page"); + assert_eq!(live.items.len(), 2, "known locality nonmatch is excluded"); + let card = live + .items + .iter() + .find(|card| card.card.card_type == TodayCardType::PhotoUpdate) + .unwrap_or_else(|| panic!("photo card missing from {:#?}", live.items)); + assert_eq!(card.card.card_type, TodayCardType::PhotoUpdate); + assert_eq!( + card.card.media[0].verification, + MediaVerificationState::Verified + ); + assert_eq!(card.thread.len(), 1); + assert_eq!( + live.items + .iter() + .find(|card| card.card.card_type == TodayCardType::FoodAvailability) + .expect("food card") + .thread + .len(), + 1 + ); + let profile = card.author_profile.as_ref().expect("profile enrichment"); + assert_eq!(profile.website.as_deref(), Some("https://moss.example")); + assert_eq!( + profile.lightning_address.as_deref(), + Some("moss@example.com") + ); + assert_eq!( + profile + .picture + .as_ref() + .expect("profile picture") + .verification, + MediaVerificationState::Verified + ); + + runtime + .phase1_set_local_author_overlay( + &context, + card.card.card_id, + Some(LocalAuthorOverlay { + operation_id: "publish-photo-1".into(), + state: "delivered".into(), + }), + ) + .await + .expect("active author overlay"); + assert!(matches!( + runtime + .phase1_set_local_author_overlay( + &context, + CardId::parse(&"f".repeat(64)).expect("unknown card id"), + None, + ) + .await, + Err(TodayError::InvalidRequest) + )); + + let search = runtime + .phase1_search(&context, "moss farm", 10, 2_000_000_200) + .await + .expect("search"); + assert!(search.iter().any(|result| result.profile.is_some())); + let me = runtime + .phase1_me(&context, &author, 2_000_000_200) + .await + .expect("me"); + assert_eq!(me.cards.len(), 2); + assert_eq!( + me.profile.expect("me profile").name.as_deref(), + Some("moss") + ); + + let rebuilt = runtime + .phase1_refresh_today(&context, 2_000_000_201, TodayProjectionUpdate::Rebuild) + .await + .expect("rebuild"); + assert!(!rebuilt.changed, "rebuild is byte-equivalent to live state"); + let after = runtime + .phase1_today_page(&context, TodayPageRequest::first(100, 2_000_000_202)) + .await + .expect("rebuilt page"); + let rebuilt_photo = after + .items + .iter() + .find(|card| card.card.card_type == TodayCardType::PhotoUpdate) + .expect("rebuilt photo"); + assert_eq!( + rebuilt_photo.card.media[0].verification, + MediaVerificationState::Verified + ); + assert_eq!( + rebuilt_photo + .author_profile + .as_ref() + .and_then(|profile| profile.picture.as_ref()) + .expect("rebuilt profile picture") + .verification, + MediaVerificationState::Verified + ); + assert_eq!( + rebuilt_photo + .local_overlay + .as_ref() + .map(|overlay| overlay.state.as_str()), + Some("delivered") + ); + assert_eq!(rebuilt_photo.thread.len(), 1); + } + + #[tokio::test] + async fn replacement_and_deletion_change_only_current_today_truth() { + let runtime = RadrootsRuntime::test_memory().expect("runtime"); + let context = context(None, 1); + let active = signed( + 30_402, + vec![ + vec!["d", "carrots"], + vec!["title", "Carrots"], + vec!["summary", "Fresh"], + vec!["published_at", "2000000000"], + vec!["location", "Victoria"], + vec!["price", "3", "CAD"], + vec!["radroots:price_unit", "lb"], + vec!["status", "active"], + ], + "available", + 2_000_000_000, + ); + ingest(&runtime, &context, active, 2_000_000_100).await; + let sold = signed( + 30_402, + vec![ + vec!["d", "carrots"], + vec!["title", "Carrots"], + vec!["summary", "Gone"], + vec!["published_at", "2000000001"], + vec!["location", "Victoria"], + vec!["price", "3", "CAD"], + vec!["radroots:price_unit", "lb"], + vec!["status", "sold"], + ], + "sold out", + 2_000_000_001, + ); + let sold_id = sold.id().to_hex(); + ingest(&runtime, &context, sold, 2_000_000_101).await; + let current = runtime + .phase1_today_page(&context, TodayPageRequest::first(10, 2_000_000_200)) + .await + .expect("current"); + assert_eq!(current.items.len(), 1); + assert_eq!(current.items[0].card.source_event_id, sold_id); + assert_eq!(current.items[0].card.lifecycle, CardLifecycleState::Sold); + + let deletion = signed_owned(5, vec![vec!["e".into(), sold_id]], "", 2_000_000_002); + ingest(&runtime, &context, deletion, 2_000_000_102).await; + let deleted = runtime + .phase1_today_page(&context, TodayPageRequest::first(10, 2_000_000_201)) + .await + .expect("deleted"); + assert!(deleted.items.is_empty()); + } + + #[tokio::test] + async fn sqlite_reopen_preserves_materialized_state_and_frozen_cursor_pages() { + use crate::runtime::{ + builder::RuntimeBuilder, + store::{MobileUserStoreConfig, ProtectedDataAvailability}, + }; + + let root = tempfile::tempdir().expect("root"); + let store = MobileUserStoreConfig::from_encoded( + root.path(), + &keys().public_key().to_string(), + "3131313131313131313131313131313131313131313131313131313131313131", + 2_000_000_000_000, + ProtectedDataAvailability::Available, + ) + .expect("store"); + std::fs::create_dir_all(store.owner_directory()).expect("owner directory"); + let context = context(None, 1); + let runtime = RuntimeBuilder::new(store.clone()) + .build() + .await + .expect("runtime"); + ingest( + &runtime, + &context, + signed(1, Vec::new(), "persisted alpha", 2_000_000_000), + 2_000_000_100, + ) + .await; + ingest( + &runtime, + &context, + signed(1, Vec::new(), "persisted bravo", 2_000_000_000), + 2_000_000_101, + ) + .await; + let first = runtime + .phase1_today_page(&context, TodayPageRequest::first(1, 2_000_000_200)) + .await + .expect("first page"); + let cursor = first.next_cursor.expect("frozen cursor"); + runtime.shutdown().await.expect("shutdown"); + + let reopened = RuntimeBuilder::new(store).build().await.expect("reopen"); + let second = reopened + .phase1_today_page(&context, TodayPageRequest::after(1, cursor)) + .await + .expect("continued page after reopen"); + assert_eq!(second.items.len(), 1); + assert_ne!(first.items[0].card.card_id, second.items[0].card.card_id); + assert!(second.next_cursor.is_none()); + let search = reopened + .phase1_search(&context, "persisted", 10, 2_000_000_200) + .await + .expect("search after reopen"); + assert_eq!(search.len(), 2); + reopened.shutdown().await.expect("shutdown reopened"); + } + + #[tokio::test] + async fn fail_closed_requests_cursors_overlays_and_projection_guards_are_executable() { + let mut runtime = RadrootsRuntime::test_memory().expect("runtime"); + let context = context(None, 1); + let note = signed(1, Vec::new(), "guarded alpha", 2_000_000_000); + assert!(matches!( + runtime + .phase1_ingest_visible(raw_admission(note.clone(), 2_000_000_000_000), &context, 1) + .await, + Err(TodayError::EventNotVisible) + )); + assert!(matches!( + runtime + .phase1_refresh_today(&context, 0, TodayProjectionUpdate::Incremental) + .await, + Err(TodayError::InvalidRequest) + )); + assert!(matches!( + runtime + .phase1_refresh_today(&context, u64::MAX, TodayProjectionUpdate::Incremental) + .await, + Err(TodayError::InvalidRequest) + )); + let empty = runtime + .phase1_refresh_today(&context, 1, TodayProjectionUpdate::Incremental) + .await + .expect("empty projection"); + assert_eq!(empty.source_events, 0); + assert!(empty.changed); + assert!( + !runtime + .phase1_refresh_today(&context, 2, TodayProjectionUpdate::Incremental) + .await + .expect("unchanged projection") + .changed + ); + + for request in [ + TodayPageRequest::first(0, 1), + TodayPageRequest::first(TODAY_PAGE_LIMIT_MAX + 1, 1), + TodayPageRequest { + limit: 1, + as_of: None, + cursor: None, + }, + TodayPageRequest::first(1, 0), + ] { + assert!(matches!( + runtime.phase1_today_page(&context, request).await, + Err(TodayError::InvalidRequest) + )); + } + for (query, limit, as_of) in [ + ("valid", 0, 1), + ("valid", TODAY_SEARCH_LIMIT_MAX + 1, 1), + ("valid", 1, 0), + (" ", 1, 1), + (&"x".repeat(257), 1, 1), + ("bad\nquery", 1, 1), + ] { + assert!(matches!( + runtime.phase1_search(&context, query, limit, as_of).await, + Err(TodayError::InvalidRequest) + )); + } + assert!(matches!( + runtime.phase1_me(&context, "bad", 1).await, + Err(TodayError::InvalidRequest) + )); + assert!(matches!( + runtime + .phase1_me(&context, &keys().public_key().to_string(), 0) + .await, + Err(TodayError::InvalidRequest) + )); + for url in ["", &"x".repeat(8_193), "bad\nurl"] { + assert!(matches!( + runtime + .phase1_set_media_verification(&context, url, MediaVerificationState::Verified,) + .await, + Err(TodayError::InvalidRequest) + )); + } + assert!( + !runtime + .phase1_set_media_verification( + &context, + "https://blob.example/missing.jpg", + MediaVerificationState::Verified, + ) + .await + .expect("unmatched media") + ); + + ingest(&runtime, &context, note.clone(), 2_000_000_100).await; + let page = runtime + .phase1_today_page(&context, TodayPageRequest::first(1, 2_000_000_200)) + .await + .expect("page"); + let card_id = page.items[0].card.card_id; + let rank = page.items[0].card.rank.expect("rank"); + let generation = projection_generation().expect("generation"); + let storage = runtime.client.storage().expect("storage"); + let state = load_state(storage, &context, generation) + .await + .expect("load state") + .expect("state"); + let scope = CursorScope::new( + context.id.clone(), + context.generation, + 2_000_000_200, + state.store_generation, + state.content_generation, + ) + .expect("scope"); + + let cursor_for = |scope: CursorScope| { + TodayCursor::encode(&scope, TodayCursorPosition { rank }) + .expect("cursor") + .as_str() + .to_owned() + }; + let mut wrong_context = scope.clone(); + wrong_context.context_id = "elsewhere".into(); + assert!(matches!( + runtime + .phase1_today_page( + &context, + TodayPageRequest::after(1, cursor_for(wrong_context)) + ) + .await, + Err(TodayError::Cursor(CursorError::ContextMismatch)) + )); + let mut wrong_context_generation = scope.clone(); + wrong_context_generation.context_generation += 1; + assert!(matches!( + runtime + .phase1_today_page( + &context, + TodayPageRequest::after(1, cursor_for(wrong_context_generation)), + ) + .await, + Err(TodayError::Cursor(CursorError::ContextMismatch)) + )); + assert!(matches!( + runtime + .phase1_today_page( + &context, + TodayPageRequest { + limit: 1, + as_of: Some(scope.as_of + 1), + cursor: Some(cursor_for(scope.clone())), + }, + ) + .await, + Err(TodayError::Cursor(CursorError::SnapshotMismatch)) + )); + let mut stale = scope.clone(); + stale.store_generation = [9; 32]; + assert!(matches!( + runtime + .phase1_today_page(&context, TodayPageRequest::after(1, cursor_for(stale))) + .await, + Err(TodayError::Cursor(CursorError::Stale)) + )); + let mut missing = scope.clone(); + missing.projection_generation = missing.projection_generation.wrapping_add(1).max(1); + assert!(matches!( + runtime + .phase1_today_page(&context, TodayPageRequest::after(1, cursor_for(missing))) + .await, + Err(TodayError::SnapshotMissing) + )); + + let snapshot = frozen_snapshot(&state, &context, scope.as_of).expect("snapshot"); + assert!(validate_snapshot(&snapshot, &scope).is_ok()); + for invalid in [ + { + let mut value = snapshot.clone(); + value.schema_version += 1; + value + }, + { + let mut value = snapshot.clone(); + value.context_id = "other".into(); + value + }, + { + let mut value = snapshot.clone(); + value.context_generation += 1; + value + }, + { + let mut value = snapshot.clone(); + value.as_of += 1; + value + }, + { + let mut value = snapshot.clone(); + value.store_generation = [8; 32]; + value + }, + { + let mut value = snapshot.clone(); + value.projection_generation = value.projection_generation.wrapping_add(1); + value + }, + ] { + assert!(matches!( + validate_snapshot(&invalid, &scope), + Err(TodayError::CorruptProjection) + )); + } + let mut absent_rank = rank; + absent_rank.card_id = CardId::parse(&"f".repeat(64)).expect("absent card id"); + assert!(matches!( + page_from_snapshot(snapshot.clone(), scope.clone(), Some(absent_rank), 1), + Err(TodayError::CursorPositionMissing) + )); + + for invalid in [ + { + let mut value = state.clone(); + value.schema_version += 1; + value + }, + { + let mut value = state.clone(); + value.content_generation = 0; + value + }, + { + let mut value = state.clone(); + value.source_events += 1; + value + }, + ] { + assert!(matches!( + decode_state(&encode(&invalid).expect("encode invalid state")), + Err(TodayError::CorruptProjection) + )); + } + let mut invalid_snapshot = snapshot.clone(); + invalid_snapshot.schema_version += 1; + assert!(matches!( + decode_snapshot(&encode(&invalid_snapshot).expect("encode invalid snapshot")), + Err(TodayError::CorruptProjection) + )); + assert!(matches!( + decode_state(b"not-json"), + Err(TodayError::CorruptProjection) + )); + + for overlay in [ + LocalAuthorOverlay { + operation_id: String::new(), + state: "queued".into(), + }, + LocalAuthorOverlay { + operation_id: "x".repeat(257), + state: "queued".into(), + }, + LocalAuthorOverlay { + operation_id: "operation".into(), + state: String::new(), + }, + LocalAuthorOverlay { + operation_id: "operation".into(), + state: "x".repeat(97), + }, + LocalAuthorOverlay { + operation_id: "operation".into(), + state: "bad\nstate".into(), + }, + ] { + assert!(matches!( + runtime + .phase1_set_local_author_overlay(&context, card_id, Some(overlay)) + .await, + Err(TodayError::InvalidRequest) + )); + } + let other_keys = Keys::generate(); + runtime.store_public_key = Some( + radroots_identity::PublicKey::from_hex(&other_keys.public_key().to_string()) + .expect("other public key"), + ); + assert!(matches!( + runtime + .phase1_me(&context, &keys().public_key().to_string(), 1) + .await, + Err(TodayError::InvalidRequest) + )); + assert!(matches!( + runtime + .phase1_set_local_author_overlay( + &context, + card_id, + Some(LocalAuthorOverlay { + operation_id: "operation".into(), + state: "queued".into(), + }), + ) + .await, + Err(TodayError::InvalidRequest) + )); + runtime.store_public_key = None; + runtime + .phase1_set_local_author_overlay( + &context, + card_id, + Some(LocalAuthorOverlay { + operation_id: "operation".into(), + state: "queued".into(), + }), + ) + .await + .expect("set overlay"); + runtime + .phase1_set_local_author_overlay(&context, card_id, None) + .await + .expect("remove overlay"); + assert_eq!( + runtime + .phase1_search(&context, "guarded", 1, 2_000_000_200) + .await + .expect("card-limited search") + .len(), + 1 + ); + + let mut event_state = state.clone(); + event_state.cards[0].card.card_type = TodayCardType::Event; + event_state.cards[0].card.event_start = Some(100); + event_state.cards[0].card.event_end = Some(200); + event_state.cards[0].card.effective_at = 100; + assert_eq!( + ranked_cards(&event_state, &context, 150).expect("live event")[0] + .card + .lifecycle, + CardLifecycleState::Active + ); + assert_eq!( + ranked_cards(&event_state, &context, 200).expect("past event")[0] + .card + .lifecycle, + CardLifecycleState::Past + ); + + let root = admitted(&note); + assert!(matches!( + profile_summary(&root), + Err(TodayError::CorruptProjection) + )); + assert!(matches!( + reply_entry(&root), + Err(TodayError::CorruptProjection) + )); + assert!(comment_entry(&root).is_none()); + assert_eq!(tag_value(&[Vec::new()], &["x"]), None); + assert_eq!(tag_value(&[vec!["x".into()]], &["x"]), None); + assert_eq!(blossom_digest("not-a-url"), None); + assert_eq!(blossom_digest("https://blob.example/short"), None); + assert_eq!( + blossom_digest(&format!("https://blob.example/{}", "A".repeat(64))), + None + ); + + let tags = locality_tags(vec![ + Vec::new(), + vec!["x".into(), "ignored".into()], + vec!["g".into()], + vec!["location".into(), " ".into()], + vec!["g".into(), "u10hr".into()], + ]); + assert_eq!(tags.len(), 1); + assert_eq!( + locality_evidence(Some("u10"), &tags), + LocalityEvidence::Match + ); + assert_eq!( + locality_evidence(Some("u10hr7"), &tags), + LocalityEvidence::Match + ); + assert_eq!( + locality_evidence(Some("other"), &tags), + LocalityEvidence::Nonmatch + ); + assert_eq!( + locality_evidence(Some("selected"), &[]), + LocalityEvidence::Missing + ); + + let mut fields = BTreeMap::new(); + fields.insert("value".into(), serde_json::json!(1)); + assert_eq!(typed_profile_extra(&fields, "missing"), None); + assert_eq!(typed_profile_extra(&fields, "value"), None); + for value in [String::new(), "x".repeat(2_049), "bad\nvalue".into()] { + fields.insert("value".into(), serde_json::Value::String(value)); + assert_eq!(typed_profile_extra(&fields, "value"), None); + } + } + + #[test] + fn malformed_cursor_and_request_bounds_fail_closed() { + assert!(matches!( + TodayCursor::scope("bad"), + Err(CursorError::Malformed) + )); + assert!(!valid_public_key("A".repeat(64).as_str())); + assert_eq!(TODAY_PAGE_LIMIT_MAX, 100); + assert_eq!(TODAY_SEARCH_LIMIT_MAX, 100); + } +}