diff --git a/README.md b/README.md index 5e36b5a..9ea8f04 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,9 @@ Approach -------- Feder separates ActivityPub protocol logic from platform execution. The core -should contain deterministic protocol decisions. Runtimes provide -platform-specific pieces such as networking, storage, clocks, scheduling, and -execution. +should contain federation behavior such as inbox/outbox state, delivery +decisions, and protocol-level rules. Runtimes provide platform-specific pieces +such as networking, storage, clocks, scheduling, and execution. The first target is a Linux proof of concept for a small single-user ActivityPub server. Future runtimes may explore more constrained environments. diff --git a/crates/feder-core/README.md b/crates/feder-core/README.md deleted file mode 100644 index c3d7005..0000000 --- a/crates/feder-core/README.md +++ /dev/null @@ -1,17 +0,0 @@ -Feder Core -========== - -Pure ActivityPub decision logic for Feder. - -This crate does not perform HTTP, database, filesystem, clock, or random ID -operations. A runtime provides the state and context needed for one decision, -then applies the returned decision itself. - - -Core decisions --------------- - -~~~~ text -stored state + policy + context + ActivityPub input - -> state changes + effects -~~~~ diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 5cf923a..3e59dfa 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -18,86 +18,227 @@ extern crate alloc; -use alloc::vec::Vec; +use alloc::{string::String, vec::Vec}; pub use feder_vocab as vocab; -/// Portable core decision logic. -#[derive(Debug, Default)] -pub struct FederCore; +/// Portable core state and decision logic. +#[derive(Debug)] +pub struct FederCore { + state: FederState, +} impl FederCore { #[must_use] - pub fn new() -> Self { - Self - } - - pub fn decide_received_follow( - &self, - follow: vocab::Follow, - state: ReceivedFollowState, - policy: FollowPolicyDecision, - context: DecisionContext, - ) -> Result { - if state.already_processed { - return Ok(Decision::none()); + pub fn new(config: FederConfig) -> Self { + Self { + state: FederState::new(config), + } + } + + #[must_use] + pub fn state(&self) -> &FederState { + &self.state + } + + /// Handle one core input and return runtime actions to perform later. + /// + /// This method intentionally performs no I/O. Returned actions describe + /// work for a runtime or test harness to perform later. + #[must_use] + pub fn handle(&mut self, input: Input) -> HandleResult { + match input { + Input::ReceivedFollow(input) => { + let actions = self.state.record_follow(input); + HandleResult::new(actions) + } + Input::UserCreateNote(input) => { + let actions = self.state.record_created_note(input); + HandleResult::new(actions) + } + } + } +} + +/// Runtime-provided configuration for portable core state. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FederConfig { + pub local_actor: vocab::Actor, +} + +impl FederConfig { + #[must_use] + pub fn new(local_actor: vocab::Actor) -> Self { + Self { local_actor } + } +} + +/// In-memory state used by portable core flows. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FederState { + local_actor: vocab::Actor, + followers: Vec, + delivery_targets: Vec, + objects: Vec, + activities: Vec, +} + +impl FederState { + #[must_use] + pub fn new(config: FederConfig) -> Self { + Self { + local_actor: config.local_actor, + followers: Vec::new(), + delivery_targets: Vec::new(), + objects: Vec::new(), + activities: Vec::new(), } + } + #[must_use] + pub fn local_actor(&self) -> &vocab::Actor { + &self.local_actor + } + + #[must_use] + pub fn followers(&self) -> &[Follower] { + &self.followers + } + + #[must_use] + /// Delivery targets known from embedded actor data. + /// + /// ID-only followers are tracked in `followers`, but they do not produce a + /// delivery target until a runtime or later core flow resolves actor data. + pub fn delivery_targets(&self) -> &[DeliveryTarget] { + &self.delivery_targets + } + + #[must_use] + pub fn objects(&self) -> &[Object] { + &self.objects + } + + #[must_use] + pub fn activities(&self) -> &[Activity] { + &self.activities + } + + fn record_follow(&mut self, input: ReceivedFollow) -> Vec { + let follow = input.follow; let Some(following) = reference_id(&follow.object) else { - return Ok(Decision::none()); + return Vec::new(); }; - if following != &context.local_actor { - return Ok(Decision::none()); + if following != &self.local_actor.id { + return Vec::new(); } let Some(follower) = reference_id(&follow.actor).cloned() else { - return Ok(Decision::none()); + return Vec::new(); }; - match policy { - FollowPolicyDecision::Reject | FollowPolicyDecision::RequireManualApproval => { - return Ok(Decision::none()); + let relation = Follower { + follower: follower.clone(), + following: following.clone(), + }; + let mut actions = Vec::new(); + + if !self.followers.contains(&relation) { + self.followers.push(relation.clone()); + + actions.push(Action::StoreFollower(StoreFollower { + follower: follow.actor.clone(), + following: follow.object.clone(), + })); + } + + let mut inbox = self + .delivery_targets + .iter() + .find(|target| target.actor == follower) + .map(|target| target.inbox.clone()); + + if let vocab::Reference::Object(actor) = &follow.actor { + let target = DeliveryTarget { + actor: follower, + inbox: actor.inbox.clone(), + }; + let mut should_store_target = false; + + if let Some(existing) = self + .delivery_targets + .iter_mut() + .find(|existing| existing.actor == target.actor) + { + if existing.inbox != target.inbox { + existing.inbox = target.inbox.clone(); + should_store_target = true; + } + } else { + self.delivery_targets.push(target.clone()); + should_store_target = true; } - FollowPolicyDecision::Accept => {} + + if should_store_target { + actions.push(Action::StoreDeliveryTarget(StoreDeliveryTarget { target })); + } + + inbox = Some(actor.inbox.clone()); } - let remote_actor = state.remote_actor.ok_or(CoreError::MissingRemoteActor)?; - let inbox = remote_actor - .shared_inbox - .clone() - .or_else(|| remote_actor.inbox.clone()) - .ok_or(CoreError::MissingInbox)?; - - let accept = vocab::Accept::new( - context.accept_id, - vocab::Reference::id(context.local_actor.clone()), - vocab::Reference::object(follow.clone()), - ); - let accept = Activity::Accept(accept); - - let mut state_changes = Vec::from([StateChange::RecordProcessedActivity { - activity_id: follow.id.clone(), - }]); - - state_changes.push(StateChange::AddFollower { - local_actor: context.local_actor, - remote_actor: follower, - inbox: remote_actor.inbox, - shared_inbox: remote_actor.shared_inbox, - }); - - state_changes.push(StateChange::StoreActivity { - activity: accept.clone(), - }); - - Ok(Decision { - state_changes, - effects: Vec::from([Effect::PlanDelivery(PlannedDelivery { - activity: accept, + if let Some(inbox) = inbox { + let accept = vocab::Accept::new( + input.accept_id, + vocab::Reference::id(self.local_actor.id.clone()), + vocab::Reference::object(follow), + ); + + actions.push(Action::SendActivity(SendActivity { + activity: Activity::Accept(accept), inbox, - })]), - }) + })); + } + + actions + } + + fn record_created_note(&mut self, input: UserCreateNote) -> Vec { + let Some(actor) = reference_id(&input.actor) else { + return Vec::new(); + }; + + if actor != &self.local_actor.id { + return Vec::new(); + } + + let actor = vocab::Reference::id(self.local_actor.id.clone()); + + let mut note = vocab::Note::new(input.note_id); + note.attributed_to = Some(actor.clone()); + note.content = Some(input.content); + note.published = input.published; + + let create = vocab::Create::new( + input.create_id, + actor, + vocab::Reference::object(note.clone()), + ); + + let object = Object::Note(note); + self.objects.push(object.clone()); + self.activities.push(Activity::CreateNote(create.clone())); + + let mut actions = Vec::from([Action::StoreObject(StoreObject { object })]); + + actions.extend(self.delivery_targets.iter().map(|target| { + Action::SendActivity(SendActivity { + activity: Activity::CreateNote(create.clone()), + inbox: target.inbox.clone(), + }) + })); + + actions } } @@ -121,104 +262,89 @@ impl HasId for vocab::Actor { } } -/// Runtime-provided stored state for deciding a received Follow. +/// Something entering the portable core from a runtime. #[derive(Clone, Debug, Eq, PartialEq)] -pub struct ReceivedFollowState { - pub already_processed: bool, - pub relationship: FollowRelationship, - pub remote_actor: Option, +#[non_exhaustive] +pub enum Input { + ReceivedFollow(ReceivedFollow), + UserCreateNote(UserCreateNote), } -/// Current stored relationship between a remote actor and the local actor. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum FollowRelationship { - NotFollowing, - Following, +/// Runtime-provided data for handling a received Follow. +/// +/// The Accept activity ID is an input so the core does not depend on clocks, +/// randomness, or platform-specific ID generation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReceivedFollow { + pub follow: vocab::Follow, + pub accept_id: vocab::Iri, } -/// Runtime-known state for a remote actor referenced by an input. +/// Runtime-provided data for creating a local note. +/// +/// IDs and timestamps are inputs so the core does not depend on clocks, +/// randomness, or platform-specific ID generation. #[derive(Clone, Debug, Eq, PartialEq)] -pub struct RemoteActorState { - pub actor_id: vocab::Iri, - pub inbox: Option, - pub shared_inbox: Option, +pub struct UserCreateNote { + pub note_id: vocab::Iri, + pub create_id: vocab::Iri, + pub actor: vocab::Reference, + pub content: String, + pub published: Option, } -/// Application policy decision for a received Follow. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum FollowPolicyDecision { - Accept, - Reject, - RequireManualApproval, +impl Input { + pub fn received_follow(follow: vocab::Follow, accept_id: vocab::Iri) -> Self { + Self::ReceivedFollow(ReceivedFollow { follow, accept_id }) + } } -/// Deterministic runtime-provided context for one core decision. #[derive(Clone, Debug, Eq, PartialEq)] -pub struct DecisionContext { - pub local_actor: vocab::Iri, - pub accept_id: vocab::Iri, +pub struct Follower { + pub follower: vocab::Iri, + pub following: vocab::Iri, } -/// Declarative result of a pure core decision. -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub struct Decision { - pub state_changes: Vec, - pub effects: Vec, +/// A known actor inbox for future delivery. +/// +/// Core records this only when an incoming object embeds enough actor data to +/// expose an inbox. It does not imply every follower has been resolved. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DeliveryTarget { + pub actor: vocab::Iri, + pub inbox: vocab::Iri, } -impl Decision { - #[must_use] - pub fn none() -> Self { - Self::default() - } - - #[must_use] - pub fn is_empty(&self) -> bool { - self.state_changes.is_empty() && self.effects.is_empty() - } +/// Something the runtime should perform after core handling. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Action { + StoreFollower(StoreFollower), + StoreDeliveryTarget(StoreDeliveryTarget), + StoreObject(StoreObject), + SendActivity(SendActivity), } -/// Durable state change a runtime should apply transactionally. #[derive(Clone, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum StateChange { - RecordProcessedActivity { - activity_id: vocab::Iri, - }, - AddFollower { - local_actor: vocab::Iri, - remote_actor: vocab::Iri, - inbox: Option, - shared_inbox: Option, - }, - StoreActivity { - activity: Activity, - }, - StoreObject { - object: Object, - }, -} - -/// External work a runtime should plan after durable state is committed. +pub struct StoreFollower { + pub follower: vocab::Reference, + pub following: vocab::Reference, +} + #[derive(Clone, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum Effect { - PlanDelivery(PlannedDelivery), +pub struct StoreDeliveryTarget { + pub target: DeliveryTarget, } -/// Delivery work to persist for a later delivery worker. #[derive(Clone, Debug, Eq, PartialEq)] -pub struct PlannedDelivery { - pub activity: Activity, - pub inbox: vocab::Iri, +pub struct StoreObject { + pub object: Object, } -/// Error raised while deciding protocol consequences. #[derive(Clone, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum CoreError { - MissingRemoteActor, - MissingInbox, +pub struct SendActivity { + pub activity: Activity, + pub inbox: vocab::Iri, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -233,3 +359,503 @@ pub enum Activity { pub enum Object { Note(vocab::Note), } + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct HandleResult { + pub actions: Vec, +} + +impl HandleResult { + #[must_use] + pub fn new(actions: Vec) -> Self { + Self { actions } + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.actions.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::format; + use alloc::string::ToString; + + fn iri(value: &str) -> vocab::Iri { + value.parse().expect("valid test IRI") + } + + fn actor(id: &str) -> vocab::Actor { + vocab::Actor::person( + iri(id), + iri(&format!("{id}/inbox")), + iri(&format!("{id}/outbox")), + ) + } + + fn core() -> FederCore { + FederCore::new(FederConfig::new(actor("https://example.com/users/alice"))) + } + + fn received_follow(follow: vocab::Follow, id: &str) -> Input { + Input::ReceivedFollow(ReceivedFollow { + follow, + accept_id: iri(id), + }) + } + + #[test] + fn core_is_created_with_local_actor_state() { + let core = core(); + + assert_eq!( + core.state().local_actor().id, + iri("https://example.com/users/alice") + ); + assert!(core.state().followers().is_empty()); + assert!(core.state().delivery_targets().is_empty()); + assert!(core.state().objects().is_empty()); + assert!(core.state().activities().is_empty()); + } + + #[test] + fn received_follow_records_follower_and_emits_accept_actions() { + let mut core = core(); + let follow = vocab::Follow::new( + iri("https://remote.example/activities/follow/1"), + vocab::Reference::object(actor("https://remote.example/users/bob")), + vocab::Reference::id(iri("https://example.com/users/alice")), + ); + + let result = core.handle(received_follow( + follow, + "https://example.com/activities/accept/1", + )); + + assert_eq!(result.actions.len(), 3); + assert_eq!( + core.state().followers(), + &[Follower { + follower: iri("https://remote.example/users/bob"), + following: iri("https://example.com/users/alice"), + }] + ); + assert_eq!( + core.state().delivery_targets(), + &[DeliveryTarget { + actor: iri("https://remote.example/users/bob"), + inbox: iri("https://remote.example/users/bob/inbox"), + }] + ); + assert_eq!( + result.actions[0], + Action::StoreFollower(StoreFollower { + follower: vocab::Reference::object(actor("https://remote.example/users/bob")), + following: vocab::Reference::id(iri("https://example.com/users/alice")), + }) + ); + assert_eq!( + result.actions[1], + Action::StoreDeliveryTarget(StoreDeliveryTarget { + target: DeliveryTarget { + actor: iri("https://remote.example/users/bob"), + inbox: iri("https://remote.example/users/bob/inbox"), + }, + }) + ); + + let Action::SendActivity(send) = &result.actions[2] else { + panic!("expected SendActivity action"); + }; + assert_eq!(send.inbox, iri("https://remote.example/users/bob/inbox")); + + let Activity::Accept(accept) = &send.activity else { + panic!("expected Accept activity"); + }; + assert_eq!(accept.id, iri("https://example.com/activities/accept/1")); + assert_eq!( + accept.actor, + vocab::Reference::id(iri("https://example.com/users/alice")) + ); + let vocab::Reference::Object(accepted_follow) = &accept.object else { + panic!("expected embedded Follow object"); + }; + assert_eq!( + accepted_follow.id, + iri("https://remote.example/activities/follow/1") + ); + } + + #[test] + fn received_follow_updates_existing_delivery_target_by_actor() { + let mut core = core(); + let first_follow = vocab::Follow::new( + iri("https://remote.example/activities/follow/1"), + vocab::Reference::object(actor("https://remote.example/users/bob")), + vocab::Reference::id(iri("https://example.com/users/alice")), + ); + + let mut updated_actor = actor("https://remote.example/users/bob"); + updated_actor.inbox = iri("https://remote.example/inboxes/bob"); + let second_follow = vocab::Follow::new( + iri("https://remote.example/activities/follow/2"), + vocab::Reference::object(updated_actor), + vocab::Reference::id(iri("https://example.com/users/alice")), + ); + + let first_result = core.handle(received_follow( + first_follow, + "https://example.com/activities/accept/1", + )); + let second_result = core.handle(received_follow( + second_follow, + "https://example.com/activities/accept/2", + )); + + assert_eq!(first_result.actions.len(), 3); + assert_eq!(second_result.actions.len(), 2); + assert_eq!( + second_result.actions[0], + Action::StoreDeliveryTarget(StoreDeliveryTarget { + target: DeliveryTarget { + actor: iri("https://remote.example/users/bob"), + inbox: iri("https://remote.example/inboxes/bob"), + }, + }) + ); + + let Action::SendActivity(send) = &second_result.actions[1] else { + panic!("expected SendActivity action"); + }; + assert_eq!(send.inbox, iri("https://remote.example/inboxes/bob")); + + let Activity::Accept(accept) = &send.activity else { + panic!("expected Accept activity"); + }; + assert_eq!(accept.id, iri("https://example.com/activities/accept/2")); + + assert_eq!( + core.state().followers(), + &[Follower { + follower: iri("https://remote.example/users/bob"), + following: iri("https://example.com/users/alice"), + }] + ); + assert_eq!( + core.state().delivery_targets(), + &[DeliveryTarget { + actor: iri("https://remote.example/users/bob"), + inbox: iri("https://remote.example/inboxes/bob"), + }] + ); + } + + #[test] + fn received_follow_with_actor_id_records_follower_without_delivery_target() { + let mut core = core(); + let follow = vocab::Follow::new( + iri("https://remote.example/activities/follow/1"), + vocab::Reference::id(iri("https://remote.example/users/bob")), + vocab::Reference::id(iri("https://example.com/users/alice")), + ); + + let result = core.handle(received_follow( + follow, + "https://example.com/activities/accept/1", + )); + + assert_eq!( + result.actions, + Vec::from([Action::StoreFollower(StoreFollower { + follower: vocab::Reference::id(iri("https://remote.example/users/bob")), + following: vocab::Reference::id(iri("https://example.com/users/alice")), + })]) + ); + assert_eq!( + core.state().followers(), + &[Follower { + follower: iri("https://remote.example/users/bob"), + following: iri("https://example.com/users/alice"), + }] + ); + assert!(core.state().delivery_targets().is_empty()); + } + + #[test] + fn received_follow_for_other_actor_is_ignored() { + let mut core = core(); + let follow = vocab::Follow::new( + iri("https://remote.example/activities/follow/1"), + vocab::Reference::object(actor("https://remote.example/users/bob")), + vocab::Reference::id(iri("https://example.com/users/other")), + ); + + let result = core.handle(received_follow( + follow, + "https://example.com/activities/accept/1", + )); + + assert!(result.is_empty()); + assert!(core.state().followers().is_empty()); + assert!(core.state().delivery_targets().is_empty()); + } + + #[test] + fn user_create_note_records_created_object_and_emits_store_action() { + let input = UserCreateNote { + note_id: iri("https://example.com/notes/1"), + create_id: iri("https://example.com/activities/create/1"), + actor: vocab::Reference::id(iri("https://example.com/users/alice")), + content: "Hello from Feder.".to_string(), + published: Some("2026-06-10T00:00:00Z".to_string()), + }; + + let mut core = core(); + let result = core.handle(Input::UserCreateNote(input)); + + assert_eq!(result.actions.len(), 1); + assert_eq!(core.state().objects().len(), 1); + assert_eq!(core.state().activities().len(), 1); + + let Object::Note(note) = &core.state().objects()[0]; + assert_eq!(note.id, iri("https://example.com/notes/1")); + assert_eq!( + note.attributed_to, + Some(vocab::Reference::id(iri("https://example.com/users/alice"))) + ); + assert_eq!(note.content, Some("Hello from Feder.".to_string())); + assert_eq!(note.published, Some("2026-06-10T00:00:00Z".to_string())); + + match &core.state().activities()[0] { + Activity::CreateNote(create) => { + assert_eq!(create.id, iri("https://example.com/activities/create/1")); + assert_eq!( + create.actor, + vocab::Reference::id(iri("https://example.com/users/alice")) + ); + } + Activity::Accept(_) => panic!("expected Create activity"), + } + + assert_eq!( + result.actions[0], + Action::StoreObject(StoreObject { + object: Object::Note(note.clone()), + }) + ); + } + + #[test] + fn user_create_note_emits_create_activity_for_known_delivery_targets() { + let mut core = core(); + let follow = vocab::Follow::new( + iri("https://remote.example/activities/follow/1"), + vocab::Reference::object(actor("https://remote.example/users/bob")), + vocab::Reference::id(iri("https://example.com/users/alice")), + ); + let _ = core.handle(received_follow( + follow, + "https://example.com/activities/accept/1", + )); + + let input = UserCreateNote { + note_id: iri("https://example.com/notes/1"), + create_id: iri("https://example.com/activities/create/1"), + actor: vocab::Reference::id(iri("https://example.com/users/alice")), + content: "Hello from Feder.".to_string(), + published: Some("2026-06-10T00:00:00Z".to_string()), + }; + + let result = core.handle(Input::UserCreateNote(input)); + + assert_eq!(result.actions.len(), 2); + let Action::StoreObject(store) = &result.actions[0] else { + panic!("expected StoreObject action"); + }; + let Object::Note(note) = &store.object; + assert_eq!(note.id, iri("https://example.com/notes/1")); + + let Action::SendActivity(send) = &result.actions[1] else { + panic!("expected SendActivity action"); + }; + assert_eq!(send.inbox, iri("https://remote.example/users/bob/inbox")); + + let Activity::CreateNote(create) = &send.activity else { + panic!("expected Create activity"); + }; + assert_eq!(create.id, iri("https://example.com/activities/create/1")); + assert_eq!( + create.actor, + vocab::Reference::id(iri("https://example.com/users/alice")) + ); + let vocab::Reference::Object(created_note) = &create.object else { + panic!("expected embedded Note object"); + }; + assert_eq!(created_note.id, iri("https://example.com/notes/1")); + } + + #[test] + fn user_create_note_emits_create_activity_for_each_known_delivery_target() { + let mut core = core(); + for (index, follower) in [ + "https://remote.example/users/bob", + "https://another.example/users/carol", + ] + .into_iter() + .enumerate() + { + let follow = vocab::Follow::new( + iri(&format!("https://example.com/activities/follow/{index}")), + vocab::Reference::object(actor(follower)), + vocab::Reference::id(iri("https://example.com/users/alice")), + ); + let _ = core.handle(received_follow( + follow, + &format!("https://example.com/activities/accept/{index}"), + )); + } + + let input = UserCreateNote { + note_id: iri("https://example.com/notes/1"), + create_id: iri("https://example.com/activities/create/1"), + actor: vocab::Reference::id(iri("https://example.com/users/alice")), + content: "Hello from Feder.".to_string(), + published: None, + }; + + let result = core.handle(Input::UserCreateNote(input)); + + assert_eq!(result.actions.len(), 3); + assert!(matches!(result.actions[0], Action::StoreObject(_))); + + let expected_inboxes = [ + iri("https://remote.example/users/bob/inbox"), + iri("https://another.example/users/carol/inbox"), + ]; + + for (action, expected_inbox) in result.actions[1..].iter().zip(expected_inboxes) { + let Action::SendActivity(send) = action else { + panic!("expected SendActivity action"); + }; + assert_eq!(send.inbox, expected_inbox); + assert!(matches!(send.activity, Activity::CreateNote(_))); + } + } + + #[test] + fn mocked_core_flow_accepts_follow_then_delivers_created_note() { + let mut core = core(); + let follow = vocab::Follow::new( + iri("https://remote.example/activities/follow/1"), + vocab::Reference::object(actor("https://remote.example/users/bob")), + vocab::Reference::id(iri("https://example.com/users/alice")), + ); + + let follow_result = core.handle(received_follow( + follow, + "https://example.com/activities/accept/1", + )); + + assert_eq!(follow_result.actions.len(), 3); + assert!(matches!(follow_result.actions[0], Action::StoreFollower(_))); + assert!(matches!( + follow_result.actions[1], + Action::StoreDeliveryTarget(_) + )); + let Action::SendActivity(accept_delivery) = &follow_result.actions[2] else { + panic!("expected Accept delivery action"); + }; + assert_eq!( + accept_delivery.inbox, + iri("https://remote.example/users/bob/inbox") + ); + assert!(matches!(accept_delivery.activity, Activity::Accept(_))); + + let create_result = core.handle(Input::UserCreateNote(UserCreateNote { + note_id: iri("https://example.com/notes/1"), + create_id: iri("https://example.com/activities/create/1"), + actor: vocab::Reference::id(iri("https://example.com/users/alice")), + content: "Hello from Feder.".to_string(), + published: Some("2026-06-10T00:00:00Z".to_string()), + })); + + assert_eq!(create_result.actions.len(), 2); + assert!(matches!(create_result.actions[0], Action::StoreObject(_))); + let Action::SendActivity(create_delivery) = &create_result.actions[1] else { + panic!("expected Create delivery action"); + }; + assert_eq!( + create_delivery.inbox, + iri("https://remote.example/users/bob/inbox") + ); + assert!(matches!(create_delivery.activity, Activity::CreateNote(_))); + + assert_eq!(core.state().followers().len(), 1); + assert_eq!(core.state().delivery_targets().len(), 1); + assert_eq!(core.state().objects().len(), 1); + assert_eq!(core.state().activities().len(), 1); + } + + #[test] + fn user_create_note_normalizes_embedded_local_actor_to_local_actor_id() { + let mut supplied_actor = actor("https://example.com/users/alice"); + supplied_actor.inbox = iri("https://untrusted.example/inbox"); + + let input = UserCreateNote { + note_id: iri("https://example.com/notes/1"), + create_id: iri("https://example.com/activities/create/1"), + actor: vocab::Reference::object(supplied_actor), + content: "Hello from Feder.".to_string(), + published: None, + }; + + let mut core = core(); + let result = core.handle(Input::UserCreateNote(input)); + + assert_eq!(result.actions.len(), 1); + + let Object::Note(note) = &core.state().objects()[0]; + assert_eq!( + note.attributed_to, + Some(vocab::Reference::id(iri("https://example.com/users/alice"))) + ); + + let Activity::CreateNote(create) = &core.state().activities()[0] else { + panic!("expected Create activity"); + }; + assert_eq!( + create.actor, + vocab::Reference::id(iri("https://example.com/users/alice")) + ); + } + + #[test] + fn user_create_note_for_non_local_actor_is_ignored() { + let input = UserCreateNote { + note_id: iri("https://remote.example/notes/1"), + create_id: iri("https://remote.example/activities/create/1"), + actor: vocab::Reference::id(iri("https://remote.example/users/bob")), + content: "Hello from elsewhere.".to_string(), + published: Some("2026-06-10T00:00:00Z".to_string()), + }; + + let mut core = core(); + let result = core.handle(Input::UserCreateNote(input)); + + assert!(result.is_empty()); + assert!(core.state().objects().is_empty()); + assert!(core.state().activities().is_empty()); + } + + #[test] + fn handle_result_wraps_action_lists() { + let result = HandleResult::new(Vec::from([Action::StoreFollower(StoreFollower { + follower: vocab::Reference::id(iri("https://remote.example/users/bob")), + following: vocab::Reference::id(iri("https://example.com/users/alice")), + })])); + + assert_eq!(result.actions.len(), 1); + } +} diff --git a/crates/feder-core/tests/received_follow_decider.rs b/crates/feder-core/tests/received_follow_decider.rs deleted file mode 100644 index 18ada96..0000000 --- a/crates/feder-core/tests/received_follow_decider.rs +++ /dev/null @@ -1,242 +0,0 @@ -use feder_core::vocab; -use feder_core::{ - Activity, CoreError, DecisionContext, Effect, FederCore, FollowPolicyDecision, - FollowRelationship, PlannedDelivery, ReceivedFollowState, RemoteActorState, StateChange, -}; - -fn iri(value: &str) -> vocab::Iri { - value.parse().expect("valid test IRI") -} - -fn core() -> FederCore { - FederCore::new() -} - -fn follow() -> vocab::Follow { - vocab::Follow::new( - iri("https://remote.example/activities/follow/1"), - vocab::Reference::id(iri("https://remote.example/users/bob")), - vocab::Reference::id(iri("https://example.com/users/alice")), - ) -} - -fn received_follow_state( - relationship: FollowRelationship, - inbox: Option<&str>, - shared_inbox: Option<&str>, -) -> ReceivedFollowState { - ReceivedFollowState { - already_processed: false, - relationship, - remote_actor: Some(RemoteActorState { - actor_id: iri("https://remote.example/users/bob"), - inbox: inbox.map(iri), - shared_inbox: shared_inbox.map(iri), - }), - } -} - -fn decision_context() -> DecisionContext { - DecisionContext { - local_actor: iri("https://example.com/users/alice"), - accept_id: iri("https://example.com/activities/accept/1"), - } -} - -#[test] -fn accepts_new_follower() { - let follow = follow(); - let decision = core() - .decide_received_follow( - follow.clone(), - received_follow_state( - FollowRelationship::NotFollowing, - Some("https://remote.example/users/bob/inbox"), - None, - ), - FollowPolicyDecision::Accept, - decision_context(), - ) - .expect("follow decision succeeds"); - - assert_eq!( - decision.state_changes[0], - StateChange::RecordProcessedActivity { - activity_id: iri("https://remote.example/activities/follow/1") - } - ); - assert_eq!( - decision.state_changes[1], - StateChange::AddFollower { - local_actor: iri("https://example.com/users/alice"), - remote_actor: iri("https://remote.example/users/bob"), - inbox: Some(iri("https://remote.example/users/bob/inbox")), - shared_inbox: None, - } - ); - - let accept = match &decision.state_changes[2] { - StateChange::StoreActivity { - activity: Activity::Accept(accept), - } => accept, - _ => panic!("expected stored Accept activity"), - }; - assert_eq!(accept.id, iri("https://example.com/activities/accept/1")); - assert_eq!( - accept.actor, - vocab::Reference::id(iri("https://example.com/users/alice")) - ); - assert_eq!(accept.object, vocab::Reference::object(follow)); - - assert_eq!( - decision.effects, - [Effect::PlanDelivery(PlannedDelivery { - activity: Activity::Accept(accept.clone()), - inbox: iri("https://remote.example/users/bob/inbox"), - })] - ); -} - -#[test] -fn uses_shared_inbox_for_delivery() { - let decision = core() - .decide_received_follow( - follow(), - received_follow_state( - FollowRelationship::NotFollowing, - Some("https://remote.example/users/bob/inbox"), - Some("https://remote.example/inbox"), - ), - FollowPolicyDecision::Accept, - decision_context(), - ) - .expect("follow decision succeeds"); - - match &decision.effects[0] { - Effect::PlanDelivery(delivery) => { - assert_eq!(delivery.inbox, iri("https://remote.example/inbox")); - } - _ => panic!("expected planned delivery"), - } -} - -#[test] -fn already_processed_activity_is_idempotent() { - let mut state = received_follow_state( - FollowRelationship::NotFollowing, - Some("https://remote.example/users/bob/inbox"), - None, - ); - state.already_processed = true; - - let decision = core() - .decide_received_follow( - follow(), - state, - FollowPolicyDecision::Accept, - decision_context(), - ) - .expect("follow decision succeeds"); - - assert!(decision.is_empty()); -} - -#[test] -fn existing_follower_endpoint_metadata_is_refreshed() { - let decision = core() - .decide_received_follow( - follow(), - received_follow_state( - FollowRelationship::Following, - Some("https://remote.example/users/bob/new-inbox"), - Some("https://remote.example/shared-inbox"), - ), - FollowPolicyDecision::Accept, - decision_context(), - ) - .expect("follow decision succeeds"); - - assert_eq!( - decision.state_changes[1], - StateChange::AddFollower { - local_actor: iri("https://example.com/users/alice"), - remote_actor: iri("https://remote.example/users/bob"), - inbox: Some(iri("https://remote.example/users/bob/new-inbox")), - shared_inbox: Some(iri("https://remote.example/shared-inbox")), - } - ); - assert_eq!(decision.effects.len(), 1); -} - -#[test] -fn existing_follower_can_refresh_to_shared_inbox_only() { - let decision = core() - .decide_received_follow( - follow(), - received_follow_state( - FollowRelationship::Following, - None, - Some("https://remote.example/shared-inbox"), - ), - FollowPolicyDecision::Accept, - decision_context(), - ) - .expect("follow decision succeeds"); - - assert_eq!( - decision.state_changes[1], - StateChange::AddFollower { - local_actor: iri("https://example.com/users/alice"), - remote_actor: iri("https://remote.example/users/bob"), - inbox: None, - shared_inbox: Some(iri("https://remote.example/shared-inbox")), - } - ); - assert_eq!( - decision.effects, - [Effect::PlanDelivery(PlannedDelivery { - activity: match &decision.state_changes[2] { - StateChange::StoreActivity { activity } => activity.clone(), - _ => panic!("expected stored Accept activity"), - }, - inbox: iri("https://remote.example/shared-inbox"), - })] - ); -} - -#[test] -fn missing_inbox_returns_error() { - let err = core() - .decide_received_follow( - follow(), - received_follow_state(FollowRelationship::NotFollowing, None, None), - FollowPolicyDecision::Accept, - decision_context(), - ) - .expect_err("missing inbox should fail"); - - assert_eq!(err, CoreError::MissingInbox); -} - -#[test] -fn non_accept_policy_has_no_protocol_side_effects() { - for policy in [ - FollowPolicyDecision::Reject, - FollowPolicyDecision::RequireManualApproval, - ] { - let decision = core() - .decide_received_follow( - follow(), - received_follow_state( - FollowRelationship::NotFollowing, - Some("https://remote.example/users/bob/inbox"), - None, - ), - policy, - decision_context(), - ) - .expect("follow decision succeeds"); - - assert!(decision.is_empty()); - } -} diff --git a/crates/feder-runtime-server/README.md b/crates/feder-runtime-server/README.md index a43f32d..d14408a 100644 --- a/crates/feder-runtime-server/README.md +++ b/crates/feder-runtime-server/README.md @@ -9,10 +9,10 @@ It provides a health check endpoint, WebFinger discovery, and a local actor route. The caller chooses concrete bind addresses, actor IRIs, usernames, and handle hosts. -ActivityPub inbox handling for supported Follow activities is included. For a -received Follow, the runtime parses the request, loads stored Follow state, -asks `feder-core` for a decision, and applies the returned state changes and -queued delivery work in one SQLite transaction. +ActivityPub inbox handling for supported Follow activities is included. The +runtime can use in-memory storage for tests and examples, or file-backed SQLite +storage for persisted follower state. Signature verification and delivery are +intentionally left to later issues. Example diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs index 53bbbdc..67d8181 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -22,10 +22,12 @@ use crate::webfinger::webfinger; use crate::{actor::actor, inbox::inbox}; use axum::routing::post; use axum::{Router, extract::DefaultBodyLimit, http::StatusCode, routing::get}; +use feder_core::{FederConfig, FederCore}; use feder_vocab::Actor; #[derive(Clone)] pub struct AppState { + pub core: Arc>, pub store: Arc>, pub local_actor: Actor, pub username: String, @@ -39,12 +41,14 @@ impl AppState { actor.preferred_username = Some(config.username.clone()); actor.name = Some(config.username.clone()); + let core = FederCore::new(FederConfig::new(actor.clone())); let store = match &config.storage { StorageConfig::InMemory => SqliteStore::open_in_memory()?, StorageConfig::Sqlite { path } => SqliteStore::open(path)?, }; Ok(Self { + core: Arc::new(Mutex::new(core)), store: Arc::new(Mutex::new(store)), local_actor: actor, username: config.username, diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index 0e0a128..83d9e14 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -20,7 +20,7 @@ use axum::{ response::{IntoResponse, Response}, }; -use feder_core::{CoreError, DecisionContext, FederCore, FollowPolicyDecision}; +use feder_core::Input; use feder_vocab::Follow; use serde_json::{Value, from_slice, from_value}; @@ -57,13 +57,6 @@ fn verify_inbox_request(app_state: &AppState, _req: &InboxRequest) -> Result<(), } } -fn status_for_core_error(error: CoreError) -> StatusCode { - match error { - CoreError::MissingRemoteActor | CoreError::MissingInbox => StatusCode::BAD_REQUEST, - _ => StatusCode::INTERNAL_SERVER_ERROR, - } -} - pub async fn inbox( State(app_state): State, Path(username): Path, @@ -106,26 +99,21 @@ pub async fn inbox( } let follow: Follow = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; let accept_id = accept_id_for_follow(&app_state.local_actor.id, &follow.id)?; - let context = DecisionContext { - local_actor: app_state.local_actor.id.clone(), - accept_id, + let input = Input::received_follow(follow, accept_id); + + let result = { + let mut core = app_state + .core + .lock() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + core.handle(input) }; - let mut store = app_state + app_state .store .lock() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let state = store - .load_received_follow_state(&follow, &app_state.local_actor.id) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let decision = FederCore::new() - .decide_received_follow(follow, state, FollowPolicyDecision::Accept, context) - .map_err(status_for_core_error)?; - - store - .apply_decision(&decision) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .persist_actions(&result.actions) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(StatusCode::ACCEPTED.into_response()) @@ -139,7 +127,6 @@ mod tests { http::{HeaderMap, Method, Request, StatusCode, Uri, header::CONTENT_TYPE}, response::Response, }; - use feder_vocab::{Follow, Iri}; use serde_json::json; use tower::ServiceExt; @@ -177,29 +164,6 @@ mod tests { ) } - fn follow_body_with_actor_id_only() -> Bytes { - Bytes::from( - serde_json::to_vec(&json!({ - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Follow", - "id": "https://remote.example/activities/follow-1", - "actor": "https://remote.example/users/bob", - "object": "http://127.0.0.1:3000/users/alice" - })) - .expect("serialize follow"), - ) - } - - fn local_actor_id() -> Iri { - "http://127.0.0.1:3000/users/alice" - .parse() - .expect("valid IRI") - } - - fn follow_from_body(body: &Bytes) -> Follow { - serde_json::from_slice(body).expect("deserialize follow body") - } - async fn post_inbox( app_state: AppState, username: &str, @@ -217,138 +181,36 @@ mod tests { .await } - fn assert_no_stored_followers(app_state: &AppState) { - let followers = app_state - .store - .lock() - .expect("store lock") - .list_followers(&local_actor_id()) - .expect("list followers"); - - assert!(followers.is_empty()); - } - #[tokio::test] - async fn valid_follow_is_applied_through_storage_decision() { + async fn valid_follow_reaches_core() { let app_state = AppState::from_config(test_config()).expect("build app state"); - let body = follow_body(); let response = post_inbox( app_state.clone(), "alice", activity_json_headers(), - body.clone(), + follow_body(), ) .await .expect("accepted follow"); assert_eq!(response.status(), StatusCode::ACCEPTED); - let store = app_state.store.lock().expect("store lock"); - let followers = store - .list_followers(&local_actor_id()) - .expect("list followers"); - assert_eq!(followers.len(), 1); + let core = app_state.core.lock().expect("core lock"); + assert_eq!(core.state().followers().len(), 1); assert_eq!( - followers[0].follower.as_str(), + core.state().followers()[0].follower.as_str(), "https://remote.example/users/bob" ); assert_eq!( - followers[0].following.as_str(), + core.state().followers()[0].following.as_str(), "http://127.0.0.1:3000/users/alice" ); + assert_eq!(core.state().delivery_targets().len(), 1); assert_eq!( - followers[0].inbox.as_ref().map(|iri| iri.as_str()), - Some("https://remote.example/users/bob/inbox") - ); - - let follow = follow_from_body(&body); - let state = store - .load_received_follow_state(&follow, &local_actor_id()) - .expect("load received follow state"); - - assert!(state.already_processed); - } - - #[tokio::test] - async fn duplicate_follow_is_idempotent() { - let app_state = AppState::from_config(test_config()).expect("build app state"); - let body = follow_body(); - - let first_response = post_inbox( - app_state.clone(), - "alice", - activity_json_headers(), - body.clone(), - ) - .await - .expect("accepted first follow"); - let second_response = post_inbox(app_state.clone(), "alice", activity_json_headers(), body) - .await - .expect("accepted duplicate follow"); - - assert_eq!(first_response.status(), StatusCode::ACCEPTED); - assert_eq!(second_response.status(), StatusCode::ACCEPTED); - - let followers = app_state - .store - .lock() - .expect("store lock") - .list_followers(&local_actor_id()) - .expect("list followers"); - - assert_eq!(followers.len(), 1); - } - - #[tokio::test] - async fn concurrent_duplicate_follow_is_idempotent() { - let app_state = AppState::from_config(test_config()).expect("build app state"); - let body = follow_body(); - - let (first_response, second_response) = tokio::join!( - post_inbox( - app_state.clone(), - "alice", - activity_json_headers(), - body.clone(), - ), - post_inbox(app_state.clone(), "alice", activity_json_headers(), body), - ); - - assert_eq!( - first_response.expect("accepted first follow").status(), - StatusCode::ACCEPTED - ); - assert_eq!( - second_response.expect("accepted duplicate follow").status(), - StatusCode::ACCEPTED + core.state().delivery_targets()[0].inbox.as_str(), + "https://remote.example/users/bob/inbox" ); - - let followers = app_state - .store - .lock() - .expect("store lock") - .list_followers(&local_actor_id()) - .expect("list followers"); - - assert_eq!(followers.len(), 1); - } - - #[tokio::test] - async fn rejects_follow_without_known_inbox() { - let app_state = AppState::from_config(test_config()).expect("build app state"); - - let error = post_inbox( - app_state.clone(), - "alice", - activity_json_headers(), - follow_body_with_actor_id_only(), - ) - .await - .expect_err("follow without known inbox should be rejected"); - - assert_eq!(error, StatusCode::BAD_REQUEST); - assert_no_stored_followers(&app_state); } #[tokio::test] @@ -367,7 +229,15 @@ mod tests { .expect_err("unsigned follow should be rejected"); assert_eq!(error, StatusCode::UNAUTHORIZED); - assert_no_stored_followers(&app_state); + assert!( + app_state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); } #[tokio::test] @@ -384,7 +254,15 @@ mod tests { .expect_err("unknown inbox actor should be rejected"); assert_eq!(error, StatusCode::NOT_FOUND); - assert_no_stored_followers(&app_state); + assert!( + app_state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); } #[tokio::test] @@ -398,7 +276,15 @@ mod tests { .expect_err("unsupported content type should be rejected"); assert_eq!(error, StatusCode::UNSUPPORTED_MEDIA_TYPE); - assert_no_stored_followers(&app_state); + assert!( + app_state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); } #[tokio::test] @@ -415,11 +301,19 @@ mod tests { .expect_err("malformed json should be rejected"); assert_eq!(error, StatusCode::BAD_REQUEST); - assert_no_stored_followers(&app_state); + assert!( + app_state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); } #[tokio::test] - async fn ignores_unsupported_activity_without_applying_decision() { + async fn ignores_unsupported_activity_without_mutating_core() { let app_state = AppState::from_config(test_config()).expect("build app state"); let body = Bytes::from( serde_json::to_vec(&json!({ @@ -440,7 +334,15 @@ mod tests { .expect("unsupported activity is accepted but ignored"); assert_eq!(response.status(), StatusCode::ACCEPTED); - assert_no_stored_followers(&app_state); + assert!( + app_state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); } #[tokio::test] diff --git a/crates/feder-runtime-server/src/storage/mod.rs b/crates/feder-runtime-server/src/storage/mod.rs index 6035fac..127f087 100644 --- a/crates/feder-runtime-server/src/storage/mod.rs +++ b/crates/feder-runtime-server/src/storage/mod.rs @@ -15,8 +15,7 @@ pub mod sqlite; -use feder_core::{Decision, ReceivedFollowState}; -use feder_vocab::Follow; +use feder_core::Action; use feder_vocab::Iri; pub use sqlite::SqliteStore; @@ -45,19 +44,10 @@ pub enum StoreError { #[error("invalid IRI: {0}")] InvalidIri(String), - - #[error("unsupported core decision value: {0}")] - UnsupportedDecisionValue(&'static str), } pub trait RuntimeStore { - fn apply_decision(&mut self, decision: &Decision) -> Result<(), StoreError>; - - fn load_received_follow_state( - &self, - follow: &Follow, - local_actor_id: &Iri, - ) -> Result; + fn persist_actions(&mut self, actions: &[Action]) -> Result<(), StoreError>; fn list_followers(&self, actor_id: &Iri) -> Result, StoreError>; diff --git a/crates/feder-runtime-server/src/storage/sqlite.rs b/crates/feder-runtime-server/src/storage/sqlite.rs index 900531f..1172695 100644 --- a/crates/feder-runtime-server/src/storage/sqlite.rs +++ b/crates/feder-runtime-server/src/storage/sqlite.rs @@ -15,12 +15,9 @@ use std::path::Path; -use feder_core::{ - Activity, Decision, Effect, FollowRelationship, Object, ReceivedFollowState, RemoteActorState, - StateChange, -}; +use feder_core::Action; use feder_vocab::{Actor, Iri, Reference}; -use rusqlite::{Connection, Transaction, params}; +use rusqlite::{Connection, params}; use crate::storage::{RuntimeStore, StoreError, StoredFollower, StoredRecipient}; @@ -61,27 +58,6 @@ impl SqliteStore { ); CREATE INDEX IF NOT EXISTS idx_followers_following_actor_id ON followers (following_actor_id); - - CREATE TABLE IF NOT EXISTS processed_inbox_activities ( - activity_id TEXT PRIMARY KEY - ); - - CREATE TABLE IF NOT EXISTS activities ( - activity_id TEXT PRIMARY KEY, - activity_json TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS objects ( - object_id TEXT PRIMARY KEY, - object_json TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS outgoing_deliveries ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - activity_id TEXT NOT NULL, - activity_json TEXT NOT NULL, - inbox_url TEXT NOT NULL - ); "#, )?; @@ -90,89 +66,60 @@ impl SqliteStore { } impl RuntimeStore for SqliteStore { - fn apply_decision(&mut self, decision: &Decision) -> Result<(), StoreError> { + fn persist_actions(&mut self, actions: &[Action]) -> Result<(), StoreError> { let tx = self.conn.transaction()?; - for change in &decision.state_changes { - if let StateChange::RecordProcessedActivity { activity_id } = change - && !record_processed_activity(&tx, activity_id)? - { - tx.rollback()?; - return Ok(()); + for action in actions { + match action { + Action::StoreFollower(action) => { + let follower = actor_reference_id(&action.follower); + let following = actor_reference_id(&action.following); + let inbox = actor_reference_inbox(&action.follower); + let shared_inbox = actor_reference_shared_inbox(&action.follower); + + tx.execute( + r#" + INSERT INTO followers ( + follower_actor_id, + following_actor_id, + inbox_url, + shared_inbox_url + ) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(follower_actor_id, following_actor_id) DO UPDATE SET + inbox_url = COALESCE(excluded.inbox_url, followers.inbox_url), + shared_inbox_url = COALESCE( + excluded.shared_inbox_url, + followers.shared_inbox_url + ) + "#, + params![ + follower.as_str(), + following.as_str(), + inbox.map(|inbox| inbox.as_str()), + shared_inbox.map(|shared_inbox| shared_inbox.as_str()), + ], + )?; + } + Action::StoreDeliveryTarget(action) => { + tx.execute( + r#" + UPDATE followers + SET inbox_url = ?2 + WHERE follower_actor_id = ?1 + "#, + params![action.target.actor.as_str(), action.target.inbox.as_str()], + )?; + } + _ => {} } } - for change in &decision.state_changes { - if matches!(change, StateChange::RecordProcessedActivity { .. }) { - continue; - } - - apply_state_change(&tx, change)?; - } - - for effect in &decision.effects { - apply_effect(&tx, effect)?; - } - tx.commit()?; Ok(()) } - fn load_received_follow_state( - &self, - follow: &feder_vocab::Follow, - local_actor_id: &Iri, - ) -> Result { - let already_processed = self.conn.query_row( - r#" - SELECT EXISTS( - SELECT 1 - FROM processed_inbox_activities - WHERE activity_id = ?1 - ) - "#, - [follow.id.as_str()], - |row| row.get::<_, bool>(0), - )?; - - let follower_id = actor_reference_id(&follow.actor); - let stored_follower = self.load_stored_follower(follower_id, local_actor_id)?; - let relationship = if stored_follower.is_some() { - FollowRelationship::Following - } else { - FollowRelationship::NotFollowing - }; - - let stored_inbox = stored_follower - .as_ref() - .and_then(|follower| follower.inbox.clone()); - let stored_shared_inbox = stored_follower - .as_ref() - .and_then(|follower| follower.shared_inbox.clone()); - let remote_actor = match &follow.actor { - Reference::Object(actor) => RemoteActorState { - actor_id: actor.id.clone(), - inbox: Some(actor.inbox.clone()), - shared_inbox: actor - .endpoints - .as_ref() - .and_then(|endpoints| endpoints.shared_inbox.clone()), - }, - Reference::Id(actor_id) => RemoteActorState { - actor_id: actor_id.clone(), - inbox: stored_inbox, - shared_inbox: stored_shared_inbox, - }, - }; - - Ok(ReceivedFollowState { - already_processed, - relationship, - remote_actor: Some(remote_actor), - }) - } - fn list_followers(&self, actor_id: &Iri) -> Result, StoreError> { let mut stmt = self.conn.prepare( r#" @@ -233,166 +180,27 @@ impl RuntimeStore for SqliteStore { } } -impl SqliteStore { - fn load_stored_follower( - &self, - follower: &Iri, - following: &Iri, - ) -> Result, StoreError> { - let mut stmt = self.conn.prepare( - r#" - SELECT follower_actor_id, following_actor_id, inbox_url, shared_inbox_url - FROM followers - WHERE follower_actor_id = ?1 - AND following_actor_id = ?2 - "#, - )?; - let mut rows = stmt.query(params![follower.as_str(), following.as_str()])?; - - let Some(row) = rows.next()? else { - return Ok(None); - }; - - Ok(Some(StoredFollower { - follower: parse_iri(row.get::<_, String>(0)?)?, - following: parse_iri(row.get::<_, String>(1)?)?, - inbox: parse_optional_iri(row.get::<_, Option>(2)?)?, - shared_inbox: parse_optional_iri(row.get::<_, Option>(3)?)?, - })) - } -} - -fn record_processed_activity(tx: &Transaction<'_>, activity_id: &Iri) -> Result { - let inserted = tx.execute( - "INSERT OR IGNORE INTO processed_inbox_activities (activity_id) VALUES (?1)", - [activity_id.as_str()], - )?; - - Ok(inserted > 0) -} - -fn apply_state_change(tx: &Transaction<'_>, change: &StateChange) -> Result<(), StoreError> { - match change { - StateChange::RecordProcessedActivity { .. } => {} - StateChange::AddFollower { - local_actor, - remote_actor, - inbox, - shared_inbox, - } => { - tx.execute( - r#" - INSERT INTO followers ( - follower_actor_id, - following_actor_id, - inbox_url, - shared_inbox_url - ) - VALUES (?1, ?2, ?3, ?4) - ON CONFLICT(follower_actor_id, following_actor_id) DO UPDATE SET - inbox_url = excluded.inbox_url, - shared_inbox_url = excluded.shared_inbox_url - "#, - params![ - remote_actor.as_str(), - local_actor.as_str(), - inbox.as_ref().map(|inbox| inbox.as_str()), - shared_inbox - .as_ref() - .map(|shared_inbox| shared_inbox.as_str()), - ], - )?; - } - StateChange::StoreActivity { activity } => { - let activity_id = activity_id(activity)?; - tx.execute( - r#" - INSERT INTO activities (activity_id, activity_json) - VALUES (?1, ?2) - ON CONFLICT(activity_id) DO UPDATE SET - activity_json = excluded.activity_json - "#, - params![activity_id.as_str(), serialize_activity(activity)?], - )?; - } - StateChange::StoreObject { object } => { - let object_id = object_id(object)?; - tx.execute( - r#" - INSERT INTO objects (object_id, object_json) - VALUES (?1, ?2) - ON CONFLICT(object_id) DO UPDATE SET - object_json = excluded.object_json - "#, - params![object_id.as_str(), serialize_object(object)?], - )?; - } - _ => return Err(StoreError::UnsupportedDecisionValue("state change")), - } - - Ok(()) -} - -fn apply_effect(tx: &Transaction<'_>, effect: &Effect) -> Result<(), StoreError> { - match effect { - Effect::PlanDelivery(delivery) => { - let activity_id = activity_id(&delivery.activity)?; - tx.execute( - r#" - INSERT INTO outgoing_deliveries ( - activity_id, - activity_json, - inbox_url - ) - VALUES (?1, ?2, ?3) - "#, - params![ - activity_id.as_str(), - serialize_activity(&delivery.activity)?, - delivery.inbox.as_str(), - ], - )?; - } - _ => return Err(StoreError::UnsupportedDecisionValue("effect")), - } - - Ok(()) -} - -fn activity_id(activity: &Activity) -> Result<&Iri, StoreError> { - match activity { - Activity::Accept(activity) => Ok(&activity.id), - Activity::CreateNote(activity) => Ok(&activity.id), - _ => Err(StoreError::UnsupportedDecisionValue("activity")), - } -} - -fn object_id(object: &Object) -> Result<&Iri, StoreError> { - match object { - Object::Note(object) => Ok(&object.id), - _ => Err(StoreError::UnsupportedDecisionValue("object")), - } -} - -fn serialize_activity(activity: &Activity) -> Result { - match activity { - Activity::Accept(activity) => Ok(serde_json::to_string(activity)?), - Activity::CreateNote(activity) => Ok(serde_json::to_string(activity)?), - _ => Err(StoreError::UnsupportedDecisionValue("activity")), +fn actor_reference_id(reference: &Reference) -> &Iri { + match reference { + Reference::Id(id) => id, + Reference::Object(actor) => &actor.id, } } -fn serialize_object(object: &Object) -> Result { - match object { - Object::Note(object) => Ok(serde_json::to_string(object)?), - _ => Err(StoreError::UnsupportedDecisionValue("object")), +fn actor_reference_inbox(reference: &Reference) -> Option<&Iri> { + match reference { + Reference::Id(_) => None, + Reference::Object(actor) => Some(&actor.inbox), } } -fn actor_reference_id(reference: &Reference) -> &Iri { +fn actor_reference_shared_inbox(reference: &Reference) -> Option<&Iri> { match reference { - Reference::Id(id) => id, - Reference::Object(actor) => &actor.id, + Reference::Id(_) => None, + Reference::Object(actor) => actor + .endpoints + .as_ref() + .and_then(|endpoints| endpoints.shared_inbox.as_ref()), } } @@ -408,9 +216,7 @@ fn parse_optional_iri(value: Option) -> Result, StoreError> #[cfg(test)] mod tests { - use feder_core::{ - Activity, Decision, Effect, FollowRelationship, PlannedDelivery, StateChange, - }; + use feder_core::{Action, StoreFollower}; use super::*; @@ -418,30 +224,11 @@ mod tests { value.parse().expect("valid test IRI") } - fn add_follower_decision( - remote_actor: &str, - local_actor: &str, - inbox: Option<&str>, - shared_inbox: Option<&str>, - ) -> Decision { - Decision { - state_changes: vec![StateChange::AddFollower { - local_actor: iri(local_actor), - remote_actor: iri(remote_actor), - inbox: inbox.map(iri), - shared_inbox: shared_inbox.map(iri), - }], - effects: Vec::new(), - } - } - - fn bob_follows_alice_decision() -> Decision { - add_follower_decision( - "https://remote.example/users/bob", - "https://example.com/users/alice", - None, - None, - ) + fn store_follower_action() -> Action { + Action::StoreFollower(StoreFollower { + follower: Reference::id(iri("https://remote.example/users/bob")), + following: Reference::id(iri("https://example.com/users/alice")), + }) } fn actor(id: &str) -> Actor { @@ -452,406 +239,181 @@ mod tests { ) } - fn follow(actor: Reference) -> feder_vocab::Follow { - feder_vocab::Follow::new( - iri("https://remote.example/activities/follow-1"), - actor, - Reference::id(iri("https://example.com/users/alice")), - ) - } - - fn accept_activity() -> Activity { - Activity::Accept(feder_vocab::Accept::new( - iri("https://example.com/activities/accept-1"), - Reference::id(iri("https://example.com/users/alice")), - Reference::object(follow(Reference::id(iri( - "https://remote.example/users/bob", - )))), - )) - } - #[test] - fn apply_decision_stores_follower() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - - store - .apply_decision(&bob_follows_alice_decision()) - .expect("apply follower decision"); + fn open_in_memory_initializes_followers_table() { + let store = SqliteStore::open_in_memory().expect("open in-memory store"); - let (follower, following): (String, String) = store + let table_count: i64 = store .conn .query_row( - "SELECT follower_actor_id, following_actor_id FROM followers", + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'followers'", [], - |row| Ok((row.get(0)?, row.get(1)?)), + |row| row.get(0), ) - .expect("query stored follower"); + .expect("query followers table"); - assert_eq!(follower, "https://remote.example/users/bob"); - assert_eq!(following, "https://example.com/users/alice"); - } + assert_eq!(table_count, 1); - #[test] - fn apply_decision_ignores_duplicate_follower() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - let decision = bob_follows_alice_decision(); + let columns: Vec = { + let mut stmt = store + .conn + .prepare("PRAGMA table_info(followers)") + .expect("prepare followers table info query"); + stmt.query_map([], |row| row.get("name")) + .expect("query followers table info") + .collect::>() + .expect("collect followers table columns") + }; - store - .apply_decision(&decision) - .expect("apply follower decision first time"); - store - .apply_decision(&decision) - .expect("apply follower decision second time"); + assert!(columns.contains(&"follower_actor_id".to_string())); + assert!(columns.contains(&"following_actor_id".to_string())); + assert!(columns.contains(&"inbox_url".to_string())); + assert!(columns.contains(&"shared_inbox_url".to_string())); - let follower_count: i64 = store + let index_count: i64 = store .conn - .query_row("SELECT COUNT(*) FROM followers", [], |row| row.get(0)) - .expect("query follower count"); + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_followers_following_actor_id'", + [], + |row| row.get(0), + ) + .expect("query followers following index"); - assert_eq!(follower_count, 1); + assert_eq!(index_count, 1); } #[test] - fn apply_decision_updates_follower_inbox() { + fn persist_actions_stores_follower() { let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); store - .apply_decision(&bob_follows_alice_decision()) - .expect("apply ID-only follower decision"); - store - .apply_decision(&add_follower_decision( - "https://remote.example/users/bob", - "https://example.com/users/alice", - Some("https://remote.example/users/bob/updated-inbox"), - None, - )) - .expect("apply updated follower decision"); + .persist_actions(&[store_follower_action()]) + .expect("persist follower action"); - let recipients = store - .list_follower_recipients(&iri("https://example.com/users/alice")) - .expect("list follower recipients"); + let (follower, following): (String, String) = store + .conn + .query_row( + "SELECT follower_actor_id, following_actor_id FROM followers", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("query stored follower"); - assert_eq!( - recipients, - vec![StoredRecipient { - actor_id: iri("https://remote.example/users/bob"), - inbox: iri("https://remote.example/users/bob/updated-inbox"), - shared_inbox: None, - }] - ); + assert_eq!(follower, "https://remote.example/users/bob"); + assert_eq!(following, "https://example.com/users/alice"); } #[test] - fn apply_decision_clears_stale_shared_inbox() { + fn persist_actions_stores_embedded_follower_inbox() { let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); + let action = Action::StoreFollower(StoreFollower { + follower: Reference::object(actor("https://remote.example/users/bob")), + following: Reference::id(iri("https://example.com/users/alice")), + }); store - .apply_decision(&add_follower_decision( - "https://remote.example/users/bob", - "https://example.com/users/alice", - Some("https://remote.example/users/bob/old-inbox"), - Some("https://remote.example/old-shared-inbox"), - )) - .expect("apply original follower decision"); - store - .apply_decision(&add_follower_decision( - "https://remote.example/users/bob", - "https://example.com/users/alice", - Some("https://remote.example/users/bob/current-inbox"), - None, - )) - .expect("apply refreshed follower decision"); + .persist_actions(&[action]) + .expect("persist follower action"); - let recipients = store - .list_follower_recipients(&iri("https://example.com/users/alice")) - .expect("list follower recipients"); + let inbox: Option = store + .conn + .query_row("SELECT inbox_url FROM followers", [], |row| row.get(0)) + .expect("query stored follower inbox"); assert_eq!( - recipients, - vec![StoredRecipient { - actor_id: iri("https://remote.example/users/bob"), - inbox: iri("https://remote.example/users/bob/current-inbox"), - shared_inbox: None, - }] + inbox.as_deref(), + Some("https://remote.example/users/bob/inbox") ); } #[test] - fn apply_decision_persists_state_changes_and_queues_delivery() { + fn persist_actions_stores_embedded_follower_shared_inbox() { let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - let activity = accept_activity(); - let decision = Decision { - state_changes: vec![ - StateChange::RecordProcessedActivity { - activity_id: iri("https://remote.example/activities/follow-1"), - }, - StateChange::AddFollower { - local_actor: iri("https://example.com/users/alice"), - remote_actor: iri("https://remote.example/users/bob"), - inbox: Some(iri("https://remote.example/users/bob/inbox")), - shared_inbox: Some(iri("https://remote.example/inbox")), - }, - StateChange::StoreActivity { - activity: activity.clone(), - }, - ], - effects: vec![Effect::PlanDelivery(PlannedDelivery { - activity, - inbox: iri("https://remote.example/inbox"), - })], - }; + let mut follower = actor("https://remote.example/users/bob"); + follower.endpoints = Some(feder_vocab::Endpoints { + shared_inbox: Some(iri("https://remote.example/inbox")), + }); + let action = Action::StoreFollower(StoreFollower { + follower: Reference::object(follower), + following: Reference::id(iri("https://example.com/users/alice")), + }); store - .apply_decision(&decision) - .expect("apply core decision"); - - let state = store - .load_received_follow_state( - &follow(Reference::id(iri("https://remote.example/users/bob"))), - &iri("https://example.com/users/alice"), - ) - .expect("load received follow state"); - assert!(state.already_processed); - assert_eq!(state.relationship, FollowRelationship::Following); - assert_eq!( - state.remote_actor.expect("remote actor state").shared_inbox, - Some(iri("https://remote.example/inbox")) - ); + .persist_actions(&[action]) + .expect("persist follower action"); - let stored_activity_count: i64 = store - .conn - .query_row("SELECT COUNT(*) FROM activities", [], |row| row.get(0)) - .expect("count stored activities"); - let delivery_count: i64 = store - .conn - .query_row("SELECT COUNT(*) FROM outgoing_deliveries", [], |row| { - row.get(0) - }) - .expect("count queued deliveries"); - let delivery_inbox: String = store + let shared_inbox: Option = store .conn - .query_row("SELECT inbox_url FROM outgoing_deliveries", [], |row| { + .query_row("SELECT shared_inbox_url FROM followers", [], |row| { row.get(0) }) - .expect("query queued delivery inbox"); - - assert_eq!(stored_activity_count, 1); - assert_eq!(delivery_count, 1); - assert_eq!(delivery_inbox, "https://remote.example/inbox"); - } + .expect("query stored follower shared inbox"); - #[test] - fn apply_decision_rolls_back_changes_before_duplicate_processed_activity() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - store - .conn - .execute( - "INSERT INTO processed_inbox_activities (activity_id) VALUES (?1)", - ["https://remote.example/activities/follow-1"], - ) - .expect("insert processed inbox activity"); - - let decision = Decision { - state_changes: vec![ - StateChange::AddFollower { - local_actor: iri("https://example.com/users/alice"), - remote_actor: iri("https://remote.example/users/bob"), - inbox: Some(iri("https://remote.example/users/bob/inbox")), - shared_inbox: None, - }, - StateChange::StoreActivity { - activity: accept_activity(), - }, - StateChange::RecordProcessedActivity { - activity_id: iri("https://remote.example/activities/follow-1"), - }, - ], - effects: vec![Effect::PlanDelivery(PlannedDelivery { - activity: accept_activity(), - inbox: iri("https://remote.example/users/bob/inbox"), - })], - }; - - store - .apply_decision(&decision) - .expect("duplicate processed activity should be ignored"); - - assert!( - store - .list_followers(&iri("https://example.com/users/alice")) - .expect("list followers") - .is_empty() - ); - assert_eq!( - store - .conn - .query_row("SELECT COUNT(*) FROM activities", [], |row| { - row.get::<_, i64>(0) - }) - .expect("count stored activities"), - 0 - ); - assert_eq!( - store - .conn - .query_row("SELECT COUNT(*) FROM outgoing_deliveries", [], |row| { - row.get::<_, i64>(0) - }) - .expect("count queued deliveries"), - 0 - ); - } - - #[test] - fn load_received_follow_state_returns_new_relationship_from_embedded_actor() { - let store = SqliteStore::open_in_memory().expect("open in-memory store"); - - let state = store - .load_received_follow_state( - &follow(Reference::object(actor("https://remote.example/users/bob"))), - &iri("https://example.com/users/alice"), - ) - .expect("load received follow state"); - - assert!(!state.already_processed); - assert_eq!(state.relationship, FollowRelationship::NotFollowing); assert_eq!( - state.remote_actor, - Some(RemoteActorState { - actor_id: iri("https://remote.example/users/bob"), - inbox: Some(iri("https://remote.example/users/bob/inbox")), - shared_inbox: None, - }) + shared_inbox.as_deref(), + Some("https://remote.example/inbox") ); } #[test] - fn load_received_follow_state_returns_existing_relationship_from_storage() { + fn persist_actions_ignores_duplicate_follower() { let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - store - .apply_decision(&add_follower_decision( - "https://remote.example/users/bob", - "https://example.com/users/alice", - Some("https://remote.example/users/bob/inbox"), - Some("https://remote.example/inbox"), - )) - .expect("apply follower decision"); - - let state = store - .load_received_follow_state( - &follow(Reference::id(iri("https://remote.example/users/bob"))), - &iri("https://example.com/users/alice"), - ) - .expect("load received follow state"); + let action = store_follower_action(); - assert_eq!(state.relationship, FollowRelationship::Following); - assert_eq!( - state.remote_actor, - Some(RemoteActorState { - actor_id: iri("https://remote.example/users/bob"), - inbox: Some(iri("https://remote.example/users/bob/inbox")), - shared_inbox: Some(iri("https://remote.example/inbox")), - }) - ); - } - - #[test] - fn load_received_follow_state_prefers_embedded_actor_endpoints() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); store - .apply_decision(&add_follower_decision( - "https://remote.example/users/bob", - "https://example.com/users/alice", - Some("https://remote.example/users/bob/inbox"), - None, - )) - .expect("apply follower decision"); + .persist_actions(&[action.clone()]) + .expect("persist follower action first time"); + store + .persist_actions(&[action]) + .expect("persist follower action second time"); - let mut updated_actor = actor("https://remote.example/users/bob"); - updated_actor.inbox = iri("https://remote.example/users/bob/updated-inbox"); - updated_actor.endpoints = Some(feder_vocab::Endpoints { - shared_inbox: Some(iri("https://remote.example/shared-inbox")), - }); - let state = store - .load_received_follow_state( - &follow(Reference::object(updated_actor)), - &iri("https://example.com/users/alice"), - ) - .expect("load received follow state"); + let follower_count: i64 = store + .conn + .query_row("SELECT COUNT(*) FROM followers", [], |row| row.get(0)) + .expect("query follower count"); - assert_eq!(state.relationship, FollowRelationship::Following); - assert_eq!( - state.remote_actor, - Some(RemoteActorState { - actor_id: iri("https://remote.example/users/bob"), - inbox: Some(iri("https://remote.example/users/bob/updated-inbox")), - shared_inbox: Some(iri("https://remote.example/shared-inbox")), - }) - ); + assert_eq!(follower_count, 1); } #[test] - fn load_received_follow_state_does_not_reuse_stale_shared_inbox() { + fn persist_actions_updates_follower_inbox_from_delivery_target() { let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - store - .apply_decision(&add_follower_decision( - "https://remote.example/users/bob", - "https://example.com/users/alice", - Some("https://remote.example/users/bob/old-inbox"), - Some("https://remote.example/old-shared-inbox"), - )) - .expect("apply follower decision"); - let mut updated_actor = actor("https://remote.example/users/bob"); - updated_actor.inbox = iri("https://remote.example/users/bob/current-inbox"); - updated_actor.endpoints = None; + store + .persist_actions(&[store_follower_action()]) + .expect("persist ID-only follower action"); + store + .persist_actions(&[Action::StoreDeliveryTarget( + feder_core::StoreDeliveryTarget { + target: feder_core::DeliveryTarget { + actor: iri("https://remote.example/users/bob"), + inbox: iri("https://remote.example/users/bob/updated-inbox"), + }, + }, + )]) + .expect("persist delivery target action"); - let state = store - .load_received_follow_state( - &follow(Reference::object(updated_actor)), - &iri("https://example.com/users/alice"), - ) - .expect("load received follow state"); + let recipients = store + .list_follower_recipients(&iri("https://example.com/users/alice")) + .expect("list follower recipients"); - assert_eq!(state.relationship, FollowRelationship::Following); assert_eq!( - state.remote_actor, - Some(RemoteActorState { + recipients, + vec![StoredRecipient { actor_id: iri("https://remote.example/users/bob"), - inbox: Some(iri("https://remote.example/users/bob/current-inbox")), + inbox: iri("https://remote.example/users/bob/updated-inbox"), shared_inbox: None, - }) + }] ); } - #[test] - fn load_received_follow_state_reports_processed_activity() { - let store = SqliteStore::open_in_memory().expect("open in-memory store"); - store - .conn - .execute( - "INSERT INTO processed_inbox_activities (activity_id) VALUES (?1)", - ["https://remote.example/activities/follow-1"], - ) - .expect("insert processed inbox activity"); - - let state = store - .load_received_follow_state( - &follow(Reference::id(iri("https://remote.example/users/bob"))), - &iri("https://example.com/users/alice"), - ) - .expect("load received follow state"); - - assert!(state.already_processed); - } - #[test] fn list_followers_returns_stored_followers() { let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); store - .apply_decision(&bob_follows_alice_decision()) - .expect("apply follower decision"); + .persist_actions(&[store_follower_action()]) + .expect("persist follower action"); let followers = store .list_followers(&iri("https://example.com/users/alice")) @@ -871,15 +433,18 @@ mod tests { #[test] fn list_followers_returns_follower_inbox() { let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); + let mut follower = actor("https://remote.example/users/bob"); + follower.endpoints = Some(feder_vocab::Endpoints { + shared_inbox: Some(iri("https://remote.example/inbox")), + }); + let action = Action::StoreFollower(StoreFollower { + follower: Reference::object(follower), + following: Reference::id(iri("https://example.com/users/alice")), + }); store - .apply_decision(&add_follower_decision( - "https://remote.example/users/bob", - "https://example.com/users/alice", - Some("https://remote.example/users/bob/inbox"), - Some("https://remote.example/inbox"), - )) - .expect("apply follower decision"); + .persist_actions(&[action]) + .expect("persist follower action"); let followers = store .list_followers(&iri("https://example.com/users/alice")) @@ -899,26 +464,18 @@ mod tests { #[test] fn list_followers_returns_only_followers_for_actor() { let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); + let bob_follows_alice = Action::StoreFollower(StoreFollower { + follower: Reference::id(iri("https://remote.example/users/bob")), + following: Reference::id(iri("https://example.com/users/alice")), + }); + let carol_follows_eve = Action::StoreFollower(StoreFollower { + follower: Reference::id(iri("https://remote.example/users/carol")), + following: Reference::id(iri("https://example.com/users/eve")), + }); store - .apply_decision(&Decision { - state_changes: vec![ - StateChange::AddFollower { - local_actor: iri("https://example.com/users/alice"), - remote_actor: iri("https://remote.example/users/bob"), - inbox: None, - shared_inbox: None, - }, - StateChange::AddFollower { - local_actor: iri("https://example.com/users/eve"), - remote_actor: iri("https://remote.example/users/carol"), - inbox: None, - shared_inbox: None, - }, - ], - effects: Vec::new(), - }) - .expect("apply follower decisions"); + .persist_actions(&[bob_follows_alice, carol_follows_eve]) + .expect("persist follower actions"); let followers = store .list_followers(&iri("https://example.com/users/alice")) @@ -938,26 +495,22 @@ mod tests { #[test] fn list_follower_recipients_returns_followers_with_inboxes() { let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); + let mut follower = actor("https://remote.example/users/bob"); + follower.endpoints = Some(feder_vocab::Endpoints { + shared_inbox: Some(iri("https://remote.example/inbox")), + }); + let follower_with_inbox = Action::StoreFollower(StoreFollower { + follower: Reference::object(follower), + following: Reference::id(iri("https://example.com/users/alice")), + }); + let follower_without_inbox = Action::StoreFollower(StoreFollower { + follower: Reference::id(iri("https://remote.example/users/carol")), + following: Reference::id(iri("https://example.com/users/alice")), + }); store - .apply_decision(&Decision { - state_changes: vec![ - StateChange::AddFollower { - local_actor: iri("https://example.com/users/alice"), - remote_actor: iri("https://remote.example/users/bob"), - inbox: Some(iri("https://remote.example/users/bob/inbox")), - shared_inbox: Some(iri("https://remote.example/inbox")), - }, - StateChange::AddFollower { - local_actor: iri("https://example.com/users/alice"), - remote_actor: iri("https://remote.example/users/carol"), - inbox: None, - shared_inbox: None, - }, - ], - effects: Vec::new(), - }) - .expect("apply follower decisions"); + .persist_actions(&[follower_with_inbox, follower_without_inbox]) + .expect("persist follower actions"); let recipients = store .list_follower_recipients(&iri("https://example.com/users/alice")) @@ -976,26 +529,18 @@ mod tests { #[test] fn list_follower_recipients_returns_only_recipients_for_actor() { let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); + let bob_follows_alice = Action::StoreFollower(StoreFollower { + follower: Reference::object(actor("https://remote.example/users/bob")), + following: Reference::id(iri("https://example.com/users/alice")), + }); + let carol_follows_eve = Action::StoreFollower(StoreFollower { + follower: Reference::object(actor("https://remote.example/users/carol")), + following: Reference::id(iri("https://example.com/users/eve")), + }); store - .apply_decision(&Decision { - state_changes: vec![ - StateChange::AddFollower { - local_actor: iri("https://example.com/users/alice"), - remote_actor: iri("https://remote.example/users/bob"), - inbox: Some(iri("https://remote.example/users/bob/inbox")), - shared_inbox: None, - }, - StateChange::AddFollower { - local_actor: iri("https://example.com/users/eve"), - remote_actor: iri("https://remote.example/users/carol"), - inbox: Some(iri("https://remote.example/users/carol/inbox")), - shared_inbox: None, - }, - ], - effects: Vec::new(), - }) - .expect("apply follower decisions"); + .persist_actions(&[bob_follows_alice, carol_follows_eve]) + .expect("persist follower actions"); let recipients = store .list_follower_recipients(&iri("https://example.com/users/alice")) diff --git a/examples/single-user-server/README.md b/examples/single-user-server/README.md index 3dbd0b1..b0a3297 100644 --- a/examples/single-user-server/README.md +++ b/examples/single-user-server/README.md @@ -3,13 +3,6 @@ Single-User Server Example Demo app using `feder-runtime-server` with one hardcoded local actor. -The example chooses concrete runtime values for the reusable server crate: - - - actor: `http://127.0.0.1:3000/users/alice` - - bind address: `127.0.0.1:3000` - - storage: in-memory SQLite - - inbox auth policy: unsigned requests allowed for local development - Run --- @@ -55,13 +48,3 @@ Expected response: ~~~~ text HTTP/1.1 204 No Content ~~~~ - -Fetch the local actor: - -~~~~ sh -curl -i http://127.0.0.1:3000/users/alice -~~~~ - -Supported `Follow` activities posted to `/users/alice/inbox` are parsed by the -runtime, decided by `feder-core`, and applied to in-memory storage. Other -activity types are currently accepted and ignored.