From c2117a1151bf29d9220ee32665b6555d8e7e36d1 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 28 Jul 2026 21:38:20 +0900 Subject: [PATCH 01/72] Revert core decider refactor Temporarily restore the stateful core architecture so the HTTP signature runtime work can be evaluated and integrated before reconsidering the decision-based architecture separetely. This reverts the changes introduced by PR #39. Assisted-by: Codex:gpt-5.6-sol --- README.md | 6 +- crates/feder-core/README.md | 17 - crates/feder-core/src/lib.rs | 892 +++++++++++++++--- .../tests/received_follow_decider.rs | 242 ----- crates/feder-runtime-server/README.md | 8 +- crates/feder-runtime-server/src/app.rs | 4 + crates/feder-runtime-server/src/inbox.rs | 232 ++--- .../feder-runtime-server/src/storage/mod.rs | 14 +- .../src/storage/sqlite.rs | 887 +++++------------ examples/single-user-server/README.md | 17 - 10 files changed, 1055 insertions(+), 1264 deletions(-) delete mode 100644 crates/feder-core/README.md delete mode 100644 crates/feder-core/tests/received_follow_decider.rs 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. From 4b4b1090b2d6075ff564500b3ef790566d6c8841 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Fri, 17 Jul 2026 14:22:42 +0900 Subject: [PATCH 02/72] Add actor cryptographic public keys Add the security JSON-LD context and model embedded CryptographicKey values on ActivityPub actors. Cover the publicKey representation with serialization tests. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-vocab/src/lib.rs | 88 ++++++++++++++++++- ...shapes.rs => activitypub_serialization.rs} | 47 ++++++++-- 2 files changed, 125 insertions(+), 10 deletions(-) rename crates/feder-vocab/tests/{phase1_shapes.rs => activitypub_serialization.rs} (79%) diff --git a/crates/feder-vocab/src/lib.rs b/crates/feder-vocab/src/lib.rs index 1c1bc16..9672ecd 100644 --- a/crates/feder-vocab/src/lib.rs +++ b/crates/feder-vocab/src/lib.rs @@ -29,6 +29,9 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer, ser::SerializeSeq} /// The canonical Activity Streams JSON-LD context URL. pub const ACTIVITYSTREAMS_CONTEXT: &str = "https://www.w3.org/ns/activitystreams"; +/// The JSON-LD context for the security vocabulary used by actor public keys. +pub const SECURITY_CONTEXT: &str = "https://w3id.org/security/v1"; + /// An absolute ActivityPub/ActivityStreams identifier. pub type Iri = IriString; @@ -172,6 +175,37 @@ activitystreams_type!(FollowType, Follow); activitystreams_type!(AcceptType, Accept); activitystreams_type!(CreateType, Create); +/// A JSON-LD context represented by one or more IRIs. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(untagged)] +pub enum Context { + Iri(Iri), + Iris(Vec), +} + +impl Context { + #[must_use] + pub fn one(context: Iri) -> Self { + Self::Iri(context) + } + + #[must_use] + pub fn many(contexts: impl Into>) -> Self { + Self::Iris(contexts.into()) + } + + fn include(&mut self, context: Iri) { + match self { + Self::Iri(existing) if existing == &context => {} + Self::Iri(existing) => { + *self = Self::Iris(Vec::from([existing.clone(), context])); + } + Self::Iris(existing) if !existing.contains(&context) => existing.push(context), + Self::Iris(_) => {} + } + } +} + #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] pub enum ActorType { Application, @@ -189,11 +223,40 @@ pub struct Endpoints { pub shared_inbox: Option, } +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub enum CryptographicKeyType { + #[default] + CryptographicKey, +} + +/// A public key published by an ActivityPub actor. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CryptographicKey { + pub id: Iri, + #[serde(rename = "type")] + pub kind: CryptographicKeyType, + pub owner: Iri, + #[serde(rename = "publicKeyPem")] + pub public_key_pem: String, +} + +impl CryptographicKey { + #[must_use] + pub fn new(id: Iri, owner: Iri, public_key_pem: String) -> Self { + Self { + id, + kind: CryptographicKeyType::default(), + owner, + public_key_pem, + } + } +} + /// A minimal ActivityPub actor. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct Actor { #[serde(rename = "@context", skip_serializing_if = "Option::is_none")] - pub context: Option, + pub context: Option, #[serde(rename = "type")] pub kind: ActorType, pub id: Iri, @@ -205,6 +268,8 @@ pub struct Actor { pub name: Option, #[serde(skip_serializing_if = "Option::is_none")] pub endpoints: Option, + #[serde(rename = "publicKey", skip_serializing_if = "Option::is_none")] + pub public_key: Option>, } impl Actor { @@ -216,11 +281,11 @@ impl Actor { #[must_use] pub fn new(kind: ActorType, id: Iri, inbox: Iri, outbox: Iri) -> Self { Self { - context: Some( + context: Some(Context::one( ACTIVITYSTREAMS_CONTEXT .parse() .expect("valid ActivityStreams IRI"), - ), + )), kind, id, inbox, @@ -228,8 +293,25 @@ impl Actor { preferred_username: None, name: None, endpoints: None, + public_key: None, } } + + pub fn set_public_key(&mut self, public_key: Reference) { + let security_context = SECURITY_CONTEXT + .parse() + .expect("valid security context IRI"); + self.context + .get_or_insert_with(|| { + Context::one( + ACTIVITYSTREAMS_CONTEXT + .parse() + .expect("valid ActivityStreams IRI"), + ) + }) + .include(security_context); + self.public_key = Some(public_key); + } } /// A minimal ActivityStreams Note object. diff --git a/crates/feder-vocab/tests/phase1_shapes.rs b/crates/feder-vocab/tests/activitypub_serialization.rs similarity index 79% rename from crates/feder-vocab/tests/phase1_shapes.rs rename to crates/feder-vocab/tests/activitypub_serialization.rs index 043f477..7d651d8 100644 --- a/crates/feder-vocab/tests/phase1_shapes.rs +++ b/crates/feder-vocab/tests/activitypub_serialization.rs @@ -14,11 +14,12 @@ // along with this program. If not, see . use feder_vocab::{ - ACTIVITYSTREAMS_CONTEXT, Accept, Create, Follow, Iri, Note, Reference, References, + ACTIVITYSTREAMS_CONTEXT, Accept, Actor, Create, CryptographicKey, Follow, Iri, Note, Reference, + References, SECURITY_CONTEXT, }; use serde_json::{Value, json}; -fn serialize(value: impl serde::Serialize) -> Value { +fn serialize_to_value(value: impl serde::Serialize) -> Value { serde_json::to_value(value).expect("serialize vocab value") } @@ -42,6 +43,38 @@ fn incoming_follow_json() -> serde_json::Value { }) } +#[test] +fn actor_serializes_embedded_cryptographic_key() { + let actor_id = iri("https://example.com/users/alice"); + let mut actor = Actor::person( + actor_id.clone(), + iri("https://example.com/users/alice/inbox"), + iri("https://example.com/users/alice/outbox"), + ); + actor.set_public_key(Reference::object(CryptographicKey::new( + iri("https://example.com/users/alice#main-key"), + actor_id, + "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----\n".to_string(), + ))); + + assert_eq!( + serialize_to_value(actor), + json!({ + "@context": [ACTIVITYSTREAMS_CONTEXT, SECURITY_CONTEXT], + "type": "Person", + "id": "https://example.com/users/alice", + "inbox": "https://example.com/users/alice/inbox", + "outbox": "https://example.com/users/alice/outbox", + "publicKey": { + "id": "https://example.com/users/alice#main-key", + "type": "CryptographicKey", + "owner": "https://example.com/users/alice", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----\n" + } + }) + ); +} + #[test] fn follow_activity_accepts_id_or_embedded_actor_references() { let follow: Follow = @@ -68,7 +101,7 @@ fn accept_activity_can_embed_follow_activity() { ); assert_eq!( - serialize(outgoing_accept), + serialize_to_value(outgoing_accept), json!({ "@context": ACTIVITYSTREAMS_CONTEXT, "type": "Accept", @@ -92,7 +125,7 @@ fn accept_activity_can_embed_follow_activity() { } #[test] -fn local_note_can_shape_create_note_activity() { +fn local_note_serializes_as_create_activity() { let mut note = Note::new(iri("https://example.com/notes/1")); note.attributed_to = Some(Reference::id(iri("https://example.com/users/alice"))); note.content = Some("Hello from Feder.".to_string()); @@ -105,7 +138,7 @@ fn local_note_can_shape_create_note_activity() { ); assert_eq!( - serialize(create), + serialize_to_value(create), json!({ "@context": ACTIVITYSTREAMS_CONTEXT, "type": "Create", @@ -124,7 +157,7 @@ fn local_note_can_shape_create_note_activity() { } #[test] -fn reference_keeps_id_and_embedded_object_shapes_distinct() { +fn reference_deserializes_id_and_embedded_object_distinctly() { let id_reference: Reference = serde_json::from_value(json!("https://example.com/notes/1")) .expect("deserialize id reference"); @@ -141,7 +174,7 @@ fn reference_keeps_id_and_embedded_object_shapes_distinct() { } #[test] -fn references_can_represent_common_recipient_shapes() { +fn references_deserialize_single_and_multiple_recipients() { let single: References = serde_json::from_value(json!("https://www.w3.org/ns/activitystreams#Public")) .expect("deserialize single recipient"); From e4e559aec75b4c7a2321656eaf73ddf50c3b1cf9 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Fri, 17 Jul 2026 16:12:35 +0900 Subject: [PATCH 03/72] Add feature-gated, Fedify-compatible 4096-bit RSA actor keys to Feder Core. Accept runtime-provided entropy, validate persisted pairs, and zeroize private PEM material. Use fixed fixtures for behavioral tests and exercise all workspace features and targets in CI. Assisted-by: Codex:gpt-5.6-sol --- Cargo.lock | 271 ++++++++++++++++++ Cargo.toml | 3 + crates/feder-core/Cargo.toml | 8 + crates/feder-core/src/http_signatures.rs | 159 ++++++++++ crates/feder-core/src/lib.rs | 3 + .../tests/fixtures/rsa-other-public-key.pem | 9 + .../tests/fixtures/rsa-private-key.pem | 28 ++ .../tests/fixtures/rsa-public-key.pem | 9 + .../src/storage/sqlite.rs | 2 +- mise.toml | 6 +- 10 files changed, 494 insertions(+), 4 deletions(-) create mode 100644 crates/feder-core/src/http_signatures.rs create mode 100644 crates/feder-core/tests/fixtures/rsa-other-public-key.pem create mode 100644 crates/feder-core/tests/fixtures/rsa-private-key.pem create mode 100644 crates/feder-core/tests/fixtures/rsa-public-key.pem diff --git a/Cargo.lock b/Cargo.lock index 9a9f225..5b67a0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "axum" version = "0.8.9" @@ -69,6 +75,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.13.0" @@ -103,6 +115,43 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "const-oid", + "crypto-common", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -120,6 +169,9 @@ name = "feder-core" version = "0.1.0" dependencies = [ "feder-vocab", + "rand_chacha", + "rsa", + "zeroize", ] [[package]] @@ -201,6 +253,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -338,6 +400,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "libc" @@ -345,6 +410,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libsqlite3-sys" version = "0.38.1" @@ -409,12 +480,66 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -427,12 +552,42 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -451,6 +606,32 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "regex-automata" version = "0.4.14" @@ -468,6 +649,26 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rsqlite-vfs" version = "0.1.1" @@ -586,6 +787,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + [[package]] name = "single-user-server" version = "0.1.0" @@ -619,6 +830,22 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sqlite-wasm-rs" version = "0.5.5" @@ -631,6 +858,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -792,6 +1025,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -810,6 +1049,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -876,6 +1121,32 @@ dependencies = [ "windows-link", ] +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 3e2de24..27b7b94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,9 +22,12 @@ feder-core = { version = "0.1.0", path = "crates/feder-core" } feder-runtime-server = { version = "0.1.0", path = "crates/feder-runtime-server" } feder-vocab = { version = "0.1.0", path = "crates/feder-vocab" } iri-string = { version = "0.7.12", default-features = false, features = ["alloc", "serde"] } +rand_chacha = { version = "0.3.1", default-features = false } +rsa = { version = "0.9.10", default-features = false, features = ["pem", "u64_digit"] } serde = { version = "1.0.219", default-features = false, features = ["alloc", "derive"] } serde_json = "1.0.140" tower = { version = "0.5", features = ["util"]} +zeroize = { version = "1.9.0", default-features = false, features = ["alloc"] } [workspace.lints.rust] warnings = "deny" diff --git a/crates/feder-core/Cargo.toml b/crates/feder-core/Cargo.toml index 40c5a8f..2cd3458 100644 --- a/crates/feder-core/Cargo.toml +++ b/crates/feder-core/Cargo.toml @@ -8,8 +8,16 @@ license.workspace = true homepage.workspace = true repository.workspace = true +[features] +http-signatures = ["dep:rsa", "dep:zeroize"] + [dependencies] feder-vocab.workspace = true +rsa = { workspace = true, optional = true } +zeroize = { workspace = true, optional = true } + +[dev-dependencies] +rand_chacha.workspace = true [lints] workspace = true diff --git a/crates/feder-core/src/http_signatures.rs b/crates/feder-core/src/http_signatures.rs new file mode 100644 index 0000000..fb23dc5 --- /dev/null +++ b/crates/feder-core/src/http_signatures.rs @@ -0,0 +1,159 @@ +//! Draft-Cavage HTTP Signature primitives. + +use alloc::string::String; +use core::fmt; + +use rsa::{ + RsaPrivateKey, RsaPublicKey, + pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey, LineEnding}, + rand_core::CryptoRngCore, +}; +use zeroize::Zeroizing; + +const ACTOR_RSA_BITS: usize = 4096; + +/// A local actor's RSA key pair encoded for persistent storage. +#[derive(Clone, Eq, PartialEq)] +pub struct ActorKeyPair { + private_key_pem: Zeroizing, + public_key_pem: String, +} + +impl ActorKeyPair { + /// Loads a persisted key pair and checks that both keys belong together. + pub fn from_pem(private_key_pem: String, public_key_pem: String) -> Result { + let private_key_pem = Zeroizing::new(private_key_pem); + let private_key = + RsaPrivateKey::from_pkcs8_pem(&private_key_pem).map_err(KeyError::InvalidPrivateKey)?; + let public_key = RsaPublicKey::from_public_key_pem(&public_key_pem) + .map_err(KeyError::InvalidPublicKey)?; + + if RsaPublicKey::from(&private_key) != public_key { + return Err(KeyError::MismatchedKeyPair); + } + + Ok(Self { + private_key_pem, + public_key_pem, + }) + } + + #[must_use] + pub fn private_key_pem(&self) -> &str { + &self.private_key_pem + } + + #[must_use] + pub fn public_key_pem(&self) -> &str { + &self.public_key_pem + } +} + +impl fmt::Debug for ActorKeyPair { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ActorKeyPair") + .field("private_key_pem", &"[REDACTED]") + .field("public_key_pem", &self.public_key_pem) + .finish() + } +} + +/// Errors produced while generating, encoding, or loading actor keys. +#[derive(Debug)] +pub enum KeyError { + Generation(rsa::Error), + PrivateKeyEncoding(rsa::pkcs8::Error), + PublicKeyEncoding(rsa::pkcs8::spki::Error), + InvalidPrivateKey(rsa::pkcs8::Error), + InvalidPublicKey(rsa::pkcs8::spki::Error), + MismatchedKeyPair, +} + +impl fmt::Display for KeyError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Generation(_) => formatter.write_str("failed to generate RSA actor key"), + Self::PrivateKeyEncoding(_) => formatter.write_str("failed to encode RSA private key"), + Self::PublicKeyEncoding(_) => formatter.write_str("failed to encode RSA public key"), + Self::InvalidPrivateKey(_) => formatter.write_str("invalid RSA private key PEM"), + Self::InvalidPublicKey(_) => formatter.write_str("invalid RSA public key PEM"), + Self::MismatchedKeyPair => formatter.write_str("RSA actor keys do not match"), + } + } +} + +impl core::error::Error for KeyError {} + +/// Generates a 4096-bit RSA actor key pair for draft-Cavage HTTP signatures. +/// The caller must supply a cryptographically secure random number generator for the target runtime. +pub fn generate_actor_key_pair( + rng: &mut (impl CryptoRngCore + ?Sized), +) -> Result { + let private_key = RsaPrivateKey::new(rng, ACTOR_RSA_BITS).map_err(KeyError::Generation)?; + let public_key = RsaPublicKey::from(&private_key); + let private_key_pem = private_key + .to_pkcs8_pem(LineEnding::LF) + .map_err(KeyError::PrivateKeyEncoding)?; + let public_key_pem = public_key + .to_public_key_pem(LineEnding::LF) + .map_err(KeyError::PublicKeyEncoding)?; + + Ok(ActorKeyPair { + private_key_pem, + public_key_pem, + }) +} + +#[cfg(test)] +mod tests { + use alloc::string::ToString; + + use rand_chacha::ChaCha20Rng; + use rsa::rand_core::SeedableRng; + use rsa::traits::PublicKeyParts; + + use super::*; + + const PRIVATE_KEY_PEM: &str = include_str!("../tests/fixtures/rsa-private-key.pem"); + const PUBLIC_KEY_PEM: &str = include_str!("../tests/fixtures/rsa-public-key.pem"); + const OTHER_PUBLIC_KEY_PEM: &str = include_str!("../tests/fixtures/rsa-other-public-key.pem"); + + #[test] + fn generated_actor_key_pair_uses_4096_bit_rsa() { + let mut rng = test_rng(1); + let pair = generate_actor_key_pair(&mut rng).expect("generate actor key pair"); + let private_key = RsaPrivateKey::from_pkcs8_pem(pair.private_key_pem()) + .expect("parse generated private key"); + let public_key = RsaPublicKey::from_public_key_pem(pair.public_key_pem()) + .expect("parse generated public key"); + + assert_eq!(private_key.n().bits(), ACTOR_RSA_BITS); + assert_eq!(public_key.n().bits(), ACTOR_RSA_BITS); + assert_eq!(RsaPublicKey::from(&private_key), public_key); + } + + #[test] + fn persisted_actor_key_pair_rejects_mismatched_keys() { + let result = ActorKeyPair::from_pem( + PRIVATE_KEY_PEM.to_string(), + OTHER_PUBLIC_KEY_PEM.to_string(), + ); + + assert!(matches!(result, Err(KeyError::MismatchedKeyPair))); + } + + #[test] + fn actor_key_pair_debug_output_redacts_private_key() { + let pair = ActorKeyPair::from_pem(PRIVATE_KEY_PEM.to_string(), PUBLIC_KEY_PEM.to_string()) + .expect("load actor key pair fixture"); + let debug = alloc::format!("{pair:?}"); + + assert!(debug.contains("[REDACTED]")); + assert!(!debug.contains(pair.private_key_pem())); + } + + fn test_rng(seed: u8) -> ChaCha20Rng { + ChaCha20Rng::from_seed([seed; 32]) + } +} diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 3e59dfa..e09b1e3 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -22,6 +22,9 @@ use alloc::{string::String, vec::Vec}; pub use feder_vocab as vocab; +#[cfg(feature = "http-signatures")] +pub mod http_signatures; + /// Portable core state and decision logic. #[derive(Debug)] pub struct FederCore { diff --git a/crates/feder-core/tests/fixtures/rsa-other-public-key.pem b/crates/feder-core/tests/fixtures/rsa-other-public-key.pem new file mode 100644 index 0000000..86fa3a6 --- /dev/null +++ b/crates/feder-core/tests/fixtures/rsa-other-public-key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAo9l6PexyP7BtQOAvgpFV +Sb+iFq5jPL3VoFdr1jevGhBHslqP881CUQHOsSzjJFDkHFTHhN30uuucceAajt9N +IpPAoS9Ft6ouYDheg/MZEPNvbnUuen7IAx1hOSAGLzdXKHIFbQp9eBEcc8pKXuR5 +mhowALUjioqA7Ax3N6iw7uI7ANzrMX+VSmCmuHgUcXbJUy/4SqCmI1M3xyKvxEFh ++KPQCMYRpIh02j6tFU5k7P5aynDitPwqXJr1w+NVcLl1qh4y4tGQ6GpscS5KGEqd +sojcVZuweV0hku4fT5onA2WAd4CWZVUVM3gBau1OzTgyCajjbgWURQjH9DrAHSrO +FQIDAQAB +-----END PUBLIC KEY----- diff --git a/crates/feder-core/tests/fixtures/rsa-private-key.pem b/crates/feder-core/tests/fixtures/rsa-private-key.pem new file mode 100644 index 0000000..0355a1e --- /dev/null +++ b/crates/feder-core/tests/fixtures/rsa-private-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDSXUHF1268trok +ZnAB9gVqnh4tL5gZc3WBSDIeNG/1niVRMVhMZ6kvLwv+WVqoyphMvTajUgeXAHIH +WIrUgJNQ8N7JhoDpqplt7+q+09l0treTRInuc+A6vjidawyMUFS1qxDK73JHmFO7 +5w+rbQneikedkGUIXL3vh7B1iOjz6o6f7g0cL0ykiVG05WhkWedY3iuHOYzL8YG+ +VazI1/7jBptlVs0OMg50y4jRogTwkmKoqUCae5F3kER2F5nw51a8D31l+KcOCNJF +7ZzPDrqaJgDXu/G1JaGSOuIizZS1d5BW6Pm6ftzv9UzOzeKtK4zEsLeBZhi0FrtI +5vKhe/l1AgMBAAECggEAMKIhPNstrYDEH3q0PevR/EBiYxlr/URRX+pgNdXzJVJi +t7bj/kP/29nxWKPxPvEZjTI4UcE64njWo+afL/oqtK1/IBGRt5O6hW1QNL5W+XHt +lmUjy0YsSoBkJ9aSD9VZhCdwii4Z2j3368rDN2NNww5ueJmjle+U9K3GyKF2k78U +2mczUoJ6LEJiAOpKVrrIdLEdb/NBE8b2lwqITHaL2Pidj9cbBfrU16pb1RG6z7Eb +Xv2AtobemO54oz+RnzLk6ApY9v27r5uXE/61WKN9iWFNHncoK/l95W/lktoCJLwM +3cMVZVPtEJdRCpEPbyfXG6pyAmgIjJDUj2lSwnr2kQKBgQDrkrVlZ+5H3qxoc6j7 +K4aWl1T7n4IHu3mGLE6ByLMHhjirq8SlG9tqJ09kpeprT5S+PtQiyVhud8PgejNJ +9eo/pU4ybE9VdAFacRCAgKN+jIFM6TniyLraNUK9+tHX53ca6Aolus8yiEaUQln5 +n8J9wrS18JV8T+9+OOJZPwql5QKBgQDkmvWOXD+wb23Sb4F8aD0WXOIqQacSHkjy +Fllp1FslvhgMGDIfwCAHDIT9osE4aK5Yy+88Fg5MGT0kgU0viguJ+fJheJ3oEwb+ +/wceEO11ts6+vaS9ZqAQO95lM+XoV6PU3uoyWE9uLYkxJd6Siz7BGUzl/TzZbWsw +ZzWD6I/MUQKBgCR1hUuXhUJsTSSxWeLdvqvJ6iYzbq2Br3I7oz7k8AhnFphDMmEX +aaMJSHlcUGahX3T+RljH7r7SHGe+ofd9bu7Ax9R3/ONN2/PCcfphbmxklJJxujrG +NF0XRygeDKIsubtZVFC4k97PRpUlm8VNm41ZOBy8inY97OQNK8MCRcSdAoGAVWXV +yWKIoD5gBjaFZpYCC/KSwjpYURpjIZxbtn8PtZ+3l/0J7HZ3AGsa2y0LhSkFyEIW +kpmiqabcAmETFmk5OkfW1babNnC1MljOrdqg+lJaFUL+4YoOzUGwKJokjpD+sKy9 +TCVVNtFn6KY+6Pt/a98prNjW/Fo1qpVDlo0v+qECgYBNH81yghesDmxGiRTcXrSF +l/eX53n77wqNnk7kay/hJj4uSH5QYkUEGRbwxLeO/X7cZ7X7mrc86QV5AXJbbdS9 +/zhWdXp7A16vqyCpSBJ2QcUreA+bZJ3eyTibBlE7pL8DMP4EDRryg4Zf563iCybO +JxO6BZUqb4N5zLW8t8GfXw== +-----END PRIVATE KEY----- diff --git a/crates/feder-core/tests/fixtures/rsa-public-key.pem b/crates/feder-core/tests/fixtures/rsa-public-key.pem new file mode 100644 index 0000000..6dfef20 --- /dev/null +++ b/crates/feder-core/tests/fixtures/rsa-public-key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0l1BxdduvLa6JGZwAfYF +ap4eLS+YGXN1gUgyHjRv9Z4lUTFYTGepLy8L/llaqMqYTL02o1IHlwByB1iK1ICT +UPDeyYaA6aqZbe/qvtPZdLa3k0SJ7nPgOr44nWsMjFBUtasQyu9yR5hTu+cPq20J +3opHnZBlCFy974ewdYjo8+qOn+4NHC9MpIlRtOVoZFnnWN4rhzmMy/GBvlWsyNf+ +4wabZVbNDjIOdMuI0aIE8JJiqKlAmnuRd5BEdheZ8OdWvA99ZfinDgjSRe2czw66 +miYA17vxtSWhkjriIs2UtXeQVuj5un7c7/VMzs3irSuMxLC3gWYYtBa7SObyoXv5 +dQIDAQAB +-----END PUBLIC KEY----- diff --git a/crates/feder-runtime-server/src/storage/sqlite.rs b/crates/feder-runtime-server/src/storage/sqlite.rs index 1172695..275665e 100644 --- a/crates/feder-runtime-server/src/storage/sqlite.rs +++ b/crates/feder-runtime-server/src/storage/sqlite.rs @@ -361,7 +361,7 @@ mod tests { let action = store_follower_action(); store - .persist_actions(&[action.clone()]) + .persist_actions(core::slice::from_ref(&action)) .expect("persist follower action first time"); store .persist_actions(&[action]) diff --git a/mise.toml b/mise.toml index 469d5f3..8c9e654 100644 --- a/mise.toml +++ b/mise.toml @@ -17,8 +17,8 @@ linux-arm64 = "hongdown-*-aarch64-unknown-linux-musl.tar.bz2" [tasks.check] description = "Run type check, lint, and check Markdown format" run = [ - "cargo check", - "cargo clippy", + "cargo check --workspace --all-targets --all-features", + "cargo clippy --workspace --all-targets --all-features", "cargo fmt --check", "hongdown --check", "mise fmt --check", @@ -30,7 +30,7 @@ run = ["cargo fmt", "hongdown --write", "mise fmt"] [tasks.test] description = "Run the Rust tests" -run = "cargo test --workspace" +run = "cargo test --workspace --all-features" [tasks.bump] description = "Preview a workspace version bump" From 6d251e342a3dbebf94c8c78f0baefe36b6425bba Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 19 Jul 2026 11:38:13 +0900 Subject: [PATCH 04/72] Persist actor RSA key pairs in SQLite Enable HTTP signature keys in the server runtime and store validated per-actor key pairs in the keys table. Reject accidental replacement and test schema creation, round trips, malformed keys, missing actors, and persistence across reopen. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/Cargo.toml | 2 +- .../feder-runtime-server/src/storage/mod.rs | 16 +- .../src/storage/sqlite.rs | 181 +++++++++++++++++- .../tests/fixtures/rsa-private-key.pem | 28 +++ .../tests/fixtures/rsa-public-key.pem | 9 + 5 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 crates/feder-runtime-server/tests/fixtures/rsa-private-key.pem create mode 100644 crates/feder-runtime-server/tests/fixtures/rsa-public-key.pem diff --git a/crates/feder-runtime-server/Cargo.toml b/crates/feder-runtime-server/Cargo.toml index 7981fea..fa5b370 100644 --- a/crates/feder-runtime-server/Cargo.toml +++ b/crates/feder-runtime-server/Cargo.toml @@ -9,7 +9,7 @@ homepage.workspace = true repository.workspace = true [dependencies] -feder-core.workspace = true +feder-core = { workspace = true, features = ["http-signatures"] } feder-vocab.workspace = true axum = "0.8" serde.workspace = true diff --git a/crates/feder-runtime-server/src/storage/mod.rs b/crates/feder-runtime-server/src/storage/mod.rs index 127f087..740f057 100644 --- a/crates/feder-runtime-server/src/storage/mod.rs +++ b/crates/feder-runtime-server/src/storage/mod.rs @@ -15,7 +15,10 @@ pub mod sqlite; -use feder_core::Action; +use feder_core::{ + Action, + http_signatures::{ActorKeyPair, KeyError}, +}; use feder_vocab::Iri; pub use sqlite::SqliteStore; @@ -44,6 +47,9 @@ pub enum StoreError { #[error("invalid IRI: {0}")] InvalidIri(String), + + #[error(transparent)] + ActorKey(#[from] KeyError), } pub trait RuntimeStore { @@ -52,4 +58,12 @@ pub trait RuntimeStore { fn list_followers(&self, actor_id: &Iri) -> Result, StoreError>; fn list_follower_recipients(&self, actor_id: &Iri) -> Result, StoreError>; + + fn insert_actor_key_pair( + &mut self, + actor_id: &Iri, + key_pair: &ActorKeyPair, + ) -> Result<(), StoreError>; + + fn load_actor_key_pair(&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 275665e..6c8d0b6 100644 --- a/crates/feder-runtime-server/src/storage/sqlite.rs +++ b/crates/feder-runtime-server/src/storage/sqlite.rs @@ -15,9 +15,9 @@ use std::path::Path; -use feder_core::Action; +use feder_core::{Action, http_signatures::ActorKeyPair}; use feder_vocab::{Actor, Iri, Reference}; -use rusqlite::{Connection, params}; +use rusqlite::{Connection, OptionalExtension, params}; use crate::storage::{RuntimeStore, StoreError, StoredFollower, StoredRecipient}; @@ -58,6 +58,11 @@ impl SqliteStore { ); CREATE INDEX IF NOT EXISTS idx_followers_following_actor_id ON followers (following_actor_id); + CREATE TABLE IF NOT EXISTS keys ( + actor_id TEXT PRIMARY KEY NOT NULL, + private_key_pem TEXT NOT NULL, + public_key_pem TEXT NOT NULL + ); "#, )?; @@ -178,6 +183,48 @@ impl RuntimeStore for SqliteStore { }) .collect() } + + fn insert_actor_key_pair( + &mut self, + actor_id: &Iri, + key_pair: &ActorKeyPair, + ) -> Result<(), StoreError> { + self.conn.execute( + r#" + INSERT INTO keys (actor_id, private_key_pem, public_key_pem) + VALUES (?1, ?2, ?3) + "#, + params![ + actor_id.as_str(), + key_pair.private_key_pem(), + key_pair.public_key_pem(), + ], + )?; + + Ok(()) + } + + fn load_actor_key_pair(&self, actor_id: &Iri) -> Result, StoreError> { + let encoded_keys = self + .conn + .query_row( + r#" + SELECT private_key_pem, public_key_pem + FROM keys + WHERE actor_id = ?1 + "#, + [actor_id.as_str()], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?; + + encoded_keys + .map(|(private_key_pem, public_key_pem)| { + ActorKeyPair::from_pem(private_key_pem, public_key_pem) + }) + .transpose() + .map_err(StoreError::from) + } } fn actor_reference_id(reference: &Reference) -> &Iri { @@ -220,6 +267,9 @@ mod tests { use super::*; + const PRIVATE_KEY_PEM: &str = include_str!("../../tests/fixtures/rsa-private-key.pem"); + const PUBLIC_KEY_PEM: &str = include_str!("../../tests/fixtures/rsa-public-key.pem"); + fn iri(value: &str) -> Iri { value.parse().expect("valid test IRI") } @@ -239,6 +289,11 @@ mod tests { ) } + fn actor_key_pair() -> ActorKeyPair { + ActorKeyPair::from_pem(PRIVATE_KEY_PEM.to_string(), PUBLIC_KEY_PEM.to_string()) + .expect("valid actor key pair fixture") + } + #[test] fn open_in_memory_initializes_followers_table() { let store = SqliteStore::open_in_memory().expect("open in-memory store"); @@ -282,6 +337,128 @@ mod tests { assert_eq!(index_count, 1); } + #[test] + fn open_in_memory_initializes_keys_table() { + let store = SqliteStore::open_in_memory().expect("open in-memory store"); + + let columns: Vec = { + let mut stmt = store + .conn + .prepare("PRAGMA table_info(keys)") + .expect("prepare keys table info query"); + stmt.query_map([], |row| row.get("name")) + .expect("query keys table info") + .collect::>() + .expect("collect keys table columns") + }; + + assert_eq!( + columns, + vec![ + "actor_id".to_string(), + "private_key_pem".to_string(), + "public_key_pem".to_string(), + ] + ); + } + + #[test] + fn actor_key_pair_roundtrips_for_actor() { + let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); + let actor_id = iri("https://example.com/users/alice"); + let expected = actor_key_pair(); + + store + .insert_actor_key_pair(&actor_id, &expected) + .expect("insert actor key pair"); + let actual = store + .load_actor_key_pair(&actor_id) + .expect("load actor key pair") + .expect("stored actor key pair"); + + assert_eq!(actual, expected); + } + + #[test] + fn actor_key_pair_persists_across_store_reopen() { + let path = std::env::temp_dir().join(format!( + "feder-actor-key-test-{}-{}.sqlite3", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time after unix epoch") + .as_nanos() + )); + let actor_id = iri("https://example.com/users/alice"); + let expected = actor_key_pair(); + + { + let mut store = SqliteStore::open(&path).expect("open SQLite store"); + store + .insert_actor_key_pair(&actor_id, &expected) + .expect("insert actor key pair"); + } + + let store = SqliteStore::open(&path).expect("reopen SQLite store"); + let actual = store + .load_actor_key_pair(&actor_id) + .expect("load actor key pair") + .expect("persisted actor key pair"); + + assert_eq!(actual, expected); + + drop(store); + let _ = std::fs::remove_file(path); + } + + #[test] + fn load_actor_key_pair_returns_none_for_unknown_actor() { + let store = SqliteStore::open_in_memory().expect("open in-memory store"); + + let key_pair = store + .load_actor_key_pair(&iri("https://example.com/users/unknown")) + .expect("load actor key pair"); + + assert!(key_pair.is_none()); + } + + #[test] + fn load_actor_key_pair_rejects_invalid_stored_keys() { + let store = SqliteStore::open_in_memory().expect("open in-memory store"); + store + .conn + .execute( + r#" + INSERT INTO keys (actor_id, private_key_pem, public_key_pem) + VALUES (?1, ?2, ?3) + "#, + params![ + "https://example.com/users/alice", + "not a private key", + PUBLIC_KEY_PEM, + ], + ) + .expect("insert invalid actor key pair"); + + let result = store.load_actor_key_pair(&iri("https://example.com/users/alice")); + + assert!(matches!(result, Err(StoreError::ActorKey(_)))); + } + + #[test] + fn insert_actor_key_pair_refuses_to_replace_existing_key() { + let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); + let actor_id = iri("https://example.com/users/alice"); + let key_pair = actor_key_pair(); + + store + .insert_actor_key_pair(&actor_id, &key_pair) + .expect("insert actor key pair"); + let result = store.insert_actor_key_pair(&actor_id, &key_pair); + + assert!(matches!(result, Err(StoreError::Sqlite(_)))); + } + #[test] fn persist_actions_stores_follower() { let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); diff --git a/crates/feder-runtime-server/tests/fixtures/rsa-private-key.pem b/crates/feder-runtime-server/tests/fixtures/rsa-private-key.pem new file mode 100644 index 0000000..0355a1e --- /dev/null +++ b/crates/feder-runtime-server/tests/fixtures/rsa-private-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDSXUHF1268trok +ZnAB9gVqnh4tL5gZc3WBSDIeNG/1niVRMVhMZ6kvLwv+WVqoyphMvTajUgeXAHIH +WIrUgJNQ8N7JhoDpqplt7+q+09l0treTRInuc+A6vjidawyMUFS1qxDK73JHmFO7 +5w+rbQneikedkGUIXL3vh7B1iOjz6o6f7g0cL0ykiVG05WhkWedY3iuHOYzL8YG+ +VazI1/7jBptlVs0OMg50y4jRogTwkmKoqUCae5F3kER2F5nw51a8D31l+KcOCNJF +7ZzPDrqaJgDXu/G1JaGSOuIizZS1d5BW6Pm6ftzv9UzOzeKtK4zEsLeBZhi0FrtI +5vKhe/l1AgMBAAECggEAMKIhPNstrYDEH3q0PevR/EBiYxlr/URRX+pgNdXzJVJi +t7bj/kP/29nxWKPxPvEZjTI4UcE64njWo+afL/oqtK1/IBGRt5O6hW1QNL5W+XHt +lmUjy0YsSoBkJ9aSD9VZhCdwii4Z2j3368rDN2NNww5ueJmjle+U9K3GyKF2k78U +2mczUoJ6LEJiAOpKVrrIdLEdb/NBE8b2lwqITHaL2Pidj9cbBfrU16pb1RG6z7Eb +Xv2AtobemO54oz+RnzLk6ApY9v27r5uXE/61WKN9iWFNHncoK/l95W/lktoCJLwM +3cMVZVPtEJdRCpEPbyfXG6pyAmgIjJDUj2lSwnr2kQKBgQDrkrVlZ+5H3qxoc6j7 +K4aWl1T7n4IHu3mGLE6ByLMHhjirq8SlG9tqJ09kpeprT5S+PtQiyVhud8PgejNJ +9eo/pU4ybE9VdAFacRCAgKN+jIFM6TniyLraNUK9+tHX53ca6Aolus8yiEaUQln5 +n8J9wrS18JV8T+9+OOJZPwql5QKBgQDkmvWOXD+wb23Sb4F8aD0WXOIqQacSHkjy +Fllp1FslvhgMGDIfwCAHDIT9osE4aK5Yy+88Fg5MGT0kgU0viguJ+fJheJ3oEwb+ +/wceEO11ts6+vaS9ZqAQO95lM+XoV6PU3uoyWE9uLYkxJd6Siz7BGUzl/TzZbWsw +ZzWD6I/MUQKBgCR1hUuXhUJsTSSxWeLdvqvJ6iYzbq2Br3I7oz7k8AhnFphDMmEX +aaMJSHlcUGahX3T+RljH7r7SHGe+ofd9bu7Ax9R3/ONN2/PCcfphbmxklJJxujrG +NF0XRygeDKIsubtZVFC4k97PRpUlm8VNm41ZOBy8inY97OQNK8MCRcSdAoGAVWXV +yWKIoD5gBjaFZpYCC/KSwjpYURpjIZxbtn8PtZ+3l/0J7HZ3AGsa2y0LhSkFyEIW +kpmiqabcAmETFmk5OkfW1babNnC1MljOrdqg+lJaFUL+4YoOzUGwKJokjpD+sKy9 +TCVVNtFn6KY+6Pt/a98prNjW/Fo1qpVDlo0v+qECgYBNH81yghesDmxGiRTcXrSF +l/eX53n77wqNnk7kay/hJj4uSH5QYkUEGRbwxLeO/X7cZ7X7mrc86QV5AXJbbdS9 +/zhWdXp7A16vqyCpSBJ2QcUreA+bZJ3eyTibBlE7pL8DMP4EDRryg4Zf563iCybO +JxO6BZUqb4N5zLW8t8GfXw== +-----END PRIVATE KEY----- diff --git a/crates/feder-runtime-server/tests/fixtures/rsa-public-key.pem b/crates/feder-runtime-server/tests/fixtures/rsa-public-key.pem new file mode 100644 index 0000000..6dfef20 --- /dev/null +++ b/crates/feder-runtime-server/tests/fixtures/rsa-public-key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0l1BxdduvLa6JGZwAfYF +ap4eLS+YGXN1gUgyHjRv9Z4lUTFYTGepLy8L/llaqMqYTL02o1IHlwByB1iK1ICT +UPDeyYaA6aqZbe/qvtPZdLa3k0SJ7nPgOr44nWsMjFBUtasQyu9yR5hTu+cPq20J +3opHnZBlCFy974ewdYjo8+qOn+4NHC9MpIlRtOVoZFnnWN4rhzmMy/GBvlWsyNf+ +4wabZVbNDjIOdMuI0aIE8JJiqKlAmnuRd5BEdheZ8OdWvA99ZfinDgjSRe2czw66 +miYA17vxtSWhkjriIs2UtXeQVuj5un7c7/VMzs3irSuMxLC3gWYYtBa7SObyoXv5 +dQIDAQAB +-----END PUBLIC KEY----- From 5a6544ac3308645f5454e85334c78da79a639521 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 19 Jul 2026 12:48:40 +0900 Subject: [PATCH 05/72] Move runtime tests to integration tests Move HTTP endpoint and startup tests under the tests directory, with shared fixtures kept private to the integration test crate. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/src/actor.rs | 63 ---- crates/feder-runtime-server/src/app.rs | 28 -- crates/feder-runtime-server/src/config.rs | 20 -- crates/feder-runtime-server/src/inbox.rs | 297 ------------------ crates/feder-runtime-server/src/webfinger.rs | 83 ----- .../feder-runtime-server/tests/cases/actor.rs | 74 +++++ .../feder-runtime-server/tests/cases/app.rs | 67 ++++ .../feder-runtime-server/tests/cases/inbox.rs | 276 ++++++++++++++++ .../tests/cases/webfinger.rs | 94 ++++++ .../feder-runtime-server/tests/common/mod.rs | 97 ++++++ crates/feder-runtime-server/tests/runtime.rs | 25 ++ 11 files changed, 633 insertions(+), 491 deletions(-) create mode 100644 crates/feder-runtime-server/tests/cases/actor.rs create mode 100644 crates/feder-runtime-server/tests/cases/app.rs create mode 100644 crates/feder-runtime-server/tests/cases/inbox.rs create mode 100644 crates/feder-runtime-server/tests/cases/webfinger.rs create mode 100644 crates/feder-runtime-server/tests/common/mod.rs create mode 100644 crates/feder-runtime-server/tests/runtime.rs diff --git a/crates/feder-runtime-server/src/actor.rs b/crates/feder-runtime-server/src/actor.rs index a57ea15..8ddfff0 100644 --- a/crates/feder-runtime-server/src/actor.rs +++ b/crates/feder-runtime-server/src/actor.rs @@ -37,66 +37,3 @@ pub async fn actor( ) .into_response()) } - -#[cfg(test)] -mod tests { - use axum::{ - body::{Body, to_bytes}, - http::{Request, StatusCode, header}, - }; - use serde_json::Value; - use tower::ServiceExt; - - use crate::{build_router, config::test_config}; - - #[tokio::test] - async fn returns_local_actor() { - let app = build_router(test_config()).expect("build router"); - - let response = app - .oneshot( - Request::builder() - .uri("/users/alice") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response.headers().get(header::CONTENT_TYPE).unwrap(), - "application/activity+json" - ); - - let body = to_bytes(response.into_body(), 2048) - .await - .expect("read response body"); - let json: Value = serde_json::from_slice(&body).expect("valid json"); - - assert_eq!(json["@context"], "https://www.w3.org/ns/activitystreams"); - assert_eq!(json["type"], "Person"); - assert_eq!(json["id"], "http://127.0.0.1:3000/users/alice"); - assert_eq!(json["inbox"], "http://127.0.0.1:3000/users/alice/inbox"); - assert_eq!(json["outbox"], "http://127.0.0.1:3000/users/alice/outbox"); - assert_eq!(json["preferredUsername"], "alice"); - assert_eq!(json["name"], "alice"); - } - - #[tokio::test] - async fn rejects_unknown_actor() { - let app = build_router(test_config()).expect("build router"); - - let response = app - .oneshot( - Request::builder() - .uri("/users/bob") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } -} diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs index 67d8181..9fdcdca 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -73,31 +73,3 @@ pub fn build_router(config: RuntimeConfig) -> Result { async fn healthz() -> StatusCode { StatusCode::NO_CONTENT } - -#[cfg(test)] -mod tests { - use axum::{ - body::Body, - http::{Request, StatusCode}, - }; - use tower::ServiceExt; - - use crate::{build_router, config::test_config}; - - #[tokio::test] - async fn returns_health_check() { - let app = build_router(test_config()).expect("build router"); - - let response = app - .oneshot( - Request::builder() - .uri("/healthz") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::NO_CONTENT); - } -} diff --git a/crates/feder-runtime-server/src/config.rs b/crates/feder-runtime-server/src/config.rs index fb1d7b5..918e18d 100644 --- a/crates/feder-runtime-server/src/config.rs +++ b/crates/feder-runtime-server/src/config.rs @@ -39,23 +39,3 @@ pub struct RuntimeConfig { pub inbox_auth_policy: InboxAuthPolicy, pub storage: StorageConfig, } - -#[cfg(test)] -pub(crate) fn test_config() -> RuntimeConfig { - RuntimeConfig { - actor_id: "http://127.0.0.1:3000/users/alice" - .parse() - .expect("valid actor IRI"), - inbox: "http://127.0.0.1:3000/users/alice/inbox" - .parse() - .expect("valid inbox IRI"), - outbox: "http://127.0.0.1:3000/users/alice/outbox" - .parse() - .expect("valid outbox IRI"), - bind: "127.0.0.1:3000".parse().expect("valid bind address"), - username: "alice".to_string(), - handle_host: "127.0.0.1:3000".to_string(), - inbox_auth_policy: InboxAuthPolicy::AllowUnsignedInsecureDev, - storage: StorageConfig::InMemory, - } -} diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index 83d9e14..ccd207a 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -118,300 +118,3 @@ pub async fn inbox( Ok(StatusCode::ACCEPTED.into_response()) } - -#[cfg(test)] -mod tests { - use axum::{ - body::{Body, Bytes}, - extract::Path, - http::{HeaderMap, Method, Request, StatusCode, Uri, header::CONTENT_TYPE}, - response::Response, - }; - use serde_json::json; - use tower::ServiceExt; - - use crate::{ - app::AppState, - build_router, - config::{InboxAuthPolicy, StorageConfig, test_config}, - storage::RuntimeStore, - }; - - use super::inbox; - - fn activity_json_headers() -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, "application/activity+json".parse().unwrap()); - headers - } - - fn follow_body() -> 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": { - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Person", - "id": "https://remote.example/users/bob", - "inbox": "https://remote.example/users/bob/inbox", - "outbox": "https://remote.example/users/bob/outbox" - }, - "object": "http://127.0.0.1:3000/users/alice" - })) - .expect("serialize follow"), - ) - } - - async fn post_inbox( - app_state: AppState, - username: &str, - headers: HeaderMap, - body: Bytes, - ) -> Result { - inbox( - axum::extract::State(app_state), - Path(username.to_string()), - headers, - Method::POST, - Uri::from_static("/users/alice/inbox"), - body, - ) - .await - } - - #[tokio::test] - async fn valid_follow_reaches_core() { - let app_state = AppState::from_config(test_config()).expect("build app state"); - - let response = post_inbox( - app_state.clone(), - "alice", - activity_json_headers(), - follow_body(), - ) - .await - .expect("accepted follow"); - - assert_eq!(response.status(), StatusCode::ACCEPTED); - - let core = app_state.core.lock().expect("core lock"); - assert_eq!(core.state().followers().len(), 1); - assert_eq!( - core.state().followers()[0].follower.as_str(), - "https://remote.example/users/bob" - ); - assert_eq!( - 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!( - core.state().delivery_targets()[0].inbox.as_str(), - "https://remote.example/users/bob/inbox" - ); - } - - #[tokio::test] - async fn require_signed_rejects_unsigned_follow_before_core() { - let mut config = test_config(); - config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; - let app_state = AppState::from_config(config).expect("build app state"); - - let error = post_inbox( - app_state.clone(), - "alice", - activity_json_headers(), - follow_body(), - ) - .await - .expect_err("unsigned follow should be rejected"); - - assert_eq!(error, StatusCode::UNAUTHORIZED); - assert!( - app_state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); - } - - #[tokio::test] - async fn rejects_unknown_inbox_actor() { - let app_state = AppState::from_config(test_config()).expect("build app state"); - - let error = post_inbox( - app_state.clone(), - "bob", - activity_json_headers(), - follow_body(), - ) - .await - .expect_err("unknown inbox actor should be rejected"); - - assert_eq!(error, StatusCode::NOT_FOUND); - assert!( - app_state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); - } - - #[tokio::test] - async fn rejects_unsupported_content_type() { - let app_state = AppState::from_config(test_config()).expect("build app state"); - let mut headers = HeaderMap::new(); - headers.insert(CONTENT_TYPE, "application/json".parse().unwrap()); - - let error = post_inbox(app_state.clone(), "alice", headers, follow_body()) - .await - .expect_err("unsupported content type should be rejected"); - - assert_eq!(error, StatusCode::UNSUPPORTED_MEDIA_TYPE); - assert!( - app_state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); - } - - #[tokio::test] - async fn rejects_malformed_json() { - let app_state = AppState::from_config(test_config()).expect("build app state"); - - let error = post_inbox( - app_state.clone(), - "alice", - activity_json_headers(), - Bytes::from_static(b"{not json"), - ) - .await - .expect_err("malformed json should be rejected"); - - assert_eq!(error, StatusCode::BAD_REQUEST); - assert!( - app_state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); - } - - #[tokio::test] - 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!({ - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Create", - "id": "https://remote.example/activities/create-1", - "actor": "https://remote.example/users/bob", - "object": { - "type": "Note", - "id": "https://remote.example/notes/1" - } - })) - .expect("serialize create"), - ); - - let response = post_inbox(app_state.clone(), "alice", activity_json_headers(), body) - .await - .expect("unsupported activity is accepted but ignored"); - - assert_eq!(response.status(), StatusCode::ACCEPTED); - assert!( - app_state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); - } - - #[tokio::test] - async fn rejects_oversized_inbox_body() { - let app = build_router(test_config()).expect("build router"); - let oversized_body = vec![b' '; 1_048_577]; - - let response = app - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/users/alice/inbox") - .header(CONTENT_TYPE, "application/activity+json") - .body(Body::from(oversized_body)) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); - } - - #[tokio::test] - async fn sqlite_storage_persists_followers_across_app_state_reopen() { - let path = std::env::temp_dir().join(format!( - "feder-runtime-server-test-{}-{}.sqlite3", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system time after unix epoch") - .as_nanos() - )); - - let mut config = test_config(); - config.storage = StorageConfig::Sqlite { path: path.clone() }; - let app_state = AppState::from_config(config).expect("build app state"); - - let response = post_inbox( - app_state.clone(), - "alice", - activity_json_headers(), - follow_body(), - ) - .await - .expect("accepted follow"); - - assert_eq!(response.status(), StatusCode::ACCEPTED); - drop(app_state); - - let mut config = test_config(); - config.storage = StorageConfig::Sqlite { path: path.clone() }; - let app_state = AppState::from_config(config).expect("reopen app state"); - let followers = app_state - .store - .lock() - .expect("store lock") - .list_followers( - &"http://127.0.0.1:3000/users/alice" - .parse() - .expect("valid IRI"), - ) - .expect("list followers"); - - assert_eq!(followers.len(), 1); - assert_eq!( - followers[0].follower.as_str(), - "https://remote.example/users/bob" - ); - - let _ = std::fs::remove_file(path); - } -} diff --git a/crates/feder-runtime-server/src/webfinger.rs b/crates/feder-runtime-server/src/webfinger.rs index 23d86f2..c84f242 100644 --- a/crates/feder-runtime-server/src/webfinger.rs +++ b/crates/feder-runtime-server/src/webfinger.rs @@ -72,86 +72,3 @@ pub async fn webfinger( ) .into_response()) } - -#[cfg(test)] -mod tests { - use axum::{ - body::{Body, to_bytes}, - http::{Request, StatusCode, header}, - }; - use serde_json::Value; - use tower::ServiceExt; - - use crate::{build_router, config::test_config}; - - const WEBFINGER_PATH: &str = "/.well-known/webfinger?resource=acct:alice@127.0.0.1:3000"; - - #[tokio::test] - async fn returns_webfinger_descriptor_for_local_actor() { - let app = build_router(test_config()).expect("build router"); - - let response = app - .oneshot( - Request::builder() - .uri(WEBFINGER_PATH) - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response.headers().get(header::CONTENT_TYPE).unwrap(), - "application/jrd+json" - ); - - let body = to_bytes(response.into_body(), 1024) - .await - .expect("read response body"); - let json: Value = serde_json::from_slice(&body).expect("valid json"); - - assert_eq!(json["subject"], "acct:alice@127.0.0.1:3000"); - assert_eq!(json["aliases"][0], "http://127.0.0.1:3000/users/alice"); - assert_eq!(json["links"][0]["rel"], "self"); - assert_eq!(json["links"][0]["type"], "application/activity+json"); - assert_eq!( - json["links"][0]["href"], - "http://127.0.0.1:3000/users/alice" - ); - } - - #[tokio::test] - async fn rejects_missing_resource() { - let app = build_router(test_config()).expect("build router"); - - let response = app - .oneshot( - Request::builder() - .uri("/.well-known/webfinger") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - } - - #[tokio::test] - async fn rejects_non_local_actor_resource() { - let app = build_router(test_config()).expect("build router"); - - let response = app - .oneshot( - Request::builder() - .uri("/.well-known/webfinger?resource=acct:bob@127.0.0.1:3000") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } -} diff --git a/crates/feder-runtime-server/tests/cases/actor.rs b/crates/feder-runtime-server/tests/cases/actor.rs new file mode 100644 index 0000000..57a8314 --- /dev/null +++ b/crates/feder-runtime-server/tests/cases/actor.rs @@ -0,0 +1,74 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, +}; +use serde_json::Value; +use tower::ServiceExt; + +use crate::common::{test_config, test_router}; + +#[tokio::test] +async fn returns_local_actor() { + let app = test_router(test_config()).expect("build router"); + + let response = app + .oneshot( + Request::builder() + .uri("/users/alice") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::CONTENT_TYPE).unwrap(), + "application/activity+json" + ); + + let body = to_bytes(response.into_body(), 2048) + .await + .expect("read response body"); + let json: Value = serde_json::from_slice(&body).expect("valid json"); + + assert_eq!(json["@context"], "https://www.w3.org/ns/activitystreams"); + assert_eq!(json["type"], "Person"); + assert_eq!(json["id"], "http://127.0.0.1:3000/users/alice"); + assert_eq!(json["inbox"], "http://127.0.0.1:3000/users/alice/inbox"); + assert_eq!(json["outbox"], "http://127.0.0.1:3000/users/alice/outbox"); + assert_eq!(json["preferredUsername"], "alice"); + assert_eq!(json["name"], "alice"); +} + +#[tokio::test] +async fn rejects_unknown_actor() { + let app = test_router(test_config()).expect("build router"); + + let response = app + .oneshot( + Request::builder() + .uri("/users/bob") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} diff --git a/crates/feder-runtime-server/tests/cases/app.rs b/crates/feder-runtime-server/tests/cases/app.rs new file mode 100644 index 0000000..510ec4f --- /dev/null +++ b/crates/feder-runtime-server/tests/cases/app.rs @@ -0,0 +1,67 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use axum::{ + body::Body, + http::{Request, StatusCode}, +}; +use feder_runtime_server::{AppState, config::StorageConfig, storage::RuntimeStore}; +use tower::ServiceExt; + +use crate::common::{temporary_database_path, test_config, test_router}; + +#[tokio::test] +async fn returns_health_check() { + let app = test_router(test_config()).expect("build router"); + + let response = app + .oneshot( + Request::builder() + .uri("/healthz") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); +} + +#[test] +fn startup_generates_then_reuses_persisted_actor_key_pair() { + let path = temporary_database_path("feder-startup-key-test"); + let mut config = test_config(); + config.storage = StorageConfig::Sqlite { path: path.clone() }; + let first = AppState::from_config(config).expect("build first app state"); + let expected_public_key = first.actor_key_pair.public_key_pem().to_string(); + let stored = first + .store + .lock() + .expect("lock store") + .load_actor_key_pair(&first.local_actor.id) + .expect("load actor key pair") + .expect("stored actor key pair"); + assert_eq!(stored, *first.actor_key_pair); + drop(first); + + let mut config = test_config(); + config.storage = StorageConfig::Sqlite { path: path.clone() }; + let second = AppState::from_config(config).expect("reopen app state"); + + assert_eq!(second.actor_key_pair.public_key_pem(), expected_public_key); + + drop(second); + let _ = std::fs::remove_file(path); +} diff --git a/crates/feder-runtime-server/tests/cases/inbox.rs b/crates/feder-runtime-server/tests/cases/inbox.rs new file mode 100644 index 0000000..c9652e3 --- /dev/null +++ b/crates/feder-runtime-server/tests/cases/inbox.rs @@ -0,0 +1,276 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use axum::{ + Router, + body::Body, + http::{Request, StatusCode, header::CONTENT_TYPE}, +}; +use feder_runtime_server::{ + app::router_with_state, + config::{InboxAuthPolicy, StorageConfig}, + storage::RuntimeStore, +}; +use serde_json::json; +use tower::ServiceExt; + +use crate::common::{temporary_database_path, test_app_state, test_config, test_router}; + +fn follow_body() -> Vec { + serde_json::to_vec(&json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Follow", + "id": "https://remote.example/activities/follow-1", + "actor": { + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Person", + "id": "https://remote.example/users/bob", + "inbox": "https://remote.example/users/bob/inbox", + "outbox": "https://remote.example/users/bob/outbox" + }, + "object": "http://127.0.0.1:3000/users/alice" + })) + .expect("serialize follow") +} + +async fn post_inbox( + app: Router, + uri: &str, + content_type: &str, + body: impl Into, +) -> axum::response::Response { + app.oneshot( + Request::builder() + .method("POST") + .uri(uri) + .header(CONTENT_TYPE, content_type) + .body(body.into()) + .expect("valid request"), + ) + .await + .expect("response") +} + +#[tokio::test] +async fn valid_follow_reaches_core() { + let state = test_app_state(test_config()).expect("build app state"); + let response = post_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + "application/activity+json", + follow_body(), + ) + .await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + + let core = state.core.lock().expect("core lock"); + assert_eq!(core.state().followers().len(), 1); + assert_eq!( + core.state().followers()[0].follower.as_str(), + "https://remote.example/users/bob" + ); + assert_eq!( + 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!( + core.state().delivery_targets()[0].inbox.as_str(), + "https://remote.example/users/bob/inbox" + ); +} + +#[tokio::test] +async fn require_signed_rejects_unsigned_follow_before_core() { + let mut config = test_config(); + config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; + let state = test_app_state(config).expect("build app state"); + let response = post_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + "application/activity+json", + follow_body(), + ) + .await; + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); +} + +#[tokio::test] +async fn rejects_unknown_inbox_actor() { + let state = test_app_state(test_config()).expect("build app state"); + let response = post_inbox( + router_with_state(state.clone()), + "/users/bob/inbox", + "application/activity+json", + follow_body(), + ) + .await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); +} + +#[tokio::test] +async fn rejects_unsupported_content_type() { + let state = test_app_state(test_config()).expect("build app state"); + let response = post_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + "application/json", + follow_body(), + ) + .await; + + assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); +} + +#[tokio::test] +async fn rejects_malformed_json() { + let state = test_app_state(test_config()).expect("build app state"); + let response = post_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + "application/activity+json", + "{not json", + ) + .await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); +} + +#[tokio::test] +async fn ignores_unsupported_activity_without_mutating_core() { + let state = test_app_state(test_config()).expect("build app state"); + let body = serde_json::to_vec(&json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Create", + "id": "https://remote.example/activities/create-1", + "actor": "https://remote.example/users/bob", + "object": { + "type": "Note", + "id": "https://remote.example/notes/1" + } + })) + .expect("serialize create"); + let response = post_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + "application/activity+json", + body, + ) + .await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); +} + +#[tokio::test] +async fn rejects_oversized_inbox_body() { + let response = post_inbox( + test_router(test_config()).expect("build router"), + "/users/alice/inbox", + "application/activity+json", + vec![b' '; 1_048_577], + ) + .await; + + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); +} + +#[tokio::test] +async fn sqlite_storage_persists_followers_across_app_state_reopen() { + let path = temporary_database_path("feder-runtime-server-test"); + let mut config = test_config(); + config.storage = StorageConfig::Sqlite { path: path.clone() }; + let state = test_app_state(config).expect("build app state"); + let response = post_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + "application/activity+json", + follow_body(), + ) + .await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + drop(state); + + let mut config = test_config(); + config.storage = StorageConfig::Sqlite { path: path.clone() }; + let state = test_app_state(config).expect("reopen app state"); + let followers = state + .store + .lock() + .expect("store lock") + .list_followers( + &"http://127.0.0.1:3000/users/alice" + .parse() + .expect("valid IRI"), + ) + .expect("list followers"); + + assert_eq!(followers.len(), 1); + assert_eq!( + followers[0].follower.as_str(), + "https://remote.example/users/bob" + ); + + drop(state); + let _ = std::fs::remove_file(path); +} diff --git a/crates/feder-runtime-server/tests/cases/webfinger.rs b/crates/feder-runtime-server/tests/cases/webfinger.rs new file mode 100644 index 0000000..3b6c963 --- /dev/null +++ b/crates/feder-runtime-server/tests/cases/webfinger.rs @@ -0,0 +1,94 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, +}; +use serde_json::Value; +use tower::ServiceExt; + +use crate::common::{test_config, test_router}; + +const WEBFINGER_PATH: &str = "/.well-known/webfinger?resource=acct:alice@127.0.0.1:3000"; + +#[tokio::test] +async fn returns_webfinger_descriptor_for_local_actor() { + let app = test_router(test_config()).expect("build router"); + + let response = app + .oneshot( + Request::builder() + .uri(WEBFINGER_PATH) + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::CONTENT_TYPE).unwrap(), + "application/jrd+json" + ); + + let body = to_bytes(response.into_body(), 1024) + .await + .expect("read response body"); + let json: Value = serde_json::from_slice(&body).expect("valid json"); + + assert_eq!(json["subject"], "acct:alice@127.0.0.1:3000"); + assert_eq!(json["aliases"][0], "http://127.0.0.1:3000/users/alice"); + assert_eq!(json["links"][0]["rel"], "self"); + assert_eq!(json["links"][0]["type"], "application/activity+json"); + assert_eq!( + json["links"][0]["href"], + "http://127.0.0.1:3000/users/alice" + ); +} + +#[tokio::test] +async fn rejects_missing_resource() { + let app = test_router(test_config()).expect("build router"); + + let response = app + .oneshot( + Request::builder() + .uri("/.well-known/webfinger") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn rejects_non_local_actor_resource() { + let app = test_router(test_config()).expect("build router"); + + let response = app + .oneshot( + Request::builder() + .uri("/.well-known/webfinger?resource=acct:bob@127.0.0.1:3000") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} diff --git a/crates/feder-runtime-server/tests/common/mod.rs b/crates/feder-runtime-server/tests/common/mod.rs new file mode 100644 index 0000000..76843d2 --- /dev/null +++ b/crates/feder-runtime-server/tests/common/mod.rs @@ -0,0 +1,97 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::sync::{Arc, Mutex}; + +use axum::Router; +use feder_core::{FederConfig, FederCore, http_signatures::ActorKeyPair}; +use feder_runtime_server::{ + Error, + app::{AppState, router_with_state}, + config::{InboxAuthPolicy, RuntimeConfig, StorageConfig}, + storage::{RuntimeStore, SqliteStore}, +}; +use feder_vocab::Actor; + +pub fn test_config() -> RuntimeConfig { + RuntimeConfig { + actor_id: "http://127.0.0.1:3000/users/alice" + .parse() + .expect("valid actor IRI"), + inbox: "http://127.0.0.1:3000/users/alice/inbox" + .parse() + .expect("valid inbox IRI"), + outbox: "http://127.0.0.1:3000/users/alice/outbox" + .parse() + .expect("valid outbox IRI"), + bind: "127.0.0.1:3000".parse().expect("valid bind address"), + username: "alice".to_string(), + handle_host: "127.0.0.1:3000".to_string(), + inbox_auth_policy: InboxAuthPolicy::AllowUnsignedInsecureDev, + storage: StorageConfig::InMemory, + } +} + +pub fn test_app_state(config: RuntimeConfig) -> Result { + let mut actor = Actor::person(config.actor_id, config.inbox, config.outbox); + actor.preferred_username = Some(config.username.clone()); + actor.name = Some(config.username.clone()); + + let core = FederCore::new(FederConfig::new(actor.clone())); + let mut store = match &config.storage { + StorageConfig::InMemory => SqliteStore::open_in_memory()?, + StorageConfig::Sqlite { path } => SqliteStore::open(path)?, + }; + let actor_key_pair = match store.load_actor_key_pair(&actor.id)? { + Some(key_pair) => key_pair, + None => { + let key_pair = fixture_actor_key_pair()?; + store.insert_actor_key_pair(&actor.id, &key_pair)?; + key_pair + } + }; + + Ok(AppState { + core: Arc::new(Mutex::new(core)), + store: Arc::new(Mutex::new(store)), + actor_key_pair: Arc::new(actor_key_pair), + local_actor: actor, + username: config.username, + handle_host: config.handle_host, + inbox_auth_policy: config.inbox_auth_policy, + }) +} + +pub fn test_router(config: RuntimeConfig) -> Result { + Ok(router_with_state(test_app_state(config)?)) +} + +pub fn temporary_database_path(prefix: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "{prefix}-{}-{}.sqlite3", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time after unix epoch") + .as_nanos() + )) +} + +fn fixture_actor_key_pair() -> Result { + ActorKeyPair::from_pem( + include_str!("../fixtures/rsa-private-key.pem").to_string(), + include_str!("../fixtures/rsa-public-key.pem").to_string(), + ) +} diff --git a/crates/feder-runtime-server/tests/runtime.rs b/crates/feder-runtime-server/tests/runtime.rs new file mode 100644 index 0000000..2aa8296 --- /dev/null +++ b/crates/feder-runtime-server/tests/runtime.rs @@ -0,0 +1,25 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +mod common; + +#[path = "cases/actor.rs"] +mod actor; +#[path = "cases/app.rs"] +mod app; +#[path = "cases/inbox.rs"] +mod inbox; +#[path = "cases/webfinger.rs"] +mod webfinger; From f5d697836b7fb0d66d8e639628b502b8ec528f97 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 19 Jul 2026 13:07:44 +0900 Subject: [PATCH 06/72] Persist actor key pairs during startup Generate and persist a 4096-bit RSA key pair with operating system entropy when an actor has no stored key, and reuse the persisted pair on restart. Assisted-by: Codex:gpt-5.6-sol --- Cargo.lock | 15 +++++++++++++ Cargo.toml | 1 + crates/feder-runtime-server/Cargo.toml | 1 + crates/feder-runtime-server/src/app.rs | 28 +++++++++++++++++++----- crates/feder-runtime-server/src/error.rs | 3 +++ 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5b67a0c..c8bbcfc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -182,6 +182,7 @@ dependencies = [ "feder-core", "feder-vocab", "percent-encoding", + "rand_core", "rusqlite", "serde", "serde_json", @@ -263,6 +264,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -631,6 +643,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] [[package]] name = "regex-automata" diff --git a/Cargo.toml b/Cargo.toml index 27b7b94..e95e1c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ feder-runtime-server = { version = "0.1.0", path = "crates/feder-runtime-server" feder-vocab = { version = "0.1.0", path = "crates/feder-vocab" } iri-string = { version = "0.7.12", default-features = false, features = ["alloc", "serde"] } rand_chacha = { version = "0.3.1", default-features = false } +rand_core = { version = "0.6.4", features = ["getrandom"] } rsa = { version = "0.9.10", default-features = false, features = ["pem", "u64_digit"] } serde = { version = "1.0.219", default-features = false, features = ["alloc", "derive"] } serde_json = "1.0.140" diff --git a/crates/feder-runtime-server/Cargo.toml b/crates/feder-runtime-server/Cargo.toml index fa5b370..51e0761 100644 --- a/crates/feder-runtime-server/Cargo.toml +++ b/crates/feder-runtime-server/Cargo.toml @@ -16,6 +16,7 @@ serde.workspace = true thiserror = "2" serde_json.workspace = true percent-encoding = "2.3.2" +rand_core.workspace = true rusqlite = { version = "0.40.1", features = ["bundled"] } [dev-dependencies] diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs index 9fdcdca..7dcc368 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -17,18 +17,23 @@ use std::sync::{Arc, Mutex}; use crate::Error; use crate::config::{InboxAuthPolicy, RuntimeConfig, StorageConfig}; -use crate::storage::SqliteStore; +use crate::storage::{RuntimeStore, SqliteStore}; 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_core::{ + FederConfig, FederCore, + http_signatures::{ActorKeyPair, generate_actor_key_pair}, +}; use feder_vocab::Actor; +use rand_core::OsRng; #[derive(Clone)] pub struct AppState { pub core: Arc>, pub store: Arc>, + pub actor_key_pair: Arc, pub local_actor: Actor, pub username: String, pub handle_host: String, @@ -42,14 +47,23 @@ impl AppState { actor.name = Some(config.username.clone()); let core = FederCore::new(FederConfig::new(actor.clone())); - let store = match &config.storage { + let mut store = match &config.storage { StorageConfig::InMemory => SqliteStore::open_in_memory()?, StorageConfig::Sqlite { path } => SqliteStore::open(path)?, }; + let actor_key_pair = match store.load_actor_key_pair(&actor.id)? { + Some(key_pair) => key_pair, + None => { + let key_pair = generate_actor_key_pair(&mut OsRng)?; + store.insert_actor_key_pair(&actor.id, &key_pair)?; + key_pair + } + }; Ok(Self { core: Arc::new(Mutex::new(core)), store: Arc::new(Mutex::new(store)), + actor_key_pair: Arc::new(actor_key_pair), local_actor: actor, username: config.username, handle_host: config.handle_host, @@ -61,13 +75,17 @@ impl AppState { pub fn build_router(config: RuntimeConfig) -> Result { let state = AppState::from_config(config)?; - Ok(Router::new() + Ok(router_with_state(state)) +} + +pub fn router_with_state(state: AppState) -> Router { + Router::new() .route("/healthz", get(healthz)) .route("/.well-known/webfinger", get(webfinger)) .route("/users/{username}", get(actor)) .route("/users/{username}/inbox", post(inbox)) .layer(DefaultBodyLimit::max(1_048_576)) - .with_state(state)) + .with_state(state) } async fn healthz() -> StatusCode { diff --git a/crates/feder-runtime-server/src/error.rs b/crates/feder-runtime-server/src/error.rs index a031fa3..120f228 100644 --- a/crates/feder-runtime-server/src/error.rs +++ b/crates/feder-runtime-server/src/error.rs @@ -23,4 +23,7 @@ pub enum Error { #[error("storage failed")] Storage(#[from] crate::storage::StoreError), + + #[error("actor key generation failed")] + ActorKeyGeneration(#[from] feder_core::http_signatures::KeyError), } From aa359d2fe1420c0a5e97619fcd82aaac08d7808d Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 19 Jul 2026 13:21:52 +0900 Subject: [PATCH 07/72] Publish actor RSA public keys Attach the persisted RSA public key to the local actor as an embedded CryptographicKey using the #main-key identifier and security context. Assisted-by: Codex:gpt-5.6-sol --- Cargo.lock | 1 + crates/feder-runtime-server/Cargo.toml | 1 + crates/feder-runtime-server/src/app.rs | 14 ++++++++++++-- .../feder-runtime-server/tests/cases/actor.rs | 17 ++++++++++++++++- crates/feder-runtime-server/tests/cases/app.rs | 15 +++++++++++++++ crates/feder-runtime-server/tests/common/mod.rs | 14 ++++++++++++-- 6 files changed, 57 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c8bbcfc..e6be604 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -181,6 +181,7 @@ dependencies = [ "axum", "feder-core", "feder-vocab", + "iri-string", "percent-encoding", "rand_core", "rusqlite", diff --git a/crates/feder-runtime-server/Cargo.toml b/crates/feder-runtime-server/Cargo.toml index 51e0761..90123db 100644 --- a/crates/feder-runtime-server/Cargo.toml +++ b/crates/feder-runtime-server/Cargo.toml @@ -11,6 +11,7 @@ repository.workspace = true [dependencies] feder-core = { workspace = true, features = ["http-signatures"] } feder-vocab.workspace = true +iri-string.workspace = true axum = "0.8" serde.workspace = true thiserror = "2" diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs index 7dcc368..cb9d63f 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -26,7 +26,8 @@ use feder_core::{ FederConfig, FederCore, http_signatures::{ActorKeyPair, generate_actor_key_pair}, }; -use feder_vocab::Actor; +use feder_vocab::{Actor, CryptographicKey, Reference}; +use iri_string::types::IriFragmentStr; use rand_core::OsRng; #[derive(Clone)] @@ -46,7 +47,6 @@ impl AppState { actor.preferred_username = Some(config.username.clone()); actor.name = Some(config.username.clone()); - let core = FederCore::new(FederConfig::new(actor.clone())); let mut store = match &config.storage { StorageConfig::InMemory => SqliteStore::open_in_memory()?, StorageConfig::Sqlite { path } => SqliteStore::open(path)?, @@ -59,6 +59,16 @@ impl AppState { key_pair } }; + let mut key_id = actor.id.clone(); + key_id.set_fragment(Some( + IriFragmentStr::new("main-key").expect("main-key is a valid IRI fragment"), + )); + actor.set_public_key(Reference::object(CryptographicKey::new( + key_id, + actor.id.clone(), + actor_key_pair.public_key_pem().to_string(), + ))); + let core = FederCore::new(FederConfig::new(actor.clone())); Ok(Self { core: Arc::new(Mutex::new(core)), diff --git a/crates/feder-runtime-server/tests/cases/actor.rs b/crates/feder-runtime-server/tests/cases/actor.rs index 57a8314..951c051 100644 --- a/crates/feder-runtime-server/tests/cases/actor.rs +++ b/crates/feder-runtime-server/tests/cases/actor.rs @@ -47,13 +47,28 @@ async fn returns_local_actor() { .expect("read response body"); let json: Value = serde_json::from_slice(&body).expect("valid json"); - assert_eq!(json["@context"], "https://www.w3.org/ns/activitystreams"); + assert_eq!( + json["@context"], + serde_json::json!([ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1" + ]) + ); assert_eq!(json["type"], "Person"); assert_eq!(json["id"], "http://127.0.0.1:3000/users/alice"); assert_eq!(json["inbox"], "http://127.0.0.1:3000/users/alice/inbox"); assert_eq!(json["outbox"], "http://127.0.0.1:3000/users/alice/outbox"); assert_eq!(json["preferredUsername"], "alice"); assert_eq!(json["name"], "alice"); + assert_eq!( + json["publicKey"], + serde_json::json!({ + "id": "http://127.0.0.1:3000/users/alice#main-key", + "type": "CryptographicKey", + "owner": "http://127.0.0.1:3000/users/alice", + "publicKeyPem": include_str!("../fixtures/rsa-public-key.pem"), + }) + ); } #[tokio::test] diff --git a/crates/feder-runtime-server/tests/cases/app.rs b/crates/feder-runtime-server/tests/cases/app.rs index 510ec4f..6fd8552 100644 --- a/crates/feder-runtime-server/tests/cases/app.rs +++ b/crates/feder-runtime-server/tests/cases/app.rs @@ -18,6 +18,7 @@ use axum::{ http::{Request, StatusCode}, }; use feder_runtime_server::{AppState, config::StorageConfig, storage::RuntimeStore}; +use feder_vocab::Reference; use tower::ServiceExt; use crate::common::{temporary_database_path, test_config, test_router}; @@ -46,6 +47,20 @@ fn startup_generates_then_reuses_persisted_actor_key_pair() { config.storage = StorageConfig::Sqlite { path: path.clone() }; let first = AppState::from_config(config).expect("build first app state"); let expected_public_key = first.actor_key_pair.public_key_pem().to_string(); + let Reference::Object(published_key) = first + .local_actor + .public_key + .as_ref() + .expect("actor publishes public key") + else { + panic!("actor public key should be embedded"); + }; + assert_eq!( + published_key.id.as_str(), + "http://127.0.0.1:3000/users/alice#main-key" + ); + assert_eq!(published_key.owner, first.local_actor.id); + assert_eq!(published_key.public_key_pem, expected_public_key); let stored = first .store .lock() diff --git a/crates/feder-runtime-server/tests/common/mod.rs b/crates/feder-runtime-server/tests/common/mod.rs index 76843d2..aa97a37 100644 --- a/crates/feder-runtime-server/tests/common/mod.rs +++ b/crates/feder-runtime-server/tests/common/mod.rs @@ -23,7 +23,8 @@ use feder_runtime_server::{ config::{InboxAuthPolicy, RuntimeConfig, StorageConfig}, storage::{RuntimeStore, SqliteStore}, }; -use feder_vocab::Actor; +use feder_vocab::{Actor, CryptographicKey, Reference}; +use iri_string::types::IriFragmentStr; pub fn test_config() -> RuntimeConfig { RuntimeConfig { @@ -49,7 +50,6 @@ pub fn test_app_state(config: RuntimeConfig) -> Result { actor.preferred_username = Some(config.username.clone()); actor.name = Some(config.username.clone()); - let core = FederCore::new(FederConfig::new(actor.clone())); let mut store = match &config.storage { StorageConfig::InMemory => SqliteStore::open_in_memory()?, StorageConfig::Sqlite { path } => SqliteStore::open(path)?, @@ -62,6 +62,16 @@ pub fn test_app_state(config: RuntimeConfig) -> Result { key_pair } }; + let mut key_id = actor.id.clone(); + key_id.set_fragment(Some( + IriFragmentStr::new("main-key").expect("main-key is a valid IRI fragment"), + )); + actor.set_public_key(Reference::object(CryptographicKey::new( + key_id, + actor.id.clone(), + actor_key_pair.public_key_pem().to_string(), + ))); + let core = FederCore::new(FederConfig::new(actor.clone())); Ok(AppState { core: Arc::new(Mutex::new(core)), From a905e26b84f8a232f403a3bfe4ebdcf99d22aa8d Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 19 Jul 2026 13:49:56 +0900 Subject: [PATCH 08/72] Execute outgoing activity deliveries Deliver SendActivity actions synchronously to recipient inboxes as ActivityPub JSON, report remote failures, and continue attempting later recipients after individual delivery failures. Assisted-by: Codex:gpt-5.6-sol --- Cargo.lock | 1000 ++++++++++++++++- crates/feder-runtime-server/Cargo.toml | 3 +- crates/feder-runtime-server/README.md | 5 +- crates/feder-runtime-server/src/app.rs | 4 + crates/feder-runtime-server/src/error.rs | 3 + crates/feder-runtime-server/src/inbox.rs | 12 + crates/feder-runtime-server/src/lib.rs | 1 + crates/feder-runtime-server/src/send.rs | 93 ++ .../feder-runtime-server/tests/cases/inbox.rs | 78 +- .../feder-runtime-server/tests/cases/send.rs | 93 ++ .../feder-runtime-server/tests/common/mod.rs | 46 +- crates/feder-runtime-server/tests/runtime.rs | 2 + 12 files changed, 1308 insertions(+), 32 deletions(-) create mode 100644 crates/feder-runtime-server/src/send.rs create mode 100644 crates/feder-runtime-server/tests/cases/send.rs diff --git a/Cargo.lock b/Cargo.lock index e6be604..3bb4867 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.8.9" @@ -75,6 +98,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.8.3" @@ -106,6 +135,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -115,12 +146,73 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -152,6 +244,23 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -183,7 +292,8 @@ dependencies = [ "feder-vocab", "iri-string", "percent-encoding", - "rand_core", + "rand_core 0.6.4", + "reqwest", "rusqlite", "serde", "serde_json", @@ -222,6 +332,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.32" @@ -272,8 +388,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -366,6 +498,22 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", ] [[package]] @@ -374,15 +522,132 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "iri-string" version = "0.7.12" @@ -398,6 +663,65 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.103" @@ -405,6 +729,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -440,12 +765,24 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "log" version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -481,7 +818,7 @@ checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -490,7 +827,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -504,7 +841,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.7", "smallvec", "zeroize", ] @@ -544,6 +881,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -592,6 +935,15 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -610,6 +962,63 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -619,6 +1028,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.7" @@ -626,7 +1041,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -636,7 +1062,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -645,7 +1071,22 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", ] [[package]] @@ -666,19 +1107,68 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] -name = "rsa" -version = "0.9.10" +name = "reqwest" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "const-oid", + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", "digest", "num-bigint-dig", "num-integer", "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", @@ -710,6 +1200,96 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -722,6 +1302,53 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -810,9 +1437,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "single-user-server" version = "0.1.0" @@ -843,7 +1486,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -874,6 +1517,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "subtle" version = "2.6.1" @@ -896,6 +1545,20 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "thiserror" @@ -926,18 +1589,44 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ + "bytes", "libc", "mio", "pin-project-lite", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -951,6 +1640,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tower" version = "0.5.3" @@ -967,6 +1666,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -1041,6 +1758,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.1" @@ -1053,6 +1776,30 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "valuable" version = "0.1.1" @@ -1071,6 +1818,25 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1090,6 +1856,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.126" @@ -1122,12 +1898,59 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1137,6 +1960,99 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.54" @@ -1157,12 +2073,66 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/crates/feder-runtime-server/Cargo.toml b/crates/feder-runtime-server/Cargo.toml index 90123db..f48b026 100644 --- a/crates/feder-runtime-server/Cargo.toml +++ b/crates/feder-runtime-server/Cargo.toml @@ -18,11 +18,12 @@ thiserror = "2" serde_json.workspace = true percent-encoding = "2.3.2" rand_core.workspace = true +reqwest = { version = "0.13.1", default-features = false, features = ["rustls"] } rusqlite = { version = "0.40.1", features = ["bundled"] } [dev-dependencies] serde_json.workspace = true -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "sync"] } tower.workspace = true [lints] diff --git a/crates/feder-runtime-server/README.md b/crates/feder-runtime-server/README.md index d14408a..44ab613 100644 --- a/crates/feder-runtime-server/README.md +++ b/crates/feder-runtime-server/README.md @@ -11,8 +11,9 @@ handle hosts. 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. +storage for persisted follower state. Outgoing `SendActivity` actions are sent +synchronously to recipient inboxes as ActivityPub JSON. HTTP signature creation +and verification 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 cb9d63f..57a036e 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -17,6 +17,7 @@ use std::sync::{Arc, Mutex}; use crate::Error; use crate::config::{InboxAuthPolicy, RuntimeConfig, StorageConfig}; +use crate::send::ActivitySender; use crate::storage::{RuntimeStore, SqliteStore}; use crate::webfinger::webfinger; use crate::{actor::actor, inbox::inbox}; @@ -35,6 +36,7 @@ pub struct AppState { pub core: Arc>, pub store: Arc>, pub actor_key_pair: Arc, + pub activity_sender: ActivitySender, pub local_actor: Actor, pub username: String, pub handle_host: String, @@ -69,11 +71,13 @@ impl AppState { actor_key_pair.public_key_pem().to_string(), ))); let core = FederCore::new(FederConfig::new(actor.clone())); + let activity_sender = ActivitySender::new()?; Ok(Self { core: Arc::new(Mutex::new(core)), store: Arc::new(Mutex::new(store)), actor_key_pair: Arc::new(actor_key_pair), + activity_sender, local_actor: actor, username: config.username, handle_host: config.handle_host, diff --git a/crates/feder-runtime-server/src/error.rs b/crates/feder-runtime-server/src/error.rs index 120f228..c3aa76c 100644 --- a/crates/feder-runtime-server/src/error.rs +++ b/crates/feder-runtime-server/src/error.rs @@ -26,4 +26,7 @@ pub enum Error { #[error("actor key generation failed")] ActorKeyGeneration(#[from] feder_core::http_signatures::KeyError), + + #[error("activity sender setup failed")] + ActivitySender(#[from] crate::send::SendError), } diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index ccd207a..65f8fe1 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -26,6 +26,7 @@ use serde_json::{Value, from_slice, from_value}; use crate::app::AppState; use crate::config::InboxAuthPolicy; +use crate::send::SendError; use crate::storage::RuntimeStore; pub struct InboxRequest { @@ -116,5 +117,16 @@ pub async fn inbox( .persist_actions(&result.actions) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + app_state + .activity_sender + .send_actions(&result.actions) + .await + .map_err(|error| match error { + SendError::Request(_) | SendError::UnsuccessfulStatus { .. } => StatusCode::BAD_GATEWAY, + SendError::BuildClient(_) + | SendError::Serialize(_) + | SendError::UnsupportedActivity => StatusCode::INTERNAL_SERVER_ERROR, + })?; + Ok(StatusCode::ACCEPTED.into_response()) } diff --git a/crates/feder-runtime-server/src/lib.rs b/crates/feder-runtime-server/src/lib.rs index 598520f..7fc5da5 100644 --- a/crates/feder-runtime-server/src/lib.rs +++ b/crates/feder-runtime-server/src/lib.rs @@ -18,6 +18,7 @@ pub mod app; pub mod config; pub mod error; pub mod inbox; +pub mod send; pub mod storage; pub mod webfinger; diff --git a/crates/feder-runtime-server/src/send.rs b/crates/feder-runtime-server/src/send.rs new file mode 100644 index 0000000..6ada4ef --- /dev/null +++ b/crates/feder-runtime-server/src/send.rs @@ -0,0 +1,93 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use feder_core::{Action, Activity, SendActivity}; +use reqwest::{Client, StatusCode, redirect::Policy}; + +#[derive(Clone, Debug)] +pub struct ActivitySender { + client: Client, +} + +impl ActivitySender { + pub fn new() -> Result { + let client = Client::builder() + .redirect(Policy::none()) + .build() + .map_err(SendError::BuildClient)?; + + Ok(Self { client }) + } + + pub async fn send_actions(&self, actions: &[Action]) -> Result<(), SendError> { + let mut first_error = None; + + for action in actions { + if let Action::SendActivity(send) = action + && let Err(error) = self.send(send).await + && first_error.is_none() + { + first_error = Some(error); + } + } + + first_error.map_or(Ok(()), Err) + } + + async fn send(&self, send: &SendActivity) -> Result<(), SendError> { + let body = match &send.activity { + Activity::Accept(activity) => serde_json::to_vec(activity), + Activity::CreateNote(activity) => serde_json::to_vec(activity), + _ => return Err(SendError::UnsupportedActivity), + } + .map_err(SendError::Serialize)?; + + let response = self + .client + .post(send.inbox.as_str()) + .header(reqwest::header::CONTENT_TYPE, "application/activity+json") + .body(body) + .send() + .await + .map_err(SendError::Request)?; + + if !response.status().is_success() { + return Err(SendError::UnsuccessfulStatus { + inbox: send.inbox.to_string(), + status: response.status(), + }); + } + + Ok(()) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum SendError { + #[error("failed to build HTTP client")] + BuildClient(#[source] reqwest::Error), + + #[error("failed to serialize activity")] + Serialize(#[source] serde_json::Error), + + #[error("failed to send activity")] + Request(#[source] reqwest::Error), + + #[error("sending activity to {inbox} returned {status}")] + UnsuccessfulStatus { inbox: String, status: StatusCode }, + + #[error("activity type is not supported for sending")] + UnsupportedActivity, +} diff --git a/crates/feder-runtime-server/tests/cases/inbox.rs b/crates/feder-runtime-server/tests/cases/inbox.rs index c9652e3..a46d3c5 100644 --- a/crates/feder-runtime-server/tests/cases/inbox.rs +++ b/crates/feder-runtime-server/tests/cases/inbox.rs @@ -26,9 +26,15 @@ use feder_runtime_server::{ use serde_json::json; use tower::ServiceExt; -use crate::common::{temporary_database_path, test_app_state, test_config, test_router}; +use crate::common::{ + spawn_inbox_server, temporary_database_path, test_app_state, test_config, test_router, +}; fn follow_body() -> Vec { + follow_body_for_inbox("https://remote.example/users/bob/inbox") +} + +fn follow_body_for_inbox(inbox: &str) -> Vec { serde_json::to_vec(&json!({ "@context": "https://www.w3.org/ns/activitystreams", "type": "Follow", @@ -37,7 +43,7 @@ fn follow_body() -> Vec { "@context": "https://www.w3.org/ns/activitystreams", "type": "Person", "id": "https://remote.example/users/bob", - "inbox": "https://remote.example/users/bob/inbox", + "inbox": inbox, "outbox": "https://remote.example/users/bob/outbox" }, "object": "http://127.0.0.1:3000/users/alice" @@ -65,32 +71,75 @@ async fn post_inbox( #[tokio::test] async fn valid_follow_reaches_core() { + let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; let state = test_app_state(test_config()).expect("build app state"); let response = post_inbox( router_with_state(state.clone()), "/users/alice/inbox", "application/activity+json", - follow_body(), + follow_body_for_inbox(&inbox), ) .await; assert_eq!(response.status(), StatusCode::ACCEPTED); - let core = state.core.lock().expect("core lock"); - assert_eq!(core.state().followers().len(), 1); + { + let core = state.core.lock().expect("core lock"); + assert_eq!(core.state().followers().len(), 1); + assert_eq!( + core.state().followers()[0].follower.as_str(), + "https://remote.example/users/bob" + ); + assert_eq!( + 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!(core.state().delivery_targets()[0].inbox.as_str(), inbox); + } + + let request = requests.recv().await.expect("receive Accept request"); assert_eq!( - core.state().followers()[0].follower.as_str(), - "https://remote.example/users/bob" + request.headers.get(CONTENT_TYPE).unwrap(), + "application/activity+json" ); + let activity: serde_json::Value = + serde_json::from_slice(&request.body).expect("valid sent activity"); + assert_eq!(activity["type"], "Accept"); + assert_eq!(activity["actor"], "http://127.0.0.1:3000/users/alice"); assert_eq!( - core.state().followers()[0].following.as_str(), - "http://127.0.0.1:3000/users/alice" + activity["object"]["id"], + "https://remote.example/activities/follow-1" ); - assert_eq!(core.state().delivery_targets().len(), 1); + inbox_server.abort(); +} + +#[tokio::test] +async fn send_failure_returns_bad_gateway_after_core_handling() { + let (inbox, mut requests, inbox_server) = + spawn_inbox_server(StatusCode::INTERNAL_SERVER_ERROR).await; + let state = test_app_state(test_config()).expect("build app state"); + let response = post_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + "application/activity+json", + follow_body_for_inbox(&inbox), + ) + .await; + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); assert_eq!( - core.state().delivery_targets()[0].inbox.as_str(), - "https://remote.example/users/bob/inbox" + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .len(), + 1 ); + requests.recv().await.expect("receive failed request"); + inbox_server.abort(); } #[tokio::test] @@ -236,6 +285,7 @@ async fn rejects_oversized_inbox_body() { #[tokio::test] async fn sqlite_storage_persists_followers_across_app_state_reopen() { + let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; let path = temporary_database_path("feder-runtime-server-test"); let mut config = test_config(); config.storage = StorageConfig::Sqlite { path: path.clone() }; @@ -244,11 +294,13 @@ async fn sqlite_storage_persists_followers_across_app_state_reopen() { router_with_state(state.clone()), "/users/alice/inbox", "application/activity+json", - follow_body(), + follow_body_for_inbox(&inbox), ) .await; assert_eq!(response.status(), StatusCode::ACCEPTED); + requests.recv().await.expect("receive Accept request"); + inbox_server.abort(); drop(state); let mut config = test_config(); diff --git a/crates/feder-runtime-server/tests/cases/send.rs b/crates/feder-runtime-server/tests/cases/send.rs new file mode 100644 index 0000000..419a9ad --- /dev/null +++ b/crates/feder-runtime-server/tests/cases/send.rs @@ -0,0 +1,93 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use axum::http::StatusCode; +use feder_core::{Action, Activity, SendActivity}; +use feder_runtime_server::send::ActivitySender; +use feder_vocab::{Create, Note, Reference}; + +use crate::common::spawn_inbox_server; + +fn create_note_send_action(inbox: &str) -> Action { + let actor_id = "https://local.example/users/alice" + .parse() + .expect("valid actor IRI"); + let note = Note::new( + "https://local.example/notes/1" + .parse() + .expect("valid note IRI"), + ); + let create = Create::new( + "https://local.example/activities/create-1" + .parse() + .expect("valid activity IRI"), + Reference::id(actor_id), + Reference::object(note), + ); + + Action::SendActivity(SendActivity { + activity: Activity::CreateNote(create), + inbox: inbox.parse().expect("valid inbox IRI"), + }) +} + +#[tokio::test] +async fn sends_create_note_action() { + let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; + let actions = [create_note_send_action(&inbox)]; + + ActivitySender::new() + .expect("build activity sender") + .send_actions(&actions) + .await + .expect("send Create activity"); + + let request = requests.recv().await.expect("receive Create request"); + let activity: serde_json::Value = + serde_json::from_slice(&request.body).expect("valid sent activity"); + assert_eq!(activity["type"], "Create"); + assert_eq!(activity["actor"], "https://local.example/users/alice"); + assert_eq!(activity["object"]["type"], "Note"); + inbox_server.abort(); +} + +#[tokio::test] +async fn attempts_later_sends_after_failure() { + let (failed_inbox, mut failed_requests, failed_server) = + spawn_inbox_server(StatusCode::INTERNAL_SERVER_ERROR).await; + let (successful_inbox, mut successful_requests, successful_server) = + spawn_inbox_server(StatusCode::ACCEPTED).await; + let actions = [ + create_note_send_action(&failed_inbox), + create_note_send_action(&successful_inbox), + ]; + + let result = ActivitySender::new() + .expect("build activity sender") + .send_actions(&actions) + .await; + + assert!(result.is_err()); + failed_requests + .recv() + .await + .expect("receive failed request"); + successful_requests + .recv() + .await + .expect("receive later request"); + failed_server.abort(); + successful_server.abort(); +} diff --git a/crates/feder-runtime-server/tests/common/mod.rs b/crates/feder-runtime-server/tests/common/mod.rs index aa97a37..13e7d15 100644 --- a/crates/feder-runtime-server/tests/common/mod.rs +++ b/crates/feder-runtime-server/tests/common/mod.rs @@ -15,16 +15,58 @@ use std::sync::{Arc, Mutex}; -use axum::Router; +use axum::{ + Router, + body::Bytes, + http::{HeaderMap, StatusCode}, + routing::post, +}; use feder_core::{FederConfig, FederCore, http_signatures::ActorKeyPair}; use feder_runtime_server::{ Error, app::{AppState, router_with_state}, config::{InboxAuthPolicy, RuntimeConfig, StorageConfig}, + send::ActivitySender, storage::{RuntimeStore, SqliteStore}, }; use feder_vocab::{Actor, CryptographicKey, Reference}; use iri_string::types::IriFragmentStr; +use tokio::{sync::mpsc, task::JoinHandle}; + +pub struct RecordedRequest { + pub headers: HeaderMap, + pub body: Bytes, +} + +pub async fn spawn_inbox_server( + response_status: StatusCode, +) -> (String, mpsc::Receiver, JoinHandle<()>) { + let (sender, receiver) = mpsc::channel(1); + let app = Router::new().route( + "/inbox", + post(move |headers: HeaderMap, body: Bytes| { + let sender = sender.clone(); + async move { + sender + .send(RecordedRequest { headers, body }) + .await + .expect("request receiver remains open"); + response_status + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind inbox server"); + let address = listener.local_addr().expect("inbox server address"); + let task = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("serve inbox endpoint"); + }); + + (format!("http://{address}/inbox"), receiver, task) +} pub fn test_config() -> RuntimeConfig { RuntimeConfig { @@ -72,11 +114,13 @@ pub fn test_app_state(config: RuntimeConfig) -> Result { actor_key_pair.public_key_pem().to_string(), ))); let core = FederCore::new(FederConfig::new(actor.clone())); + let activity_sender = ActivitySender::new()?; Ok(AppState { core: Arc::new(Mutex::new(core)), store: Arc::new(Mutex::new(store)), actor_key_pair: Arc::new(actor_key_pair), + activity_sender, local_actor: actor, username: config.username, handle_host: config.handle_host, diff --git a/crates/feder-runtime-server/tests/runtime.rs b/crates/feder-runtime-server/tests/runtime.rs index 2aa8296..0b2690e 100644 --- a/crates/feder-runtime-server/tests/runtime.rs +++ b/crates/feder-runtime-server/tests/runtime.rs @@ -21,5 +21,7 @@ mod actor; mod app; #[path = "cases/inbox.rs"] mod inbox; +#[path = "cases/send.rs"] +mod send; #[path = "cases/webfinger.rs"] mod webfinger; From 9835dc9acc77fd128119579f8b0db42f8a39c93a Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 19 Jul 2026 14:05:32 +0900 Subject: [PATCH 09/72] Sign outgoing ActivityPub requests Create draft-Cavage RSA signatures in the portable core and apply them to outgoing ActivityPub requests using the persisted actor key and matching #main-key identifier. Assisted-by: Codex:gpt-5.6-sol --- Cargo.lock | 35 ++++- Cargo.toml | 3 +- crates/feder-core/Cargo.toml | 3 +- crates/feder-core/src/http_signatures.rs | 137 +++++++++++++++++- crates/feder-runtime-server/Cargo.toml | 1 + crates/feder-runtime-server/README.md | 5 +- crates/feder-runtime-server/src/app.rs | 7 +- crates/feder-runtime-server/src/inbox.rs | 2 + crates/feder-runtime-server/src/send.rs | 67 ++++++++- .../feder-runtime-server/tests/cases/send.rs | 27 +++- .../feder-runtime-server/tests/common/mod.rs | 15 +- 11 files changed, 275 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3bb4867..366bde9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,6 +116,15 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -159,7 +168,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.0", "rand_core 0.10.1", ] @@ -204,6 +213,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -240,6 +258,7 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ + "block-buffer", "const-oid", "crypto-common", ] @@ -277,6 +296,7 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" name = "feder-core" version = "0.1.0" dependencies = [ + "base64", "feder-vocab", "rand_chacha", "rsa", @@ -290,6 +310,7 @@ dependencies = [ "axum", "feder-core", "feder-vocab", + "httpdate", "iri-string", "percent-encoding", "rand_core 0.6.4", @@ -1169,6 +1190,7 @@ dependencies = [ "pkcs1", "pkcs8", "rand_core 0.6.4", + "sha2", "signature", "spki", "subtle", @@ -1415,6 +1437,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" diff --git a/Cargo.toml b/Cargo.toml index e95e1c4..492eb2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,13 +18,14 @@ repository = "https://github.com/fedify-dev/feder" [workspace.dependencies] # Keep workspace crate versions in sync with [workspace.package].version. # Use `mise run bump-execute ` instead of editing these by hand. +base64 = { version = "0.22.1", default-features = false, features = ["alloc"] } feder-core = { version = "0.1.0", path = "crates/feder-core" } feder-runtime-server = { version = "0.1.0", path = "crates/feder-runtime-server" } feder-vocab = { version = "0.1.0", path = "crates/feder-vocab" } iri-string = { version = "0.7.12", default-features = false, features = ["alloc", "serde"] } rand_chacha = { version = "0.3.1", default-features = false } rand_core = { version = "0.6.4", features = ["getrandom"] } -rsa = { version = "0.9.10", default-features = false, features = ["pem", "u64_digit"] } +rsa = { version = "0.9.10", default-features = false, features = ["pem", "sha2", "u64_digit"] } serde = { version = "1.0.219", default-features = false, features = ["alloc", "derive"] } serde_json = "1.0.140" tower = { version = "0.5", features = ["util"]} diff --git a/crates/feder-core/Cargo.toml b/crates/feder-core/Cargo.toml index 2cd3458..ab5bb5b 100644 --- a/crates/feder-core/Cargo.toml +++ b/crates/feder-core/Cargo.toml @@ -9,9 +9,10 @@ homepage.workspace = true repository.workspace = true [features] -http-signatures = ["dep:rsa", "dep:zeroize"] +http-signatures = ["dep:base64", "dep:rsa", "dep:zeroize"] [dependencies] +base64 = { workspace = true, optional = true } feder-vocab.workspace = true rsa = { workspace = true, optional = true } zeroize = { workspace = true, optional = true } diff --git a/crates/feder-core/src/http_signatures.rs b/crates/feder-core/src/http_signatures.rs index fb23dc5..6c9493d 100644 --- a/crates/feder-core/src/http_signatures.rs +++ b/crates/feder-core/src/http_signatures.rs @@ -1,12 +1,16 @@ //! Draft-Cavage HTTP Signature primitives. -use alloc::string::String; +use alloc::{format, string::String, vec::Vec}; use core::fmt; +use base64::{Engine as _, engine::general_purpose::STANDARD}; use rsa::{ RsaPrivateKey, RsaPublicKey, + pkcs1v15::SigningKey, pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey, LineEnding}, rand_core::CryptoRngCore, + sha2::{Digest, Sha256}, + signature::{SignatureEncoding, Signer}, }; use zeroize::Zeroizing; @@ -105,6 +109,75 @@ pub fn generate_actor_key_pair( }) } +/// Creates an RFC 3230 SHA-256 digest header. +#[must_use] +pub fn create_sha256_digest_header(body: &[u8]) -> String { + let digest = Sha256::digest(body); + format!("SHA-256={}", STANDARD.encode(digest)) +} + +/// Signs a prepared HTTP request using the draft-Cavage header format. +/// +/// Header names must be supplied in the order in which they should appear in +/// the signature's `headers` parameter. +pub fn sign_draft_cavage( + key_pair: &ActorKeyPair, + key_id: &str, + method: &str, + request_target: &str, + headers: &[(&str, &str)], +) -> Result { + let signature_base = draft_cavage_signature_base(method, request_target, headers); + let private_key = RsaPrivateKey::from_pkcs8_pem(key_pair.private_key_pem()) + .map_err(HttpSignatureError::InvalidPrivateKey)?; + let signing_key = SigningKey::::new(private_key); + let signature = signing_key.sign(signature_base.as_bytes()).to_bytes(); + let signed_headers = headers + .iter() + .map(|(name, _)| name.to_ascii_lowercase()) + .collect::>() + .join(" "); + + Ok(format!( + "keyId=\"{key_id}\",algorithm=\"rsa-sha256\",headers=\"(request-target) {signed_headers}\",signature=\"{}\"", + STANDARD.encode(signature) + )) +} + +fn draft_cavage_signature_base( + method: &str, + request_target: &str, + headers: &[(&str, &str)], +) -> String { + let mut lines = Vec::with_capacity(headers.len() + 1); + lines.push(format!( + "(request-target): {} {request_target}", + method.to_ascii_lowercase() + )); + lines.extend( + headers + .iter() + .map(|(name, value)| format!("{}: {}", name.to_ascii_lowercase(), value.trim())), + ); + lines.join("\n") +} + +/// Errors produced while creating an HTTP signature. +#[derive(Debug)] +pub enum HttpSignatureError { + InvalidPrivateKey(rsa::pkcs8::Error), +} + +impl fmt::Display for HttpSignatureError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidPrivateKey(_) => formatter.write_str("invalid RSA private key PEM"), + } + } +} + +impl core::error::Error for HttpSignatureError {} + #[cfg(test)] mod tests { use alloc::string::ToString; @@ -112,6 +185,10 @@ mod tests { use rand_chacha::ChaCha20Rng; use rsa::rand_core::SeedableRng; use rsa::traits::PublicKeyParts; + use rsa::{ + pkcs1v15::{Signature, VerifyingKey}, + signature::Verifier, + }; use super::*; @@ -153,6 +230,64 @@ mod tests { assert!(!debug.contains(pair.private_key_pem())); } + #[test] + fn sha256_digest_matches_known_vector() { + assert_eq!( + create_sha256_digest_header(b"Hello, world!"), + "SHA-256=MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=" + ); + } + + #[test] + fn draft_cavage_signature_preserves_header_order() { + let pair = ActorKeyPair::from_pem(PRIVATE_KEY_PEM.to_string(), PUBLIC_KEY_PEM.to_string()) + .expect("load actor key pair fixture"); + let headers = [ + ("accept", "text/plain"), + ("content-type", "text/plain; charset=utf-8"), + ("date", "Tue, 05 Mar 2024 07:49:44 GMT"), + ( + "digest", + "SHA-256=MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=", + ), + ("host", "example.com"), + ]; + + let signature_header = + sign_draft_cavage(&pair, "https://example.com/key", "POST", "/", &headers) + .expect("sign request"); + + assert!(signature_header.starts_with( + "keyId=\"https://example.com/key\",algorithm=\"rsa-sha256\",headers=\"(request-target) accept content-type date digest host\",signature=\"" + )); + let signature_prefix = signature_header_prefix(&headers); + let signature = signature_header + .strip_prefix(&signature_prefix) + .and_then(|value| value.strip_suffix('"')) + .expect("signature parameter"); + let signature = STANDARD.decode(signature).expect("base64 signature"); + let signature = Signature::try_from(signature.as_slice()).expect("RSA signature"); + let public_key = + RsaPublicKey::from_public_key_pem(pair.public_key_pem()).expect("parse public key"); + let verifying_key = VerifyingKey::::new(public_key); + let signature_base = draft_cavage_signature_base("POST", "/", &headers); + + verifying_key + .verify(signature_base.as_bytes(), &signature) + .expect("verify signature"); + } + + fn signature_header_prefix(headers: &[(&str, &str)]) -> String { + let signed_headers = headers + .iter() + .map(|(name, _)| name.to_ascii_lowercase()) + .collect::>() + .join(" "); + format!( + "keyId=\"https://example.com/key\",algorithm=\"rsa-sha256\",headers=\"(request-target) {signed_headers}\",signature=\"" + ) + } + fn test_rng(seed: u8) -> ChaCha20Rng { ChaCha20Rng::from_seed([seed; 32]) } diff --git a/crates/feder-runtime-server/Cargo.toml b/crates/feder-runtime-server/Cargo.toml index f48b026..f6e6f90 100644 --- a/crates/feder-runtime-server/Cargo.toml +++ b/crates/feder-runtime-server/Cargo.toml @@ -13,6 +13,7 @@ feder-core = { workspace = true, features = ["http-signatures"] } feder-vocab.workspace = true iri-string.workspace = true axum = "0.8" +httpdate = "1" serde.workspace = true thiserror = "2" serde_json.workspace = true diff --git a/crates/feder-runtime-server/README.md b/crates/feder-runtime-server/README.md index 44ab613..9a3d470 100644 --- a/crates/feder-runtime-server/README.md +++ b/crates/feder-runtime-server/README.md @@ -12,8 +12,9 @@ handle hosts. 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. Outgoing `SendActivity` actions are sent -synchronously to recipient inboxes as ActivityPub JSON. HTTP signature creation -and verification are intentionally left to later issues. +synchronously to recipient inboxes as ActivityPub JSON signed with the actor's +draft-Cavage RSA key. HTTP signature verification is intentionally left to a +later issue. Example diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs index 57a036e..418cd35 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -66,17 +66,18 @@ impl AppState { IriFragmentStr::new("main-key").expect("main-key is a valid IRI fragment"), )); actor.set_public_key(Reference::object(CryptographicKey::new( - key_id, + key_id.clone(), actor.id.clone(), actor_key_pair.public_key_pem().to_string(), ))); let core = FederCore::new(FederConfig::new(actor.clone())); - let activity_sender = ActivitySender::new()?; + let actor_key_pair = Arc::new(actor_key_pair); + let activity_sender = ActivitySender::new(actor_key_pair.clone(), key_id.to_string())?; Ok(Self { core: Arc::new(Mutex::new(core)), store: Arc::new(Mutex::new(store)), - actor_key_pair: Arc::new(actor_key_pair), + actor_key_pair, activity_sender, 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 65f8fe1..c7a9972 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -124,7 +124,9 @@ pub async fn inbox( .map_err(|error| match error { SendError::Request(_) | SendError::UnsuccessfulStatus { .. } => StatusCode::BAD_GATEWAY, SendError::BuildClient(_) + | SendError::InvalidInbox(_) | SendError::Serialize(_) + | SendError::Sign(_) | SendError::UnsupportedActivity => StatusCode::INTERNAL_SERVER_ERROR, })?; diff --git a/crates/feder-runtime-server/src/send.rs b/crates/feder-runtime-server/src/send.rs index 6ada4ef..90c89b6 100644 --- a/crates/feder-runtime-server/src/send.rs +++ b/crates/feder-runtime-server/src/send.rs @@ -13,24 +13,44 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use feder_core::{Action, Activity, SendActivity}; -use reqwest::{Client, StatusCode, redirect::Policy}; - +use std::{sync::Arc, time::SystemTime}; + +use feder_core::{ + Action, Activity, SendActivity, + http_signatures::{ + ActorKeyPair, HttpSignatureError, create_sha256_digest_header, sign_draft_cavage, + }, +}; +use reqwest::{ + Client, StatusCode, Url, + header::{CONTENT_TYPE, DATE, HOST}, + redirect::Policy, +}; + +/// Sends core `SendActivity` actions as signed ActivityPub HTTP requests. #[derive(Clone, Debug)] pub struct ActivitySender { client: Client, + key_pair: Arc, + key_id: String, } impl ActivitySender { - pub fn new() -> Result { + /// Creates an activity sender for one actor identity. + pub fn new(key_pair: Arc, key_id: String) -> Result { let client = Client::builder() .redirect(Policy::none()) .build() .map_err(SendError::BuildClient)?; - Ok(Self { client }) + Ok(Self { + client, + key_pair, + key_id, + }) } + /// Attempts every send action and returns the first error encountered. pub async fn send_actions(&self, actions: &[Action]) -> Result<(), SendError> { let mut first_error = None; @@ -53,11 +73,38 @@ impl ActivitySender { _ => return Err(SendError::UnsupportedActivity), } .map_err(SendError::Serialize)?; + let url = Url::parse(send.inbox.as_str()) + .map_err(|_| SendError::InvalidInbox(send.inbox.to_string()))?; + if !matches!(url.scheme(), "http" | "https") { + return Err(SendError::InvalidInbox(send.inbox.to_string())); + } + let mut host = url + .host() + .ok_or_else(|| SendError::InvalidInbox(send.inbox.to_string()))? + .to_string(); + if let Some(port) = url.port() { + host = format!("{host}:{port}"); + } + let date = httpdate::fmt_http_date(SystemTime::now()); + let digest = create_sha256_digest_header(&body); + let headers = [ + ("content-type", "application/activity+json"), + ("date", date.as_str()), + ("digest", digest.as_str()), + ("host", host.as_str()), + ]; + let signature = + sign_draft_cavage(&self.key_pair, &self.key_id, "POST", url.path(), &headers) + .map_err(SendError::Sign)?; let response = self .client - .post(send.inbox.as_str()) - .header(reqwest::header::CONTENT_TYPE, "application/activity+json") + .post(url) + .header(CONTENT_TYPE, "application/activity+json") + .header(DATE, date) + .header("Digest", digest) + .header(HOST, host) + .header("Signature", signature) .body(body) .send() .await @@ -82,6 +129,12 @@ pub enum SendError { #[error("failed to serialize activity")] Serialize(#[source] serde_json::Error), + #[error("invalid recipient inbox: {0}")] + InvalidInbox(String), + + #[error("failed to sign activity request")] + Sign(#[source] HttpSignatureError), + #[error("failed to send activity")] Request(#[source] reqwest::Error), diff --git a/crates/feder-runtime-server/tests/cases/send.rs b/crates/feder-runtime-server/tests/cases/send.rs index 419a9ad..9624037 100644 --- a/crates/feder-runtime-server/tests/cases/send.rs +++ b/crates/feder-runtime-server/tests/cases/send.rs @@ -15,10 +15,9 @@ use axum::http::StatusCode; use feder_core::{Action, Activity, SendActivity}; -use feder_runtime_server::send::ActivitySender; use feder_vocab::{Create, Note, Reference}; -use crate::common::spawn_inbox_server; +use crate::common::{spawn_inbox_server, test_activity_sender}; fn create_note_send_action(inbox: &str) -> Action { let actor_id = "https://local.example/users/alice" @@ -48,13 +47,28 @@ async fn sends_create_note_action() { let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; let actions = [create_note_send_action(&inbox)]; - ActivitySender::new() - .expect("build activity sender") + test_activity_sender() .send_actions(&actions) .await .expect("send Create activity"); let request = requests.recv().await.expect("receive Create request"); + assert_eq!( + request.headers["host"], + inbox + .strip_prefix("http://") + .and_then(|value| value.strip_suffix("/inbox")) + .expect("inbox authority") + ); + assert!(httpdate::parse_http_date(request.headers["date"].to_str().unwrap()).is_ok()); + assert_eq!( + request.headers["digest"], + feder_core::http_signatures::create_sha256_digest_header(&request.body) + ); + let signature = request.headers["signature"].to_str().unwrap(); + assert!(signature.starts_with( + "keyId=\"https://local.example/users/alice#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) content-type date digest host\",signature=\"" + )); let activity: serde_json::Value = serde_json::from_slice(&request.body).expect("valid sent activity"); assert_eq!(activity["type"], "Create"); @@ -74,10 +88,7 @@ async fn attempts_later_sends_after_failure() { create_note_send_action(&successful_inbox), ]; - let result = ActivitySender::new() - .expect("build activity sender") - .send_actions(&actions) - .await; + let result = test_activity_sender().send_actions(&actions).await; assert!(result.is_err()); failed_requests diff --git a/crates/feder-runtime-server/tests/common/mod.rs b/crates/feder-runtime-server/tests/common/mod.rs index 13e7d15..2038071 100644 --- a/crates/feder-runtime-server/tests/common/mod.rs +++ b/crates/feder-runtime-server/tests/common/mod.rs @@ -109,17 +109,18 @@ pub fn test_app_state(config: RuntimeConfig) -> Result { IriFragmentStr::new("main-key").expect("main-key is a valid IRI fragment"), )); actor.set_public_key(Reference::object(CryptographicKey::new( - key_id, + key_id.clone(), actor.id.clone(), actor_key_pair.public_key_pem().to_string(), ))); let core = FederCore::new(FederConfig::new(actor.clone())); - let activity_sender = ActivitySender::new()?; + let actor_key_pair = Arc::new(actor_key_pair); + let activity_sender = ActivitySender::new(actor_key_pair.clone(), key_id.to_string())?; Ok(AppState { core: Arc::new(Mutex::new(core)), store: Arc::new(Mutex::new(store)), - actor_key_pair: Arc::new(actor_key_pair), + actor_key_pair, activity_sender, local_actor: actor, username: config.username, @@ -149,3 +150,11 @@ fn fixture_actor_key_pair() -> Result ActivitySender { + ActivitySender::new( + Arc::new(fixture_actor_key_pair().expect("load actor key pair fixture")), + "https://local.example/users/alice#main-key".to_string(), + ) + .expect("build activity sender") +} From 1f11b4198e58cb81ce2ae1327b08e57041b31c7b Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 19 Jul 2026 14:43:14 +0900 Subject: [PATCH 10/72] Include query strings in signed request targets Sign the complete path and query for outgoing ActivityPub requests, and add regression coverage for inbox URLs containing query parameters. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/src/send.rs | 16 +++++++-- .../feder-runtime-server/tests/cases/send.rs | 35 ++++++++++++++++--- .../feder-runtime-server/tests/common/mod.rs | 7 ++-- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/crates/feder-runtime-server/src/send.rs b/crates/feder-runtime-server/src/send.rs index 90c89b6..8a26d4f 100644 --- a/crates/feder-runtime-server/src/send.rs +++ b/crates/feder-runtime-server/src/send.rs @@ -93,9 +93,19 @@ impl ActivitySender { ("digest", digest.as_str()), ("host", host.as_str()), ]; - let signature = - sign_draft_cavage(&self.key_pair, &self.key_id, "POST", url.path(), &headers) - .map_err(SendError::Sign)?; + let mut request_target = url.path().to_string(); + if let Some(query) = url.query() { + request_target.push('?'); + request_target.push_str(query); + } + let signature = sign_draft_cavage( + &self.key_pair, + &self.key_id, + "POST", + &request_target, + &headers, + ) + .map_err(SendError::Sign)?; let response = self .client diff --git a/crates/feder-runtime-server/tests/cases/send.rs b/crates/feder-runtime-server/tests/cases/send.rs index 9624037..bae073a 100644 --- a/crates/feder-runtime-server/tests/cases/send.rs +++ b/crates/feder-runtime-server/tests/cases/send.rs @@ -14,7 +14,10 @@ // along with this program. If not, see . use axum::http::StatusCode; -use feder_core::{Action, Activity, SendActivity}; +use feder_core::{ + Action, Activity, SendActivity, + http_signatures::{ActorKeyPair, sign_draft_cavage}, +}; use feder_vocab::{Create, Note, Reference}; use crate::common::{spawn_inbox_server, test_activity_sender}; @@ -45,6 +48,7 @@ fn create_note_send_action(inbox: &str) -> Action { #[tokio::test] async fn sends_create_note_action() { let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; + let inbox = format!("{inbox}?shared=true"); let actions = [create_note_send_action(&inbox)]; test_activity_sender() @@ -53,11 +57,12 @@ async fn sends_create_note_action() { .expect("send Create activity"); let request = requests.recv().await.expect("receive Create request"); + assert_eq!(request.uri, "/inbox?shared=true"); assert_eq!( request.headers["host"], inbox .strip_prefix("http://") - .and_then(|value| value.strip_suffix("/inbox")) + .and_then(|value| value.split_once('/').map(|(authority, _)| authority)) .expect("inbox authority") ); assert!(httpdate::parse_http_date(request.headers["date"].to_str().unwrap()).is_ok()); @@ -66,9 +71,29 @@ async fn sends_create_note_action() { feder_core::http_signatures::create_sha256_digest_header(&request.body) ); let signature = request.headers["signature"].to_str().unwrap(); - assert!(signature.starts_with( - "keyId=\"https://local.example/users/alice#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) content-type date digest host\",signature=\"" - )); + let headers = [ + ( + "content-type", + request.headers["content-type"].to_str().unwrap(), + ), + ("date", request.headers["date"].to_str().unwrap()), + ("digest", request.headers["digest"].to_str().unwrap()), + ("host", request.headers["host"].to_str().unwrap()), + ]; + let key_pair = ActorKeyPair::from_pem( + include_str!("../fixtures/rsa-private-key.pem").to_string(), + include_str!("../fixtures/rsa-public-key.pem").to_string(), + ) + .expect("load actor key pair fixture"); + let expected_signature = sign_draft_cavage( + &key_pair, + "https://local.example/users/alice#main-key", + "POST", + "/inbox?shared=true", + &headers, + ) + .expect("sign captured request"); + assert_eq!(signature, expected_signature); let activity: serde_json::Value = serde_json::from_slice(&request.body).expect("valid sent activity"); assert_eq!(activity["type"], "Create"); diff --git a/crates/feder-runtime-server/tests/common/mod.rs b/crates/feder-runtime-server/tests/common/mod.rs index 2038071..74edaad 100644 --- a/crates/feder-runtime-server/tests/common/mod.rs +++ b/crates/feder-runtime-server/tests/common/mod.rs @@ -18,7 +18,7 @@ use std::sync::{Arc, Mutex}; use axum::{ Router, body::Bytes, - http::{HeaderMap, StatusCode}, + http::{HeaderMap, StatusCode, Uri}, routing::post, }; use feder_core::{FederConfig, FederCore, http_signatures::ActorKeyPair}; @@ -35,6 +35,7 @@ use tokio::{sync::mpsc, task::JoinHandle}; pub struct RecordedRequest { pub headers: HeaderMap, + pub uri: Uri, pub body: Bytes, } @@ -44,11 +45,11 @@ pub async fn spawn_inbox_server( let (sender, receiver) = mpsc::channel(1); let app = Router::new().route( "/inbox", - post(move |headers: HeaderMap, body: Bytes| { + post(move |headers: HeaderMap, uri: Uri, body: Bytes| { let sender = sender.clone(); async move { sender - .send(RecordedRequest { headers, body }) + .send(RecordedRequest { headers, uri, body }) .await .expect("request receiver remains open"); response_status From 943647181f6c92c7ec2ef65fad1060e2a1e9d7db Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 19 Jul 2026 16:10:21 +0900 Subject: [PATCH 11/72] Harden outgoing ActivityPub delivery Block private and special-use inbox destinations using validated DNS resolution, with an explicit option for local development. Configure connection and total request timeouts to prevent stalled deliveries. Assisted-by: Codex:gpt-5.6-sol --- Cargo.lock | 2 + crates/feder-runtime-server/Cargo.toml | 3 + crates/feder-runtime-server/src/app.rs | 6 +- crates/feder-runtime-server/src/config.rs | 12 ++ crates/feder-runtime-server/src/inbox.rs | 4 +- crates/feder-runtime-server/src/lib.rs | 3 +- .../src/outbound_network.rs | 147 ++++++++++++++++++ crates/feder-runtime-server/src/send.rs | 28 +++- .../feder-runtime-server/tests/cases/send.rs | 49 +++++- .../feder-runtime-server/tests/common/mod.rs | 14 +- examples/single-user-server/src/main.rs | 5 +- 11 files changed, 260 insertions(+), 13 deletions(-) create mode 100644 crates/feder-runtime-server/src/outbound_network.rs diff --git a/Cargo.lock b/Cargo.lock index 366bde9..0f6ef02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -311,6 +311,7 @@ dependencies = [ "feder-core", "feder-vocab", "httpdate", + "ipnet", "iri-string", "percent-encoding", "rand_core 0.6.4", @@ -321,6 +322,7 @@ dependencies = [ "thiserror", "tokio", "tower", + "url", ] [[package]] diff --git a/crates/feder-runtime-server/Cargo.toml b/crates/feder-runtime-server/Cargo.toml index f6e6f90..81adfbd 100644 --- a/crates/feder-runtime-server/Cargo.toml +++ b/crates/feder-runtime-server/Cargo.toml @@ -14,6 +14,7 @@ feder-vocab.workspace = true iri-string.workspace = true axum = "0.8" httpdate = "1" +ipnet = "2.11.0" serde.workspace = true thiserror = "2" serde_json.workspace = true @@ -21,6 +22,8 @@ percent-encoding = "2.3.2" rand_core.workspace = true reqwest = { version = "0.13.1", default-features = false, features = ["rustls"] } rusqlite = { version = "0.40.1", features = ["bundled"] } +tokio = { version = "1", features = ["net"] } +url = "2" [dev-dependencies] serde_json.workspace = true diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs index 418cd35..bcf6130 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -72,7 +72,11 @@ impl AppState { ))); let core = FederCore::new(FederConfig::new(actor.clone())); let actor_key_pair = Arc::new(actor_key_pair); - let activity_sender = ActivitySender::new(actor_key_pair.clone(), key_id.to_string())?; + let activity_sender = ActivitySender::new( + actor_key_pair.clone(), + key_id.to_string(), + config.outbound_address_policy, + )?; Ok(Self { core: Arc::new(Mutex::new(core)), diff --git a/crates/feder-runtime-server/src/config.rs b/crates/feder-runtime-server/src/config.rs index 918e18d..8ee8725 100644 --- a/crates/feder-runtime-server/src/config.rs +++ b/crates/feder-runtime-server/src/config.rs @@ -23,6 +23,17 @@ pub enum InboxAuthPolicy { AllowUnsignedInsecureDev, } +/// Controls which network addresses may receive outgoing activities. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum OutboundAddressPolicy { + /// Allows only publicly routable destination addresses. + #[default] + PublicOnly, + + /// Allows private and special-use destinations. This disables SSRF protection. + AllowPrivateAddress, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub enum StorageConfig { InMemory, @@ -37,5 +48,6 @@ pub struct RuntimeConfig { pub username: String, pub handle_host: String, pub inbox_auth_policy: InboxAuthPolicy, + pub outbound_address_policy: OutboundAddressPolicy, pub storage: StorageConfig, } diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index c7a9972..28676e0 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -122,7 +122,9 @@ pub async fn inbox( .send_actions(&result.actions) .await .map_err(|error| match error { - SendError::Request(_) | SendError::UnsuccessfulStatus { .. } => StatusCode::BAD_GATEWAY, + SendError::PrivateInboxAddress { .. } + | SendError::Request(_) + | SendError::UnsuccessfulStatus { .. } => StatusCode::BAD_GATEWAY, SendError::BuildClient(_) | SendError::InvalidInbox(_) | SendError::Serialize(_) diff --git a/crates/feder-runtime-server/src/lib.rs b/crates/feder-runtime-server/src/lib.rs index 7fc5da5..488c5ec 100644 --- a/crates/feder-runtime-server/src/lib.rs +++ b/crates/feder-runtime-server/src/lib.rs @@ -18,10 +18,11 @@ pub mod app; pub mod config; pub mod error; pub mod inbox; +mod outbound_network; pub mod send; pub mod storage; pub mod webfinger; pub use app::{AppState, build_router}; -pub use config::{InboxAuthPolicy, RuntimeConfig, StorageConfig}; +pub use config::{InboxAuthPolicy, OutboundAddressPolicy, RuntimeConfig, StorageConfig}; pub use error::Error; diff --git a/crates/feder-runtime-server/src/outbound_network.rs b/crates/feder-runtime-server/src/outbound_network.rs new file mode 100644 index 0000000..15967b9 --- /dev/null +++ b/crates/feder-runtime-server/src/outbound_network.rs @@ -0,0 +1,147 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::{ + io, + net::{IpAddr, SocketAddr}, + sync::LazyLock, + time::Duration, +}; + +use ipnet::IpNet; +use reqwest::{ + Client, Url, + dns::{Addrs, Name, Resolve, Resolving}, + redirect::Policy, +}; + +use crate::config::OutboundAddressPolicy; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +const NON_PUBLIC_NETWORK_CIDRS: &[&str] = &[ + "0.0.0.0/8", + "10.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.0.0.0/24", + "192.0.2.0/24", + "192.88.99.0/24", + "192.168.0.0/16", + "198.18.0.0/15", + "198.51.100.0/24", + "203.0.113.0/24", + "224.0.0.0/4", + "240.0.0.0/4", + "::/128", + "::1/128", + "64:ff9b::/96", + "64:ff9b:1::/48", + "100::/64", + "100:0:0:1::/64", + "2001::/23", + "2001:db8::/32", + "2002::/16", + "3fff::/20", + "5f00::/16", + "fc00::/7", + "fe80::/10", + "ff00::/8", +]; + +static NON_PUBLIC_NETWORKS: LazyLock> = LazyLock::new(|| { + NON_PUBLIC_NETWORK_CIDRS + .iter() + .map(|cidr| cidr.parse().expect("hardcoded network CIDR is valid")) + .collect() +}); + +pub(crate) fn build_client(policy: OutboundAddressPolicy) -> Result { + Client::builder() + .dns_resolver(PublicDnsResolver { policy }) + .redirect(Policy::none()) + .no_proxy() + .connect_timeout(CONNECT_TIMEOUT) + .timeout(REQUEST_TIMEOUT) + .build() +} + +pub(crate) fn validate_literal_host( + url: &Url, + policy: OutboundAddressPolicy, +) -> Result<(), IpAddr> { + if policy == OutboundAddressPolicy::AllowPrivateAddress { + return Ok(()); + } + + match url.host() { + Some(url::Host::Ipv4(address)) => validate_public_address(address.into()), + Some(url::Host::Ipv6(address)) => validate_public_address(address.into()), + Some(url::Host::Domain(_)) | None => Ok(()), + } +} + +#[derive(Clone, Copy, Debug)] +struct PublicDnsResolver { + policy: OutboundAddressPolicy, +} + +impl Resolve for PublicDnsResolver { + fn resolve(&self, name: Name) -> Resolving { + let host = name.as_str().to_string(); + let policy = self.policy; + + Box::pin(async move { + let addresses = tokio::net::lookup_host((host.as_str(), 0)) + .await? + .collect::>(); + if addresses.is_empty() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("{host} resolved to no addresses"), + ) + .into()); + } + if policy == OutboundAddressPolicy::PublicOnly { + for address in &addresses { + validate_public_address(address.ip()).map_err(|blocked| { + io::Error::new( + io::ErrorKind::PermissionDenied, + format!("{host} resolved to non-public address {blocked}"), + ) + })?; + } + } + + Ok(Box::new(addresses.into_iter()) as Addrs) + }) + } +} + +fn validate_public_address(address: IpAddr) -> Result<(), IpAddr> { + if let IpAddr::V6(address) = address + && let Some(mapped) = address.to_ipv4_mapped() + { + return validate_public_address(mapped.into()); + } + let is_public = !NON_PUBLIC_NETWORKS + .iter() + .any(|network| network.contains(&address)); + + if is_public { Ok(()) } else { Err(address) } +} diff --git a/crates/feder-runtime-server/src/send.rs b/crates/feder-runtime-server/src/send.rs index 8a26d4f..07ed21e 100644 --- a/crates/feder-runtime-server/src/send.rs +++ b/crates/feder-runtime-server/src/send.rs @@ -15,6 +15,7 @@ use std::{sync::Arc, time::SystemTime}; +use crate::{config::OutboundAddressPolicy, outbound_network}; use feder_core::{ Action, Activity, SendActivity, http_signatures::{ @@ -24,7 +25,6 @@ use feder_core::{ use reqwest::{ Client, StatusCode, Url, header::{CONTENT_TYPE, DATE, HOST}, - redirect::Policy, }; /// Sends core `SendActivity` actions as signed ActivityPub HTTP requests. @@ -33,20 +33,24 @@ pub struct ActivitySender { client: Client, key_pair: Arc, key_id: String, + address_policy: OutboundAddressPolicy, } impl ActivitySender { /// Creates an activity sender for one actor identity. - pub fn new(key_pair: Arc, key_id: String) -> Result { - let client = Client::builder() - .redirect(Policy::none()) - .build() - .map_err(SendError::BuildClient)?; + pub fn new( + key_pair: Arc, + key_id: String, + address_policy: OutboundAddressPolicy, + ) -> Result { + let client = + outbound_network::build_client(address_policy).map_err(SendError::BuildClient)?; Ok(Self { client, key_pair, key_id, + address_policy, }) } @@ -78,6 +82,12 @@ impl ActivitySender { if !matches!(url.scheme(), "http" | "https") { return Err(SendError::InvalidInbox(send.inbox.to_string())); } + outbound_network::validate_literal_host(&url, self.address_policy).map_err(|address| { + SendError::PrivateInboxAddress { + inbox: send.inbox.to_string(), + address, + } + })?; let mut host = url .host() .ok_or_else(|| SendError::InvalidInbox(send.inbox.to_string()))? @@ -142,6 +152,12 @@ pub enum SendError { #[error("invalid recipient inbox: {0}")] InvalidInbox(String), + #[error("recipient inbox {inbox} resolves to non-public address {address}")] + PrivateInboxAddress { + inbox: String, + address: std::net::IpAddr, + }, + #[error("failed to sign activity request")] Sign(#[source] HttpSignatureError), diff --git a/crates/feder-runtime-server/tests/cases/send.rs b/crates/feder-runtime-server/tests/cases/send.rs index bae073a..fdf0750 100644 --- a/crates/feder-runtime-server/tests/cases/send.rs +++ b/crates/feder-runtime-server/tests/cases/send.rs @@ -18,9 +18,10 @@ use feder_core::{ Action, Activity, SendActivity, http_signatures::{ActorKeyPair, sign_draft_cavage}, }; +use feder_runtime_server::{OutboundAddressPolicy, send::SendError}; use feder_vocab::{Create, Note, Reference}; -use crate::common::{spawn_inbox_server, test_activity_sender}; +use crate::common::{spawn_inbox_server, test_activity_sender, test_activity_sender_with_policy}; fn create_note_send_action(inbox: &str) -> Action { let actor_id = "https://local.example/users/alice" @@ -127,3 +128,49 @@ async fn attempts_later_sends_after_failure() { failed_server.abort(); successful_server.abort(); } + +#[tokio::test] +async fn blocks_literal_private_inbox_address() { + let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; + let actions = [create_note_send_action(&inbox)]; + + let result = test_activity_sender_with_policy(OutboundAddressPolicy::PublicOnly) + .send_actions(&actions) + .await; + + assert!(matches!( + result, + Err(SendError::PrivateInboxAddress { address, .. }) if address.is_loopback() + )); + assert!(requests.try_recv().is_err()); + inbox_server.abort(); +} + +#[tokio::test] +async fn blocks_hostname_resolving_to_private_address() { + let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; + let inbox = inbox.replacen("127.0.0.1", "localhost", 1); + let actions = [create_note_send_action(&inbox)]; + + let result = test_activity_sender_with_policy(OutboundAddressPolicy::PublicOnly) + .send_actions(&actions) + .await; + + assert!(matches!(result, Err(SendError::Request(_)))); + assert!(requests.try_recv().is_err()); + inbox_server.abort(); +} + +#[tokio::test] +async fn blocks_special_use_ipv6_inbox_addresses() { + let sender = test_activity_sender_with_policy(OutboundAddressPolicy::PublicOnly); + + for address in ["100:0:0:1::1", "2001:2::1", "5f00::1"] { + let inbox = format!("http://[{address}]/inbox"); + let actions = [create_note_send_action(&inbox)]; + + let result = sender.send_actions(&actions).await; + + assert!(matches!(result, Err(SendError::PrivateInboxAddress { .. }))); + } +} diff --git a/crates/feder-runtime-server/tests/common/mod.rs b/crates/feder-runtime-server/tests/common/mod.rs index 74edaad..ee47807 100644 --- a/crates/feder-runtime-server/tests/common/mod.rs +++ b/crates/feder-runtime-server/tests/common/mod.rs @@ -25,7 +25,7 @@ use feder_core::{FederConfig, FederCore, http_signatures::ActorKeyPair}; use feder_runtime_server::{ Error, app::{AppState, router_with_state}, - config::{InboxAuthPolicy, RuntimeConfig, StorageConfig}, + config::{InboxAuthPolicy, OutboundAddressPolicy, RuntimeConfig, StorageConfig}, send::ActivitySender, storage::{RuntimeStore, SqliteStore}, }; @@ -84,6 +84,7 @@ pub fn test_config() -> RuntimeConfig { username: "alice".to_string(), handle_host: "127.0.0.1:3000".to_string(), inbox_auth_policy: InboxAuthPolicy::AllowUnsignedInsecureDev, + outbound_address_policy: OutboundAddressPolicy::AllowPrivateAddress, storage: StorageConfig::InMemory, } } @@ -116,7 +117,11 @@ pub fn test_app_state(config: RuntimeConfig) -> Result { ))); let core = FederCore::new(FederConfig::new(actor.clone())); let actor_key_pair = Arc::new(actor_key_pair); - let activity_sender = ActivitySender::new(actor_key_pair.clone(), key_id.to_string())?; + let activity_sender = ActivitySender::new( + actor_key_pair.clone(), + key_id.to_string(), + config.outbound_address_policy, + )?; Ok(AppState { core: Arc::new(Mutex::new(core)), @@ -153,9 +158,14 @@ fn fixture_actor_key_pair() -> Result ActivitySender { + test_activity_sender_with_policy(OutboundAddressPolicy::AllowPrivateAddress) +} + +pub fn test_activity_sender_with_policy(policy: OutboundAddressPolicy) -> ActivitySender { ActivitySender::new( Arc::new(fixture_actor_key_pair().expect("load actor key pair fixture")), "https://local.example/users/alice#main-key".to_string(), + policy, ) .expect("build activity sender") } diff --git a/examples/single-user-server/src/main.rs b/examples/single-user-server/src/main.rs index 89ed860..d4d1f37 100644 --- a/examples/single-user-server/src/main.rs +++ b/examples/single-user-server/src/main.rs @@ -13,7 +13,9 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use feder_runtime_server::{Error, InboxAuthPolicy, RuntimeConfig, StorageConfig, build_router}; +use feder_runtime_server::{ + Error, InboxAuthPolicy, OutboundAddressPolicy, RuntimeConfig, StorageConfig, build_router, +}; fn default_local() -> RuntimeConfig { RuntimeConfig { @@ -32,6 +34,7 @@ fn default_local() -> RuntimeConfig { username: "alice".to_string(), handle_host: "127.0.0.1:3000".to_string(), inbox_auth_policy: InboxAuthPolicy::AllowUnsignedInsecureDev, + outbound_address_policy: OutboundAddressPolicy::AllowPrivateAddress, storage: StorageConfig::InMemory, } } From e0a72d9730d77fb7f5508d7857cb62c0a4cb2941 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 19 Jul 2026 16:20:25 +0900 Subject: [PATCH 12/72] Rename outbound_network.rs to url.rs --- crates/feder-runtime-server/src/lib.rs | 2 +- crates/feder-runtime-server/src/send.rs | 7 +++---- .../src/{outbound_network.rs => url.rs} | 7 ++++--- 3 files changed, 8 insertions(+), 8 deletions(-) rename crates/feder-runtime-server/src/{outbound_network.rs => url.rs} (94%) diff --git a/crates/feder-runtime-server/src/lib.rs b/crates/feder-runtime-server/src/lib.rs index 488c5ec..30afbd6 100644 --- a/crates/feder-runtime-server/src/lib.rs +++ b/crates/feder-runtime-server/src/lib.rs @@ -18,9 +18,9 @@ pub mod app; pub mod config; pub mod error; pub mod inbox; -mod outbound_network; pub mod send; pub mod storage; +mod url; pub mod webfinger; pub use app::{AppState, build_router}; diff --git a/crates/feder-runtime-server/src/send.rs b/crates/feder-runtime-server/src/send.rs index 07ed21e..a8fc26f 100644 --- a/crates/feder-runtime-server/src/send.rs +++ b/crates/feder-runtime-server/src/send.rs @@ -15,7 +15,7 @@ use std::{sync::Arc, time::SystemTime}; -use crate::{config::OutboundAddressPolicy, outbound_network}; +use crate::{config::OutboundAddressPolicy, url}; use feder_core::{ Action, Activity, SendActivity, http_signatures::{ @@ -43,8 +43,7 @@ impl ActivitySender { key_id: String, address_policy: OutboundAddressPolicy, ) -> Result { - let client = - outbound_network::build_client(address_policy).map_err(SendError::BuildClient)?; + let client = url::build_client(address_policy).map_err(SendError::BuildClient)?; Ok(Self { client, @@ -82,7 +81,7 @@ impl ActivitySender { if !matches!(url.scheme(), "http" | "https") { return Err(SendError::InvalidInbox(send.inbox.to_string())); } - outbound_network::validate_literal_host(&url, self.address_policy).map_err(|address| { + crate::url::validate_literal_host(&url, self.address_policy).map_err(|address| { SendError::PrivateInboxAddress { inbox: send.inbox.to_string(), address, diff --git a/crates/feder-runtime-server/src/outbound_network.rs b/crates/feder-runtime-server/src/url.rs similarity index 94% rename from crates/feder-runtime-server/src/outbound_network.rs rename to crates/feder-runtime-server/src/url.rs index 15967b9..f16c90a 100644 --- a/crates/feder-runtime-server/src/outbound_network.rs +++ b/crates/feder-runtime-server/src/url.rs @@ -26,6 +26,7 @@ use reqwest::{ dns::{Addrs, Name, Resolve, Resolving}, redirect::Policy, }; +use url::Host; use crate::config::OutboundAddressPolicy; @@ -90,9 +91,9 @@ pub(crate) fn validate_literal_host( } match url.host() { - Some(url::Host::Ipv4(address)) => validate_public_address(address.into()), - Some(url::Host::Ipv6(address)) => validate_public_address(address.into()), - Some(url::Host::Domain(_)) | None => Ok(()), + Some(Host::Ipv4(address)) => validate_public_address(address.into()), + Some(Host::Ipv6(address)) => validate_public_address(address.into()), + Some(Host::Domain(_)) | None => Ok(()), } } From 1b7cbc05d97e0cd83f8e388a04918b66c35f7eb9 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 19 Jul 2026 17:32:52 +0900 Subject: [PATCH 13/72] Fetch ID-only Follow actors before core handling, persist their discovered inbox, and deliver the signed Accept activity. Reuse outbound network protections and enforce response type, size, and actor ID validation. Add an end-to-end test covering Mastodon-style actor resolution and Accept delivery. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/src/actor.rs | 184 +++++++++++++++++- crates/feder-runtime-server/src/app.rs | 4 + crates/feder-runtime-server/src/error.rs | 3 + crates/feder-runtime-server/src/inbox.rs | 14 +- crates/feder-runtime-server/src/lib.rs | 1 + .../feder-runtime-server/tests/cases/inbox.rs | 115 ++++++++++- .../feder-runtime-server/tests/common/mod.rs | 3 + 7 files changed, 318 insertions(+), 6 deletions(-) diff --git a/crates/feder-runtime-server/src/actor.rs b/crates/feder-runtime-server/src/actor.rs index 8ddfff0..edae040 100644 --- a/crates/feder-runtime-server/src/actor.rs +++ b/crates/feder-runtime-server/src/actor.rs @@ -19,8 +19,17 @@ use axum::{ http::{StatusCode, header}, response::{IntoResponse, Response}, }; +use feder_vocab::{Actor, ActorType, CryptographicKey, Endpoints, Iri, Reference}; +use reqwest::{ + Client, StatusCode as HttpStatusCode, Url, + header::{ACCEPT, CONTENT_TYPE}, +}; +use serde::Deserialize; + +use crate::{app::AppState, config::OutboundAddressPolicy, url}; -use crate::app::AppState; +const MAX_ACTOR_BODY_SIZE: usize = 1_048_576; +const ACTIVITYPUB_ACCEPT: &str = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""; pub async fn actor( State(app_state): State, @@ -37,3 +46,176 @@ pub async fn actor( ) .into_response()) } + +/// Resolves remote ActivityPub actors for runtime protocol handling. +#[derive(Clone, Debug)] +pub struct ActorResolver { + client: Client, + address_policy: OutboundAddressPolicy, +} + +impl ActorResolver { + pub fn new(address_policy: OutboundAddressPolicy) -> Result { + let client = url::build_client(address_policy).map_err(ActorResolveError::BuildClient)?; + Ok(Self { + client, + address_policy, + }) + } + + pub async fn resolve_reference( + &self, + reference: &mut Reference, + ) -> Result<(), ActorResolveError> { + let Reference::Id(actor_id) = reference else { + return Ok(()); + }; + let actor_id = actor_id.clone(); + let actor = self.resolve(&actor_id).await?; + *reference = Reference::object(actor); + Ok(()) + } + + pub async fn resolve(&self, actor_id: &Iri) -> Result { + let url = Url::parse(actor_id.as_str()) + .map_err(|_| ActorResolveError::InvalidActorId(actor_id.to_string()))?; + if !matches!(url.scheme(), "http" | "https") + || !url.username().is_empty() + || url.password().is_some() + || url.host().is_none() + { + return Err(ActorResolveError::InvalidActorId(actor_id.to_string())); + } + url::validate_literal_host(&url, self.address_policy).map_err(|address| { + ActorResolveError::PrivateActorAddress { + actor: actor_id.to_string(), + address, + } + })?; + + let mut response = self + .client + .get(url) + .header(ACCEPT, ACTIVITYPUB_ACCEPT) + .send() + .await + .map_err(ActorResolveError::Request)?; + if !response.status().is_success() { + return Err(ActorResolveError::UnsuccessfulStatus { + actor: actor_id.to_string(), + status: response.status(), + }); + } + let content_type = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + if !is_activitypub_content_type(content_type) { + return Err(ActorResolveError::UnsupportedContentType( + content_type.to_string(), + )); + } + if response + .content_length() + .is_some_and(|length| length > MAX_ACTOR_BODY_SIZE as u64) + { + return Err(ActorResolveError::ResponseTooLarge); + } + + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(ActorResolveError::Request)? { + if body.len().saturating_add(chunk.len()) > MAX_ACTOR_BODY_SIZE { + return Err(ActorResolveError::ResponseTooLarge); + } + body.extend_from_slice(&chunk); + } + let document: ActorDocument = + serde_json::from_slice(&body).map_err(ActorResolveError::Deserialize)?; + let actor = document.into_actor(); + if actor.id != *actor_id { + return Err(ActorResolveError::ActorIdMismatch { + requested: actor_id.to_string(), + returned: actor.id.to_string(), + }); + } + + Ok(actor) + } +} + +fn is_activitypub_content_type(content_type: &str) -> bool { + let media_type = content_type + .split_once(';') + .map_or(content_type, |(media_type, _)| media_type) + .trim(); + media_type.eq_ignore_ascii_case("application/activity+json") + || media_type.eq_ignore_ascii_case("application/ld+json") +} + +#[derive(Deserialize)] +struct ActorDocument { + #[serde(rename = "type")] + kind: ActorType, + id: Iri, + inbox: Iri, + outbox: Iri, + #[serde(rename = "preferredUsername")] + preferred_username: Option, + name: Option, + endpoints: Option, + #[serde(rename = "publicKey")] + public_key: Option>, +} + +impl ActorDocument { + fn into_actor(self) -> Actor { + Actor { + context: None, + kind: self.kind, + id: self.id, + inbox: self.inbox, + outbox: self.outbox, + preferred_username: self.preferred_username, + name: self.name, + endpoints: self.endpoints, + public_key: self.public_key, + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ActorResolveError { + #[error("failed to build actor resolution HTTP client")] + BuildClient(#[source] reqwest::Error), + + #[error("invalid remote actor ID: {0}")] + InvalidActorId(String), + + #[error("remote actor {actor} uses non-public address {address}")] + PrivateActorAddress { + actor: String, + address: std::net::IpAddr, + }, + + #[error("failed to fetch remote actor")] + Request(#[source] reqwest::Error), + + #[error("fetching remote actor {actor} returned {status}")] + UnsuccessfulStatus { + actor: String, + status: HttpStatusCode, + }, + + #[error("remote actor response has unsupported content type: {0}")] + UnsupportedContentType(String), + + #[error("remote actor response exceeds size limit")] + ResponseTooLarge, + + #[error("failed to deserialize remote actor")] + Deserialize(#[source] serde_json::Error), + + #[error("remote actor ID mismatch: requested {requested}, returned {returned}")] + ActorIdMismatch { requested: String, returned: String }, +} diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs index bcf6130..ad11d97 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -16,6 +16,7 @@ use std::sync::{Arc, Mutex}; use crate::Error; +use crate::actor::ActorResolver; use crate::config::{InboxAuthPolicy, RuntimeConfig, StorageConfig}; use crate::send::ActivitySender; use crate::storage::{RuntimeStore, SqliteStore}; @@ -36,6 +37,7 @@ pub struct AppState { pub core: Arc>, pub store: Arc>, pub actor_key_pair: Arc, + pub actor_resolver: ActorResolver, pub activity_sender: ActivitySender, pub local_actor: Actor, pub username: String, @@ -72,6 +74,7 @@ impl AppState { ))); let core = FederCore::new(FederConfig::new(actor.clone())); let actor_key_pair = Arc::new(actor_key_pair); + let actor_resolver = ActorResolver::new(config.outbound_address_policy)?; let activity_sender = ActivitySender::new( actor_key_pair.clone(), key_id.to_string(), @@ -82,6 +85,7 @@ impl AppState { core: Arc::new(Mutex::new(core)), store: Arc::new(Mutex::new(store)), actor_key_pair, + actor_resolver, activity_sender, local_actor: actor, username: config.username, diff --git a/crates/feder-runtime-server/src/error.rs b/crates/feder-runtime-server/src/error.rs index c3aa76c..d405636 100644 --- a/crates/feder-runtime-server/src/error.rs +++ b/crates/feder-runtime-server/src/error.rs @@ -29,4 +29,7 @@ pub enum Error { #[error("activity sender setup failed")] ActivitySender(#[from] crate::send::SendError), + + #[error("actor resolver setup failed")] + ActorResolver(#[from] crate::actor::ActorResolveError), } diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index 28676e0..3aeee28 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -98,7 +98,19 @@ pub async fn inbox( if activity_type != Some("Follow") { return Ok(StatusCode::ACCEPTED.into_response()); } - let follow: Follow = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; + let mut follow: Follow = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; + let follows_local_actor = match &follow.object { + feder_vocab::Reference::Id(actor_id) => actor_id == &app_state.local_actor.id, + feder_vocab::Reference::Object(actor) => actor.id == app_state.local_actor.id, + }; + if !follows_local_actor { + return Ok(StatusCode::ACCEPTED.into_response()); + } + app_state + .actor_resolver + .resolve_reference(&mut follow.actor) + .await + .map_err(|_| StatusCode::BAD_GATEWAY)?; let accept_id = accept_id_for_follow(&app_state.local_actor.id, &follow.id)?; let input = Input::received_follow(follow, accept_id); diff --git a/crates/feder-runtime-server/src/lib.rs b/crates/feder-runtime-server/src/lib.rs index 30afbd6..975825b 100644 --- a/crates/feder-runtime-server/src/lib.rs +++ b/crates/feder-runtime-server/src/lib.rs @@ -23,6 +23,7 @@ pub mod storage; mod url; pub mod webfinger; +pub use actor::{ActorResolveError, ActorResolver}; pub use app::{AppState, build_router}; pub use config::{InboxAuthPolicy, OutboundAddressPolicy, RuntimeConfig, StorageConfig}; pub use error::Error; diff --git a/crates/feder-runtime-server/tests/cases/inbox.rs b/crates/feder-runtime-server/tests/cases/inbox.rs index a46d3c5..1db1763 100644 --- a/crates/feder-runtime-server/tests/cases/inbox.rs +++ b/crates/feder-runtime-server/tests/cases/inbox.rs @@ -14,9 +14,10 @@ // along with this program. If not, see . use axum::{ - Router, - body::Body, - http::{Request, StatusCode, header::CONTENT_TYPE}, + Json, Router, + body::{Body, Bytes}, + http::{HeaderMap, Request, StatusCode, Uri, header::CONTENT_TYPE}, + routing::{get, post}, }; use feder_runtime_server::{ app::router_with_state, @@ -27,7 +28,8 @@ use serde_json::json; use tower::ServiceExt; use crate::common::{ - spawn_inbox_server, temporary_database_path, test_app_state, test_config, test_router, + RecordedRequest, spawn_inbox_server, temporary_database_path, test_app_state, test_config, + test_router, }; fn follow_body() -> Vec { @@ -51,6 +53,71 @@ fn follow_body_for_inbox(inbox: &str) -> Vec { .expect("serialize follow") } +fn id_only_follow_body(actor_id: &str) -> Vec { + serde_json::to_vec(&json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Follow", + "id": format!("{actor_id}/follows/1"), + "actor": actor_id, + "object": "http://127.0.0.1:3000/users/alice" + })) + .expect("serialize ID-only follow") +} + +async fn spawn_actor_server() -> ( + String, + tokio::sync::mpsc::Receiver, + tokio::task::JoinHandle<()>, +) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind actor server"); + let address = listener.local_addr().expect("actor server address"); + let actor_id = format!("http://{address}/users/bob"); + let inbox = format!("http://{address}/inbox"); + let actor = json!({ + "@context": [ + "https://www.w3.org/ns/activitystreams", + { "toot": "http://joinmastodon.org/ns#" } + ], + "type": "Person", + "id": actor_id, + "inbox": inbox, + "outbox": format!("http://{address}/users/bob/outbox"), + "preferredUsername": "bob", + "endpoints": { "sharedInbox": inbox } + }); + let (sender, receiver) = tokio::sync::mpsc::channel(1); + let app = Router::new() + .route( + "/users/bob", + get(move || { + let actor = actor.clone(); + async move { ([(CONTENT_TYPE, "application/activity+json")], Json(actor)) } + }), + ) + .route( + "/inbox", + post(move |headers: HeaderMap, uri: Uri, body: Bytes| { + let sender = sender.clone(); + async move { + sender + .send(RecordedRequest { headers, uri, body }) + .await + .expect("request receiver remains open"); + StatusCode::ACCEPTED + } + }), + ); + let task = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("serve actor endpoint"); + }); + + (actor_id, receiver, task) +} + async fn post_inbox( app: Router, uri: &str, @@ -114,6 +181,46 @@ async fn valid_follow_reaches_core() { inbox_server.abort(); } +#[tokio::test] +async fn resolves_id_only_follower_and_sends_accept() { + let (actor_id, mut requests, actor_server) = spawn_actor_server().await; + let state = test_app_state(test_config()).expect("build app state"); + let response = post_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + "application/activity+json", + id_only_follow_body(&actor_id), + ) + .await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + + let followers = state + .store + .lock() + .expect("store lock") + .list_followers(&state.local_actor.id) + .expect("list followers"); + assert_eq!(followers.len(), 1); + assert_eq!(followers[0].follower.as_str(), actor_id); + assert_eq!( + followers[0] + .inbox + .as_ref() + .expect("resolved inbox") + .as_str(), + format!("{}/inbox", actor_id.trim_end_matches("/users/bob")) + ); + + let request = requests.recv().await.expect("receive Accept request"); + assert!(request.headers.contains_key("signature")); + let activity: serde_json::Value = + serde_json::from_slice(&request.body).expect("valid sent activity"); + assert_eq!(activity["type"], "Accept"); + assert_eq!(activity["object"]["actor"]["id"], actor_id); + actor_server.abort(); +} + #[tokio::test] async fn send_failure_returns_bad_gateway_after_core_handling() { let (inbox, mut requests, inbox_server) = diff --git a/crates/feder-runtime-server/tests/common/mod.rs b/crates/feder-runtime-server/tests/common/mod.rs index ee47807..84664ed 100644 --- a/crates/feder-runtime-server/tests/common/mod.rs +++ b/crates/feder-runtime-server/tests/common/mod.rs @@ -24,6 +24,7 @@ use axum::{ use feder_core::{FederConfig, FederCore, http_signatures::ActorKeyPair}; use feder_runtime_server::{ Error, + actor::ActorResolver, app::{AppState, router_with_state}, config::{InboxAuthPolicy, OutboundAddressPolicy, RuntimeConfig, StorageConfig}, send::ActivitySender, @@ -117,6 +118,7 @@ pub fn test_app_state(config: RuntimeConfig) -> Result { ))); let core = FederCore::new(FederConfig::new(actor.clone())); let actor_key_pair = Arc::new(actor_key_pair); + let actor_resolver = ActorResolver::new(config.outbound_address_policy)?; let activity_sender = ActivitySender::new( actor_key_pair.clone(), key_id.to_string(), @@ -127,6 +129,7 @@ pub fn test_app_state(config: RuntimeConfig) -> Result { core: Arc::new(Mutex::new(core)), store: Arc::new(Mutex::new(store)), actor_key_pair, + actor_resolver, activity_sender, local_actor: actor, username: config.username, From d4654b433249f6ba7761c700815bbeea23c874b2 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 19 Jul 2026 18:05:11 +0900 Subject: [PATCH 14/72] Verify incoming HTTP signatures Validate Cavage RSA-SHA256 signatures, signed headers, request dates, body digests, actor ownership, and public-key identity before processing inbox activities. Add tests for valid, tampered, unsigned, and mismatched-actor requests. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-core/src/http_signatures.rs | 88 +++++- crates/feder-runtime-server/src/inbox.rs | 250 +++++++++++++++++- .../feder-runtime-server/tests/cases/inbox.rs | 146 +++++++++- .../feder-runtime-server/tests/common/mod.rs | 2 +- 4 files changed, 469 insertions(+), 17 deletions(-) diff --git a/crates/feder-core/src/http_signatures.rs b/crates/feder-core/src/http_signatures.rs index 6c9493d..d06d56f 100644 --- a/crates/feder-core/src/http_signatures.rs +++ b/crates/feder-core/src/http_signatures.rs @@ -6,11 +6,11 @@ use core::fmt; use base64::{Engine as _, engine::general_purpose::STANDARD}; use rsa::{ RsaPrivateKey, RsaPublicKey, - pkcs1v15::SigningKey, + pkcs1v15::{Signature, SigningKey, VerifyingKey}, pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey, LineEnding}, rand_core::CryptoRngCore, sha2::{Digest, Sha256}, - signature::{SignatureEncoding, Signer}, + signature::{SignatureEncoding, Signer, Verifier}, }; use zeroize::Zeroizing; @@ -144,6 +144,32 @@ pub fn sign_draft_cavage( )) } +/// Verifies a draft-Cavage RSA-SHA256 signature over a prepared request. +/// +/// `headers` must contain the signed HTTP headers in their declared order, +/// excluding the `(request-target)` pseudo-header. +pub fn verify_draft_cavage( + public_key_pem: &str, + method: &str, + request_target: &str, + headers: &[(&str, &str)], + signature: &str, +) -> Result<(), HttpSignatureVerificationError> { + let signature_base = draft_cavage_signature_base(method, request_target, headers); + let public_key = RsaPublicKey::from_public_key_pem(public_key_pem) + .map_err(HttpSignatureVerificationError::InvalidPublicKey)?; + let signature = STANDARD + .decode(signature) + .map_err(HttpSignatureVerificationError::InvalidSignatureEncoding)?; + let signature = Signature::try_from(signature.as_slice()) + .map_err(HttpSignatureVerificationError::InvalidSignature)?; + let verifying_key = VerifyingKey::::new(public_key); + + verifying_key + .verify(signature_base.as_bytes(), &signature) + .map_err(HttpSignatureVerificationError::Verification) +} + fn draft_cavage_signature_base( method: &str, request_target: &str, @@ -178,6 +204,30 @@ impl fmt::Display for HttpSignatureError { impl core::error::Error for HttpSignatureError {} +/// Errors produced while verifying an HTTP signature. +#[derive(Debug)] +pub enum HttpSignatureVerificationError { + InvalidPublicKey(rsa::pkcs8::spki::Error), + InvalidSignatureEncoding(base64::DecodeError), + InvalidSignature(rsa::signature::Error), + Verification(rsa::signature::Error), +} + +impl fmt::Display for HttpSignatureVerificationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidPublicKey(_) => formatter.write_str("invalid RSA public key PEM"), + Self::InvalidSignatureEncoding(_) => { + formatter.write_str("invalid base64 signature encoding") + } + Self::InvalidSignature(_) => formatter.write_str("invalid RSA signature"), + Self::Verification(_) => formatter.write_str("HTTP signature verification failed"), + } + } +} + +impl core::error::Error for HttpSignatureVerificationError {} + #[cfg(test)] mod tests { use alloc::string::ToString; @@ -277,6 +327,40 @@ mod tests { .expect("verify signature"); } + #[test] + fn draft_cavage_signature_verifies_and_rejects_changed_headers() { + let pair = ActorKeyPair::from_pem(PRIVATE_KEY_PEM.to_string(), PUBLIC_KEY_PEM.to_string()) + .expect("load actor key pair fixture"); + let headers = [ + ("date", "Tue, 05 Mar 2024 07:49:44 GMT"), + ( + "digest", + "SHA-256=MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=", + ), + ("host", "example.com"), + ]; + let signature_header = + sign_draft_cavage(&pair, "https://example.com/key", "POST", "/inbox", &headers) + .expect("sign request"); + let signature = signature_header + .rsplit_once("signature=\"") + .and_then(|(_, signature)| signature.strip_suffix('"')) + .expect("signature parameter"); + + verify_draft_cavage(pair.public_key_pem(), "POST", "/inbox", &headers, signature) + .expect("verify request"); + assert!( + verify_draft_cavage( + pair.public_key_pem(), + "POST", + "/other-inbox", + &headers, + signature, + ) + .is_err() + ); + } + fn signature_header_prefix(headers: &[(&str, &str)]) -> String { let signed_headers = headers .iter() diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index 3aeee28..46b0d1f 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -13,6 +13,11 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +use std::{ + collections::{BTreeMap, HashSet}, + time::{Duration, SystemTime}, +}; + use axum::{ body::Bytes, extract::{Path, State}, @@ -20,8 +25,11 @@ use axum::{ response::{IntoResponse, Response}, }; -use feder_core::Input; -use feder_vocab::Follow; +use feder_core::{ + Input, + http_signatures::{create_sha256_digest_header, verify_draft_cavage}, +}; +use feder_vocab::{Actor, Follow, Iri, Reference}; use serde_json::{Value, from_slice, from_value}; use crate::app::AppState; @@ -29,6 +37,9 @@ use crate::config::InboxAuthPolicy; use crate::send::SendError; use crate::storage::RuntimeStore; +const MAX_SIGNATURE_AGE: Duration = Duration::from_secs(65 * 60); +const MAX_CLOCK_SKEW: Duration = Duration::from_secs(60 * 60); + pub struct InboxRequest { pub username: String, pub headers: HeaderMap, @@ -51,11 +62,217 @@ fn accept_id_for_follow( .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) } -fn verify_inbox_request(app_state: &AppState, _req: &InboxRequest) -> Result<(), StatusCode> { +async fn verify_inbox_request( + app_state: &AppState, + req: &InboxRequest, +) -> Result, StatusCode> { match app_state.inbox_auth_policy { - InboxAuthPolicy::AllowUnsignedInsecureDev => Ok(()), - InboxAuthPolicy::RequireSigned => Err(StatusCode::UNAUTHORIZED), + InboxAuthPolicy::AllowUnsignedInsecureDev => Ok(None), + InboxAuthPolicy::RequireSigned => verify_signed_request(app_state, req).await.map(Some), + } +} + +async fn verify_signed_request( + app_state: &AppState, + req: &InboxRequest, +) -> Result { + let signature_header = req + .headers + .get("signature") + .and_then(|value| value.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + let signature = parse_signature_header(signature_header).ok_or(StatusCode::UNAUTHORIZED)?; + if signature.algorithm != "rsa-sha256" + || signature.signed_headers.first().map(String::as_str) != Some("(request-target)") + { + return Err(StatusCode::UNAUTHORIZED); + } + + let mut seen_headers = HashSet::new(); + let mut signed_headers = Vec::new(); + for name in signature.signed_headers.iter().skip(1) { + if name.starts_with('(') || !seen_headers.insert(name.as_str()) { + return Err(StatusCode::UNAUTHORIZED); + } + let values = req.headers.get_all(name).iter().collect::>(); + let [value] = values.as_slice() else { + return Err(StatusCode::UNAUTHORIZED); + }; + let value = value.to_str().map_err(|_| StatusCode::UNAUTHORIZED)?; + signed_headers.push((name.as_str(), value)); + } + if !["host", "date", "digest"] + .iter() + .all(|required| seen_headers.contains(required)) + { + return Err(StatusCode::UNAUTHORIZED); + } + + verify_request_date(&req.headers)?; + verify_request_digest(&req.headers, &req.body)?; + + let key_id: Iri = signature + .key_id + .parse() + .map_err(|_| StatusCode::UNAUTHORIZED)?; + let mut actor_url = + reqwest::Url::parse(key_id.as_str()).map_err(|_| StatusCode::UNAUTHORIZED)?; + actor_url.set_fragment(None); + let actor_id: Iri = actor_url + .as_str() + .parse() + .map_err(|_| StatusCode::UNAUTHORIZED)?; + let actor = app_state + .actor_resolver + .resolve(&actor_id) + .await + .map_err(|_| StatusCode::BAD_GATEWAY)?; + let Reference::Object(public_key) = + actor.public_key.as_ref().ok_or(StatusCode::UNAUTHORIZED)? + else { + return Err(StatusCode::UNAUTHORIZED); + }; + if public_key.id != key_id || public_key.owner != actor.id { + return Err(StatusCode::UNAUTHORIZED); + } + + let request_target = req + .uri + .path_and_query() + .map_or(req.uri.path(), |value| value.as_str()); + verify_draft_cavage( + &public_key.public_key_pem, + req.method.as_str(), + request_target, + &signed_headers, + &signature.signature, + ) + .map_err(|_| StatusCode::UNAUTHORIZED)?; + + Ok(actor) +} + +fn verify_request_date(headers: &HeaderMap) -> Result<(), StatusCode> { + let date = headers + .get("date") + .and_then(|value| value.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED) + .and_then(|value| httpdate::parse_http_date(value).map_err(|_| StatusCode::UNAUTHORIZED))?; + let now = SystemTime::now(); + if now + .duration_since(date) + .is_ok_and(|age| age > MAX_SIGNATURE_AGE) + || date + .duration_since(now) + .is_ok_and(|skew| skew > MAX_CLOCK_SKEW) + { + return Err(StatusCode::UNAUTHORIZED); + } + + Ok(()) +} + +fn verify_request_digest(headers: &HeaderMap, body: &[u8]) -> Result<(), StatusCode> { + let digest = headers + .get("digest") + .and_then(|value| value.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + let expected = create_sha256_digest_header(body); + let matches = digest.split(',').any(|entry| { + entry + .trim() + .split_once('=') + .is_some_and(|(algorithm, value)| { + algorithm.eq_ignore_ascii_case("sha-256") + && expected + .split_once('=') + .is_some_and(|(_, expected)| value == expected) + }) + }); + + if matches { + Ok(()) + } else { + Err(StatusCode::UNAUTHORIZED) + } +} + +struct ParsedSignature { + key_id: String, + algorithm: String, + signed_headers: Vec, + signature: String, +} + +fn parse_signature_header(header: &str) -> Option { + let mut parameters = BTreeMap::new(); + let mut remaining = header; + while !remaining.trim_start().is_empty() { + remaining = remaining.trim_start(); + let equals = remaining.find('=')?; + let name = remaining[..equals].trim(); + if name.is_empty() + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return None; + } + remaining = &remaining[equals + 1..]; + let (value, rest) = parse_quoted_parameter(remaining.trim_start())?; + if parameters + .insert(name.to_ascii_lowercase(), value) + .is_some() + { + return None; + } + remaining = rest.trim_start(); + if remaining.is_empty() { + break; + } + remaining = remaining.strip_prefix(',')?; + } + + let key_id = parameters.remove("keyid")?; + let algorithm = parameters.remove("algorithm")?; + let signed_headers = parameters + .remove("headers")? + .split_ascii_whitespace() + .map(str::to_ascii_lowercase) + .collect::>(); + let signature = parameters.remove("signature")?; + if key_id.is_empty() || signed_headers.is_empty() || signature.is_empty() { + return None; } + + Some(ParsedSignature { + key_id, + algorithm: algorithm.to_ascii_lowercase(), + signed_headers, + signature, + }) +} + +fn parse_quoted_parameter(input: &str) -> Option<(String, &str)> { + let input = input.strip_prefix('"')?; + let mut value = String::new(); + let mut escaped = false; + for (index, character) in input.char_indices() { + if escaped { + value.push(character); + escaped = false; + } else if character == '\\' { + escaped = true; + } else if character == '"' { + return Some((value, &input[index + character.len_utf8()..])); + } else if character.is_control() { + return None; + } else { + value.push(character); + } + } + + None } pub async fn inbox( @@ -88,7 +305,7 @@ pub async fn inbox( body, }; - verify_inbox_request(&app_state, &req)?; + let verified_actor = verify_inbox_request(&app_state, &req).await?; let value: Value = from_slice(&req.body).map_err(|_| StatusCode::BAD_REQUEST)?; @@ -106,11 +323,22 @@ pub async fn inbox( if !follows_local_actor { return Ok(StatusCode::ACCEPTED.into_response()); } - app_state - .actor_resolver - .resolve_reference(&mut follow.actor) - .await - .map_err(|_| StatusCode::BAD_GATEWAY)?; + if let Some(actor) = verified_actor { + let actor_matches_signature = match &follow.actor { + Reference::Id(actor_id) => actor_id == &actor.id, + Reference::Object(follow_actor) => follow_actor.id == actor.id, + }; + if !actor_matches_signature { + return Err(StatusCode::UNAUTHORIZED); + } + follow.actor = Reference::object(actor); + } else { + app_state + .actor_resolver + .resolve_reference(&mut follow.actor) + .await + .map_err(|_| StatusCode::BAD_GATEWAY)?; + } let accept_id = accept_id_for_follow(&app_state.local_actor.id, &follow.id)?; let input = Input::received_follow(follow, accept_id); diff --git a/crates/feder-runtime-server/tests/cases/inbox.rs b/crates/feder-runtime-server/tests/cases/inbox.rs index 1db1763..a4a17cd 100644 --- a/crates/feder-runtime-server/tests/cases/inbox.rs +++ b/crates/feder-runtime-server/tests/cases/inbox.rs @@ -19,6 +19,7 @@ use axum::{ http::{HeaderMap, Request, StatusCode, Uri, header::CONTENT_TYPE}, routing::{get, post}, }; +use feder_core::http_signatures::{create_sha256_digest_header, sign_draft_cavage}; use feder_runtime_server::{ app::router_with_state, config::{InboxAuthPolicy, StorageConfig}, @@ -28,8 +29,8 @@ use serde_json::json; use tower::ServiceExt; use crate::common::{ - RecordedRequest, spawn_inbox_server, temporary_database_path, test_app_state, test_config, - test_router, + RecordedRequest, fixture_actor_key_pair, spawn_inbox_server, temporary_database_path, + test_app_state, test_config, test_router, }; fn follow_body() -> Vec { @@ -85,7 +86,15 @@ async fn spawn_actor_server() -> ( "inbox": inbox, "outbox": format!("http://{address}/users/bob/outbox"), "preferredUsername": "bob", - "endpoints": { "sharedInbox": inbox } + "endpoints": { "sharedInbox": inbox }, + "publicKey": { + "id": format!("{actor_id}#main-key"), + "type": "CryptographicKey", + "owner": actor_id, + "publicKeyPem": fixture_actor_key_pair() + .expect("load actor key fixture") + .public_key_pem() + } }); let (sender, receiver) = tokio::sync::mpsc::channel(1); let app = Router::new() @@ -136,6 +145,47 @@ async fn post_inbox( .expect("response") } +async fn post_signed_inbox( + app: Router, + uri: &str, + actor_id: &str, + signed_body: &[u8], + delivered_body: impl Into, +) -> axum::response::Response { + let date = httpdate::fmt_http_date(std::time::SystemTime::now()); + let digest = create_sha256_digest_header(signed_body); + let host = "local.example"; + let headers = [ + ("content-type", "application/activity+json"), + ("date", date.as_str()), + ("digest", digest.as_str()), + ("host", host), + ]; + let signature = sign_draft_cavage( + &fixture_actor_key_pair().expect("load actor key fixture"), + &format!("{actor_id}#main-key"), + "POST", + uri, + &headers, + ) + .expect("sign inbox request"); + + app.oneshot( + Request::builder() + .method("POST") + .uri(uri) + .header(CONTENT_TYPE, "application/activity+json") + .header("date", date) + .header("digest", digest) + .header("host", host) + .header("signature", signature) + .body(delivered_body.into()) + .expect("valid request"), + ) + .await + .expect("response") +} + #[tokio::test] async fn valid_follow_reaches_core() { let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; @@ -221,6 +271,96 @@ async fn resolves_id_only_follower_and_sends_accept() { actor_server.abort(); } +#[tokio::test] +async fn verifies_signed_id_only_follow() { + let (actor_id, mut requests, actor_server) = spawn_actor_server().await; + let mut config = test_config(); + config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; + let state = test_app_state(config).expect("build app state"); + let body = id_only_follow_body(&actor_id); + let response = post_signed_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + &actor_id, + &body, + body.clone(), + ) + .await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert_eq!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .len(), + 1 + ); + requests.recv().await.expect("receive Accept request"); + actor_server.abort(); +} + +#[tokio::test] +async fn signed_follow_rejects_tampered_body() { + let (actor_id, _requests, actor_server) = spawn_actor_server().await; + let mut config = test_config(); + config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; + let state = test_app_state(config).expect("build app state"); + let signed_body = id_only_follow_body(&actor_id); + let delivered_body = id_only_follow_body("https://attacker.example/users/mallory"); + let response = post_signed_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + &actor_id, + &signed_body, + delivered_body, + ) + .await; + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); + actor_server.abort(); +} + +#[tokio::test] +async fn signed_follow_rejects_actor_different_from_key_owner() { + let (actor_id, _requests, actor_server) = spawn_actor_server().await; + let mut config = test_config(); + config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; + let state = test_app_state(config).expect("build app state"); + let body = id_only_follow_body("https://attacker.example/users/mallory"); + let response = post_signed_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + &actor_id, + &body, + body.clone(), + ) + .await; + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); + actor_server.abort(); +} + #[tokio::test] async fn send_failure_returns_bad_gateway_after_core_handling() { let (inbox, mut requests, inbox_server) = diff --git a/crates/feder-runtime-server/tests/common/mod.rs b/crates/feder-runtime-server/tests/common/mod.rs index 84664ed..196ed27 100644 --- a/crates/feder-runtime-server/tests/common/mod.rs +++ b/crates/feder-runtime-server/tests/common/mod.rs @@ -153,7 +153,7 @@ pub fn temporary_database_path(prefix: &str) -> std::path::PathBuf { )) } -fn fixture_actor_key_pair() -> Result { +pub fn fixture_actor_key_pair() -> Result { ActorKeyPair::from_pem( include_str!("../fixtures/rsa-private-key.pem").to_string(), include_str!("../fixtures/rsa-public-key.pem").to_string(), From d5fe01c7731f1adb92865ede7bbf2b7b7db077fd Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 20 Jul 2026 02:52:41 +0900 Subject: [PATCH 15/72] Resolve signing keys independently from actors Fetch signature key IDs directly and support standalone key documents as well as keys embedded in actor documents. Verify that the activity actor owns and advertises the resolved key before processing the request. Add coverage for signed Follows using an independent key URL. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/src/actor.rs | 79 ++++++++++----- crates/feder-runtime-server/src/inbox.rs | 57 +++++++---- .../feder-runtime-server/tests/cases/inbox.rs | 99 ++++++++++++++++--- 3 files changed, 179 insertions(+), 56 deletions(-) diff --git a/crates/feder-runtime-server/src/actor.rs b/crates/feder-runtime-server/src/actor.rs index edae040..e5a215c 100644 --- a/crates/feder-runtime-server/src/actor.rs +++ b/crates/feder-runtime-server/src/actor.rs @@ -77,18 +77,55 @@ impl ActorResolver { } pub async fn resolve(&self, actor_id: &Iri) -> Result { - let url = Url::parse(actor_id.as_str()) - .map_err(|_| ActorResolveError::InvalidActorId(actor_id.to_string()))?; + let body = self.fetch_document(actor_id).await?; + let document: ActorDocument = + serde_json::from_slice(&body).map_err(ActorResolveError::Deserialize)?; + let actor = document.into_actor(); + if actor.id != *actor_id { + return Err(ActorResolveError::ActorIdMismatch { + requested: actor_id.to_string(), + returned: actor.id.to_string(), + }); + } + + Ok(actor) + } + + pub(crate) async fn resolve_key( + &self, + key_id: &Iri, + ) -> Result { + let body = self.fetch_document(key_id).await?; + if let Ok(key) = serde_json::from_slice::(&body) + && key.id == *key_id + { + return Ok(key); + } + if let Ok(document) = serde_json::from_slice::(&body) + && let Some(Reference::Object(key)) = document.public_key + && key.id == *key_id + { + return Ok(*key); + } + + Err(ActorResolveError::KeyNotFound(key_id.to_string())) + } + + async fn fetch_document(&self, resource_id: &Iri) -> Result, ActorResolveError> { + let url = Url::parse(resource_id.as_str()) + .map_err(|_| ActorResolveError::InvalidResourceId(resource_id.to_string()))?; if !matches!(url.scheme(), "http" | "https") || !url.username().is_empty() || url.password().is_some() || url.host().is_none() { - return Err(ActorResolveError::InvalidActorId(actor_id.to_string())); + return Err(ActorResolveError::InvalidResourceId( + resource_id.to_string(), + )); } url::validate_literal_host(&url, self.address_policy).map_err(|address| { - ActorResolveError::PrivateActorAddress { - actor: actor_id.to_string(), + ActorResolveError::PrivateResourceAddress { + resource: resource_id.to_string(), address, } })?; @@ -102,7 +139,7 @@ impl ActorResolver { .map_err(ActorResolveError::Request)?; if !response.status().is_success() { return Err(ActorResolveError::UnsuccessfulStatus { - actor: actor_id.to_string(), + resource: resource_id.to_string(), status: response.status(), }); } @@ -130,17 +167,8 @@ impl ActorResolver { } body.extend_from_slice(&chunk); } - let document: ActorDocument = - serde_json::from_slice(&body).map_err(ActorResolveError::Deserialize)?; - let actor = document.into_actor(); - if actor.id != *actor_id { - return Err(ActorResolveError::ActorIdMismatch { - requested: actor_id.to_string(), - returned: actor.id.to_string(), - }); - } - Ok(actor) + Ok(body) } } @@ -189,21 +217,21 @@ pub enum ActorResolveError { #[error("failed to build actor resolution HTTP client")] BuildClient(#[source] reqwest::Error), - #[error("invalid remote actor ID: {0}")] - InvalidActorId(String), + #[error("invalid remote ActivityPub resource ID: {0}")] + InvalidResourceId(String), - #[error("remote actor {actor} uses non-public address {address}")] - PrivateActorAddress { - actor: String, + #[error("remote ActivityPub resource {resource} uses non-public address {address}")] + PrivateResourceAddress { + resource: String, address: std::net::IpAddr, }, - #[error("failed to fetch remote actor")] + #[error("failed to fetch remote ActivityPub resource")] Request(#[source] reqwest::Error), - #[error("fetching remote actor {actor} returned {status}")] + #[error("fetching remote ActivityPub resource {resource} returned {status}")] UnsuccessfulStatus { - actor: String, + resource: String, status: HttpStatusCode, }, @@ -218,4 +246,7 @@ pub enum ActorResolveError { #[error("remote actor ID mismatch: requested {requested}, returned {returned}")] ActorIdMismatch { requested: String, returned: String }, + + #[error("remote ActivityPub document does not contain key {0}")] + KeyNotFound(String), } diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index 46b0d1f..d8cbcef 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -65,16 +65,23 @@ fn accept_id_for_follow( async fn verify_inbox_request( app_state: &AppState, req: &InboxRequest, + activity_actor_id: Option<&Iri>, ) -> Result, StatusCode> { match app_state.inbox_auth_policy { InboxAuthPolicy::AllowUnsignedInsecureDev => Ok(None), - InboxAuthPolicy::RequireSigned => verify_signed_request(app_state, req).await.map(Some), + InboxAuthPolicy::RequireSigned => { + let activity_actor_id = activity_actor_id.ok_or(StatusCode::UNAUTHORIZED)?; + verify_signed_request(app_state, req, activity_actor_id) + .await + .map(Some) + } } } async fn verify_signed_request( app_state: &AppState, req: &InboxRequest, + activity_actor_id: &Iri, ) -> Result { let signature_header = req .headers @@ -115,24 +122,12 @@ async fn verify_signed_request( .key_id .parse() .map_err(|_| StatusCode::UNAUTHORIZED)?; - let mut actor_url = - reqwest::Url::parse(key_id.as_str()).map_err(|_| StatusCode::UNAUTHORIZED)?; - actor_url.set_fragment(None); - let actor_id: Iri = actor_url - .as_str() - .parse() - .map_err(|_| StatusCode::UNAUTHORIZED)?; - let actor = app_state + let public_key = app_state .actor_resolver - .resolve(&actor_id) + .resolve_key(&key_id) .await .map_err(|_| StatusCode::BAD_GATEWAY)?; - let Reference::Object(public_key) = - actor.public_key.as_ref().ok_or(StatusCode::UNAUTHORIZED)? - else { - return Err(StatusCode::UNAUTHORIZED); - }; - if public_key.id != key_id || public_key.owner != actor.id { + if public_key.owner != *activity_actor_id { return Err(StatusCode::UNAUTHORIZED); } @@ -149,9 +144,35 @@ async fn verify_signed_request( ) .map_err(|_| StatusCode::UNAUTHORIZED)?; + let actor = app_state + .actor_resolver + .resolve(activity_actor_id) + .await + .map_err(|_| StatusCode::BAD_GATEWAY)?; + let actor_owns_key = match actor.public_key.as_ref() { + Some(Reference::Id(advertised_key_id)) => advertised_key_id == &public_key.id, + Some(Reference::Object(advertised_key)) => { + advertised_key.id == public_key.id + && advertised_key.owner == actor.id + && advertised_key.public_key_pem == public_key.public_key_pem + } + None => false, + }; + if !actor_owns_key { + return Err(StatusCode::UNAUTHORIZED); + } + Ok(actor) } +fn activity_actor_id(value: &Value) -> Option { + let actor = value.get("actor")?; + let actor_id = actor + .as_str() + .or_else(|| actor.get("id").and_then(Value::as_str))?; + actor_id.parse().ok() +} + fn verify_request_date(headers: &HeaderMap) -> Result<(), StatusCode> { let date = headers .get("date") @@ -305,9 +326,9 @@ pub async fn inbox( body, }; - let verified_actor = verify_inbox_request(&app_state, &req).await?; - let value: Value = from_slice(&req.body).map_err(|_| StatusCode::BAD_REQUEST)?; + let activity_actor_id = activity_actor_id(&value); + let verified_actor = verify_inbox_request(&app_state, &req, activity_actor_id.as_ref()).await?; let activity_type = value.get("type").and_then(|value| value.as_str()); diff --git a/crates/feder-runtime-server/tests/cases/inbox.rs b/crates/feder-runtime-server/tests/cases/inbox.rs index a4a17cd..cbb7f6b 100644 --- a/crates/feder-runtime-server/tests/cases/inbox.rs +++ b/crates/feder-runtime-server/tests/cases/inbox.rs @@ -69,13 +69,47 @@ async fn spawn_actor_server() -> ( String, tokio::sync::mpsc::Receiver, tokio::task::JoinHandle<()>, +) { + let (actor_id, _key_id, receiver, task) = spawn_actor_server_inner(false).await; + (actor_id, receiver, task) +} + +async fn spawn_actor_server_with_separate_key() -> ( + String, + String, + tokio::sync::mpsc::Receiver, + tokio::task::JoinHandle<()>, +) { + spawn_actor_server_inner(true).await +} + +async fn spawn_actor_server_inner( + separate_key: bool, +) -> ( + String, + String, + tokio::sync::mpsc::Receiver, + tokio::task::JoinHandle<()>, ) { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind actor server"); let address = listener.local_addr().expect("actor server address"); let actor_id = format!("http://{address}/users/bob"); + let key_id = if separate_key { + format!("http://{address}/keys/1") + } else { + format!("{actor_id}#main-key") + }; let inbox = format!("http://{address}/inbox"); + let public_key = json!({ + "id": key_id, + "type": "CryptographicKey", + "owner": actor_id, + "publicKeyPem": fixture_actor_key_pair() + .expect("load actor key fixture") + .public_key_pem() + }); let actor = json!({ "@context": [ "https://www.w3.org/ns/activitystreams", @@ -87,14 +121,7 @@ async fn spawn_actor_server() -> ( "outbox": format!("http://{address}/users/bob/outbox"), "preferredUsername": "bob", "endpoints": { "sharedInbox": inbox }, - "publicKey": { - "id": format!("{actor_id}#main-key"), - "type": "CryptographicKey", - "owner": actor_id, - "publicKeyPem": fixture_actor_key_pair() - .expect("load actor key fixture") - .public_key_pem() - } + "publicKey": if separate_key { json!(key_id) } else { public_key.clone() } }); let (sender, receiver) = tokio::sync::mpsc::channel(1); let app = Router::new() @@ -105,6 +132,18 @@ async fn spawn_actor_server() -> ( async move { ([(CONTENT_TYPE, "application/activity+json")], Json(actor)) } }), ) + .route( + "/keys/1", + get(move || { + let public_key = public_key.clone(); + async move { + ( + [(CONTENT_TYPE, "application/activity+json")], + Json(public_key), + ) + } + }), + ) .route( "/inbox", post(move |headers: HeaderMap, uri: Uri, body: Bytes| { @@ -124,7 +163,7 @@ async fn spawn_actor_server() -> ( .expect("serve actor endpoint"); }); - (actor_id, receiver, task) + (actor_id, key_id, receiver, task) } async fn post_inbox( @@ -148,7 +187,7 @@ async fn post_inbox( async fn post_signed_inbox( app: Router, uri: &str, - actor_id: &str, + key_id: &str, signed_body: &[u8], delivered_body: impl Into, ) -> axum::response::Response { @@ -163,7 +202,7 @@ async fn post_signed_inbox( ]; let signature = sign_draft_cavage( &fixture_actor_key_pair().expect("load actor key fixture"), - &format!("{actor_id}#main-key"), + key_id, "POST", uri, &headers, @@ -281,7 +320,39 @@ async fn verifies_signed_id_only_follow() { let response = post_signed_inbox( router_with_state(state.clone()), "/users/alice/inbox", - &actor_id, + &format!("{actor_id}#main-key"), + &body, + body.clone(), + ) + .await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert_eq!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .len(), + 1 + ); + requests.recv().await.expect("receive Accept request"); + actor_server.abort(); +} + +#[tokio::test] +async fn verifies_signed_follow_with_independent_key_id() { + let (actor_id, key_id, mut requests, actor_server) = + spawn_actor_server_with_separate_key().await; + let mut config = test_config(); + config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; + let state = test_app_state(config).expect("build app state"); + let body = id_only_follow_body(&actor_id); + let response = post_signed_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + &key_id, &body, body.clone(), ) @@ -313,7 +384,7 @@ async fn signed_follow_rejects_tampered_body() { let response = post_signed_inbox( router_with_state(state.clone()), "/users/alice/inbox", - &actor_id, + &format!("{actor_id}#main-key"), &signed_body, delivered_body, ) @@ -342,7 +413,7 @@ async fn signed_follow_rejects_actor_different_from_key_owner() { let response = post_signed_inbox( router_with_state(state.clone()), "/users/alice/inbox", - &actor_id, + &format!("{actor_id}#main-key"), &body, body.clone(), ) From 4192b14977653d97bf88709f2777d4e7c055505e Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 20 Jul 2026 03:32:00 +0900 Subject: [PATCH 16/72] Accept public keys without an explicit type Default omitted public key types to CryptographicKey for compatibility with Mastodon actor documents. Cover the representation in vocabulary and signed Follow integration tests. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/tests/cases/inbox.rs | 1 - crates/feder-vocab/src/lib.rs | 14 +++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/feder-runtime-server/tests/cases/inbox.rs b/crates/feder-runtime-server/tests/cases/inbox.rs index cbb7f6b..bd47ad1 100644 --- a/crates/feder-runtime-server/tests/cases/inbox.rs +++ b/crates/feder-runtime-server/tests/cases/inbox.rs @@ -104,7 +104,6 @@ async fn spawn_actor_server_inner( let inbox = format!("http://{address}/inbox"); let public_key = json!({ "id": key_id, - "type": "CryptographicKey", "owner": actor_id, "publicKeyPem": fixture_actor_key_pair() .expect("load actor key fixture") diff --git a/crates/feder-vocab/src/lib.rs b/crates/feder-vocab/src/lib.rs index 9672ecd..0413ecb 100644 --- a/crates/feder-vocab/src/lib.rs +++ b/crates/feder-vocab/src/lib.rs @@ -233,7 +233,7 @@ pub enum CryptographicKeyType { #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct CryptographicKey { pub id: Iri, - #[serde(rename = "type")] + #[serde(rename = "type", default)] pub kind: CryptographicKeyType, pub owner: Iri, #[serde(rename = "publicKeyPem")] @@ -442,6 +442,18 @@ mod tests { use serde::de::DeserializeOwned; use serde_json::json; + #[test] + fn cryptographic_key_defaults_missing_type() { + let key: CryptographicKey = serde_json::from_value(serde_json::json!({ + "id": "https://example.com/users/alice#main-key", + "owner": "https://example.com/users/alice", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----" + })) + .expect("deserialize key without type"); + + assert_eq!(key.kind, CryptographicKeyType::CryptographicKey); + } + fn roundtrip(value: &T) -> T where T: DeserializeOwned + Serialize, From 4691fe4cf5da0201351645c423b2548217807e9c Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 20 Jul 2026 17:25:47 +0900 Subject: [PATCH 17/72] Handle Undo Follow activities Add Undo vocabulary and core handling for follower removal. Validate that the Undo actor owns the embedded Follow, remove matching in-memory and SQLite follower state, and cover signed unfollow and forged Undo requests. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-core/src/lib.rs | 146 ++++++++++++++++++ crates/feder-runtime-server/README.md | 12 +- crates/feder-runtime-server/src/inbox.rs | 81 ++++++---- .../src/storage/sqlite.rs | 32 +++- .../feder-runtime-server/tests/cases/inbox.rs | 110 +++++++++++++ crates/feder-vocab/src/lib.rs | 30 ++++ .../tests/activitypub_serialization.rs | 24 ++- 7 files changed, 397 insertions(+), 38 deletions(-) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index e09b1e3..5957c09 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -55,6 +55,10 @@ impl FederCore { let actions = self.state.record_follow(input); HandleResult::new(actions) } + Input::ReceivedUndoFollow(input) => { + let actions = self.state.record_undo_follow(input); + HandleResult::new(actions) + } Input::UserCreateNote(input) => { let actions = self.state.record_created_note(input); HandleResult::new(actions) @@ -206,6 +210,45 @@ impl FederState { actions } + fn record_undo_follow(&mut self, input: ReceivedUndoFollow) -> Vec { + let undo = input.undo; + let Some(undo_actor) = reference_id(&undo.actor) else { + return Vec::new(); + }; + let vocab::Reference::Object(follow) = undo.object else { + return Vec::new(); + }; + let Some(follower) = reference_id(&follow.actor) else { + return Vec::new(); + }; + let Some(following) = reference_id(&follow.object) else { + return Vec::new(); + }; + + if undo_actor != follower || following != &self.local_actor.id { + return Vec::new(); + } + + let relation = Follower { + follower: follower.clone(), + following: following.clone(), + }; + self.followers.retain(|existing| existing != &relation); + if !self + .followers + .iter() + .any(|existing| existing.follower == *follower) + { + self.delivery_targets + .retain(|target| target.actor != *follower); + } + + Vec::from([Action::RemoveFollower(RemoveFollower { + follower: follower.clone(), + following: following.clone(), + })]) + } + fn record_created_note(&mut self, input: UserCreateNote) -> Vec { let Some(actor) = reference_id(&input.actor) else { return Vec::new(); @@ -270,6 +313,7 @@ impl HasId for vocab::Actor { #[non_exhaustive] pub enum Input { ReceivedFollow(ReceivedFollow), + ReceivedUndoFollow(ReceivedUndoFollow), UserCreateNote(UserCreateNote), } @@ -283,6 +327,12 @@ pub struct ReceivedFollow { pub accept_id: vocab::Iri, } +/// Runtime-provided data for handling a received Undo of a Follow. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReceivedUndoFollow { + pub undo: vocab::Undo, +} + /// Runtime-provided data for creating a local note. /// /// IDs and timestamps are inputs so the core does not depend on clocks, @@ -300,6 +350,10 @@ impl Input { pub fn received_follow(follow: vocab::Follow, accept_id: vocab::Iri) -> Self { Self::ReceivedFollow(ReceivedFollow { follow, accept_id }) } + + pub fn received_undo_follow(undo: vocab::Undo) -> Self { + Self::ReceivedUndoFollow(ReceivedUndoFollow { undo }) + } } #[derive(Clone, Debug, Eq, PartialEq)] @@ -323,6 +377,7 @@ pub struct DeliveryTarget { #[non_exhaustive] pub enum Action { StoreFollower(StoreFollower), + RemoveFollower(RemoveFollower), StoreDeliveryTarget(StoreDeliveryTarget), StoreObject(StoreObject), SendActivity(SendActivity), @@ -334,6 +389,14 @@ pub struct StoreFollower { pub following: vocab::Reference, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RemoveFollower { + /// The remote actor ending the follower relation. + pub follower: vocab::Iri, + /// The local actor that was followed. + pub following: vocab::Iri, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct StoreDeliveryTarget { pub target: DeliveryTarget, @@ -409,6 +472,14 @@ mod tests { }) } + fn received_undo_follow(follow: vocab::Follow, actor_id: &str) -> Input { + Input::received_undo_follow(vocab::Undo::new( + iri("https://remote.example/activities/undo/1"), + vocab::Reference::id(iri(actor_id)), + vocab::Reference::object(follow), + )) + } + #[test] fn core_is_created_with_local_actor_state() { let core = core(); @@ -605,6 +676,81 @@ mod tests { assert!(core.state().delivery_targets().is_empty()); } + #[test] + fn received_undo_follow_removes_follower_and_delivery_target() { + 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.clone(), + "https://example.com/activities/accept/1", + )); + + let result = core.handle(received_undo_follow( + follow, + "https://remote.example/users/bob", + )); + + assert_eq!( + result.actions, + Vec::from([Action::RemoveFollower(RemoveFollower { + follower: iri("https://remote.example/users/bob"), + following: iri("https://example.com/users/alice"), + })]) + ); + assert!(core.state().followers().is_empty()); + assert!(core.state().delivery_targets().is_empty()); + } + + #[test] + fn received_undo_follow_rejects_actor_that_does_not_own_follow() { + 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.clone(), + "https://example.com/activities/accept/1", + )); + + let result = core.handle(received_undo_follow( + follow, + "https://remote.example/users/mallory", + )); + + assert!(result.is_empty()); + assert_eq!(core.state().followers().len(), 1); + assert_eq!(core.state().delivery_targets().len(), 1); + } + + #[test] + fn received_undo_follow_emits_idempotent_removal_action() { + 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 mut core = core(); + + let result = core.handle(received_undo_follow( + follow, + "https://remote.example/users/bob", + )); + + assert_eq!( + result.actions, + Vec::from([Action::RemoveFollower(RemoveFollower { + follower: iri("https://remote.example/users/bob"), + following: iri("https://example.com/users/alice"), + })]) + ); + } + #[test] fn user_create_note_records_created_object_and_emits_store_action() { let input = UserCreateNote { diff --git a/crates/feder-runtime-server/README.md b/crates/feder-runtime-server/README.md index 9a3d470..77c4d03 100644 --- a/crates/feder-runtime-server/README.md +++ b/crates/feder-runtime-server/README.md @@ -9,12 +9,12 @@ 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. The -runtime can use in-memory storage for tests and examples, or file-backed SQLite -storage for persisted follower state. Outgoing `SendActivity` actions are sent -synchronously to recipient inboxes as ActivityPub JSON signed with the actor's -draft-Cavage RSA key. HTTP signature verification is intentionally left to a -later issue. +ActivityPub inbox handling for Follow and embedded Undo(Follow) activities is +included. The runtime can use in-memory storage for tests and examples, or +file-backed SQLite storage for persisted follower state. Outgoing +`SendActivity` actions are sent synchronously to recipient inboxes as +ActivityPub JSON signed with the actor's draft-Cavage RSA key. Incoming inbox +requests can require verification with the same signature scheme. Example diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index d8cbcef..b041b8b 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -29,7 +29,7 @@ use feder_core::{ Input, http_signatures::{create_sha256_digest_header, verify_draft_cavage}, }; -use feder_vocab::{Actor, Follow, Iri, Reference}; +use feder_vocab::{Actor, Follow, Iri, Reference, Undo}; use serde_json::{Value, from_slice, from_value}; use crate::app::AppState; @@ -173,6 +173,13 @@ fn activity_actor_id(value: &Value) -> Option { actor_id.parse().ok() } +fn actor_reference_id(reference: &Reference) -> &Iri { + match reference { + Reference::Id(actor_id) => actor_id, + Reference::Object(actor) => &actor.id, + } +} + fn verify_request_date(headers: &HeaderMap) -> Result<(), StatusCode> { let date = headers .get("date") @@ -332,36 +339,50 @@ pub async fn inbox( let activity_type = value.get("type").and_then(|value| value.as_str()); - // Unsupported activity types will be ignored - if activity_type != Some("Follow") { - return Ok(StatusCode::ACCEPTED.into_response()); - } - let mut follow: Follow = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; - let follows_local_actor = match &follow.object { - feder_vocab::Reference::Id(actor_id) => actor_id == &app_state.local_actor.id, - feder_vocab::Reference::Object(actor) => actor.id == app_state.local_actor.id, - }; - if !follows_local_actor { - return Ok(StatusCode::ACCEPTED.into_response()); - } - if let Some(actor) = verified_actor { - let actor_matches_signature = match &follow.actor { - Reference::Id(actor_id) => actor_id == &actor.id, - Reference::Object(follow_actor) => follow_actor.id == actor.id, - }; - if !actor_matches_signature { - return Err(StatusCode::UNAUTHORIZED); + let input = match activity_type { + Some("Follow") => { + let mut follow: Follow = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; + if actor_reference_id(&follow.object) != &app_state.local_actor.id { + return Ok(StatusCode::ACCEPTED.into_response()); + } + if let Some(actor) = verified_actor { + if actor_reference_id(&follow.actor) != &actor.id { + return Err(StatusCode::UNAUTHORIZED); + } + follow.actor = Reference::object(actor); + } else { + app_state + .actor_resolver + .resolve_reference(&mut follow.actor) + .await + .map_err(|_| StatusCode::BAD_GATEWAY)?; + } + let accept_id = accept_id_for_follow(&app_state.local_actor.id, &follow.id)?; + Input::received_follow(follow, accept_id) } - follow.actor = Reference::object(actor); - } else { - app_state - .actor_resolver - .resolve_reference(&mut follow.actor) - .await - .map_err(|_| StatusCode::BAD_GATEWAY)?; - } - let accept_id = accept_id_for_follow(&app_state.local_actor.id, &follow.id)?; - let input = Input::received_follow(follow, accept_id); + Some("Undo") => { + let undo: Undo = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; + let Reference::Object(follow) = &undo.object else { + return Ok(StatusCode::ACCEPTED.into_response()); + }; + let undo_actor_id = actor_reference_id(&undo.actor); + if undo_actor_id != actor_reference_id(&follow.actor) { + return Err(StatusCode::UNAUTHORIZED); + } + if verified_actor + .as_ref() + .is_some_and(|actor| undo_actor_id != &actor.id) + { + return Err(StatusCode::UNAUTHORIZED); + } + if actor_reference_id(&follow.object) != &app_state.local_actor.id { + return Ok(StatusCode::ACCEPTED.into_response()); + } + Input::received_undo_follow(undo) + } + // Unsupported activity types will be ignored. + _ => return Ok(StatusCode::ACCEPTED.into_response()), + }; let result = { let mut core = app_state diff --git a/crates/feder-runtime-server/src/storage/sqlite.rs b/crates/feder-runtime-server/src/storage/sqlite.rs index 6c8d0b6..16e42f1 100644 --- a/crates/feder-runtime-server/src/storage/sqlite.rs +++ b/crates/feder-runtime-server/src/storage/sqlite.rs @@ -106,6 +106,15 @@ impl RuntimeStore for SqliteStore { ], )?; } + Action::RemoveFollower(action) => { + tx.execute( + r#" + DELETE FROM followers + WHERE follower_actor_id = ?1 AND following_actor_id = ?2 + "#, + params![action.follower.as_str(), action.following.as_str()], + )?; + } Action::StoreDeliveryTarget(action) => { tx.execute( r#" @@ -263,7 +272,7 @@ fn parse_optional_iri(value: Option) -> Result, StoreError> #[cfg(test)] mod tests { - use feder_core::{Action, StoreFollower}; + use feder_core::{Action, RemoveFollower, StoreFollower}; use super::*; @@ -480,6 +489,27 @@ mod tests { assert_eq!(following, "https://example.com/users/alice"); } + #[test] + fn persist_actions_removes_follower() { + let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); + store + .persist_actions(&[store_follower_action()]) + .expect("persist follower action"); + + store + .persist_actions(&[Action::RemoveFollower(RemoveFollower { + follower: iri("https://remote.example/users/bob"), + following: iri("https://example.com/users/alice"), + })]) + .expect("persist follower removal action"); + + let follower_count: i64 = store + .conn + .query_row("SELECT COUNT(*) FROM followers", [], |row| row.get(0)) + .expect("query follower count"); + assert_eq!(follower_count, 0); + } + #[test] fn persist_actions_stores_embedded_follower_inbox() { let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); diff --git a/crates/feder-runtime-server/tests/cases/inbox.rs b/crates/feder-runtime-server/tests/cases/inbox.rs index bd47ad1..0208e78 100644 --- a/crates/feder-runtime-server/tests/cases/inbox.rs +++ b/crates/feder-runtime-server/tests/cases/inbox.rs @@ -65,6 +65,22 @@ fn id_only_follow_body(actor_id: &str) -> Vec { .expect("serialize ID-only follow") } +fn undo_follow_body(actor_id: &str, follow_actor_id: &str) -> Vec { + serde_json::to_vec(&json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Undo", + "id": format!("{actor_id}/undo/1"), + "actor": actor_id, + "object": { + "type": "Follow", + "id": format!("{actor_id}/follows/1"), + "actor": follow_actor_id, + "object": "http://127.0.0.1:3000/users/alice" + } + })) + .expect("serialize Undo Follow") +} + async fn spawn_actor_server() -> ( String, tokio::sync::mpsc::Receiver, @@ -372,6 +388,100 @@ async fn verifies_signed_follow_with_independent_key_id() { actor_server.abort(); } +#[tokio::test] +async fn signed_undo_follow_removes_persisted_follower() { + let (actor_id, mut requests, actor_server) = spawn_actor_server().await; + let mut config = test_config(); + config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; + let state = test_app_state(config).expect("build app state"); + let key_id = format!("{actor_id}#main-key"); + let follow_body = id_only_follow_body(&actor_id); + let follow_response = post_signed_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + &key_id, + &follow_body, + follow_body.clone(), + ) + .await; + assert_eq!(follow_response.status(), StatusCode::ACCEPTED); + requests.recv().await.expect("receive Accept request"); + + let undo_body = undo_follow_body(&actor_id, &actor_id); + let undo_response = post_signed_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + &key_id, + &undo_body, + undo_body.clone(), + ) + .await; + + assert_eq!(undo_response.status(), StatusCode::ACCEPTED); + assert!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); + assert!( + state + .store + .lock() + .expect("store lock") + .list_followers(&state.local_actor.id) + .expect("list followers") + .is_empty() + ); + actor_server.abort(); +} + +#[tokio::test] +async fn signed_undo_follow_rejects_actor_that_does_not_own_follow() { + let (actor_id, mut requests, actor_server) = spawn_actor_server().await; + let mut config = test_config(); + config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; + let state = test_app_state(config).expect("build app state"); + let key_id = format!("{actor_id}#main-key"); + let follow_body = id_only_follow_body(&actor_id); + let follow_response = post_signed_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + &key_id, + &follow_body, + follow_body.clone(), + ) + .await; + assert_eq!(follow_response.status(), StatusCode::ACCEPTED); + requests.recv().await.expect("receive Accept request"); + + let undo_body = undo_follow_body(&actor_id, "https://remote.example/users/mallory"); + let undo_response = post_signed_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + &key_id, + &undo_body, + undo_body.clone(), + ) + .await; + + assert_eq!(undo_response.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + state + .store + .lock() + .expect("store lock") + .list_followers(&state.local_actor.id) + .expect("list followers") + .len(), + 1 + ); + actor_server.abort(); +} + #[tokio::test] async fn signed_follow_rejects_tampered_body() { let (actor_id, _requests, actor_server) = spawn_actor_server().await; diff --git a/crates/feder-vocab/src/lib.rs b/crates/feder-vocab/src/lib.rs index 0413ecb..12cb62e 100644 --- a/crates/feder-vocab/src/lib.rs +++ b/crates/feder-vocab/src/lib.rs @@ -173,6 +173,7 @@ macro_rules! activitystreams_type { activitystreams_type!(NoteType, Note); activitystreams_type!(FollowType, Follow); activitystreams_type!(AcceptType, Accept); +activitystreams_type!(UndoType, Undo); activitystreams_type!(CreateType, Create); /// A JSON-LD context represented by one or more IRIs. @@ -406,6 +407,35 @@ impl Accept { } } +/// A minimal Undo activity for a Follow. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Undo { + #[serde(rename = "@context", skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(rename = "type")] + pub kind: UndoType, + pub id: Iri, + pub actor: Reference, + pub object: Reference, +} + +impl Undo { + #[must_use] + pub fn new(id: Iri, actor: Reference, object: Reference) -> Self { + Self { + context: Some( + ACTIVITYSTREAMS_CONTEXT + .parse() + .expect("valid ActivityStreams IRI"), + ), + kind: UndoType::default(), + id, + actor, + object, + } + } +} + /// A minimal Create activity for a concrete object type. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct Create { diff --git a/crates/feder-vocab/tests/activitypub_serialization.rs b/crates/feder-vocab/tests/activitypub_serialization.rs index 7d651d8..8c68179 100644 --- a/crates/feder-vocab/tests/activitypub_serialization.rs +++ b/crates/feder-vocab/tests/activitypub_serialization.rs @@ -15,7 +15,7 @@ use feder_vocab::{ ACTIVITYSTREAMS_CONTEXT, Accept, Actor, Create, CryptographicKey, Follow, Iri, Note, Reference, - References, SECURITY_CONTEXT, + References, SECURITY_CONTEXT, Undo, }; use serde_json::{Value, json}; @@ -124,6 +124,28 @@ fn accept_activity_can_embed_follow_activity() { ); } +#[test] +fn undo_activity_can_embed_follow_activity() { + let follow: Follow = + serde_json::from_value(incoming_follow_json()).expect("deserialize incoming follow"); + let undo = Undo::new( + iri("https://remote.example/activities/undo/1"), + Reference::id(iri("https://remote.example/users/bob")), + Reference::object(follow), + ); + + assert_eq!( + serialize_to_value(undo), + json!({ + "@context": ACTIVITYSTREAMS_CONTEXT, + "type": "Undo", + "id": "https://remote.example/activities/undo/1", + "actor": "https://remote.example/users/bob", + "object": incoming_follow_json() + }) + ); +} + #[test] fn local_note_serializes_as_create_activity() { let mut note = Note::new(iri("https://example.com/notes/1")); From 160e70ffad7f4b7ec5cd3d57bfe329307fce277f Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 20 Jul 2026 22:09:24 +0900 Subject: [PATCH 18/72] Add followers collection vocabulary Add the OrderedCollection vocabulary type and followers property for actors. Advertise the local actor's followers collection URI and preserve the property when resolving remote actors. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/src/actor.rs | 2 ++ crates/feder-runtime-server/src/app.rs | 5 +++ .../feder-runtime-server/tests/cases/actor.rs | 4 +++ .../feder-runtime-server/tests/common/mod.rs | 5 +++ crates/feder-vocab/src/lib.rs | 35 +++++++++++++++++++ .../tests/activitypub_serialization.rs | 30 ++++++++++++++-- 6 files changed, 79 insertions(+), 2 deletions(-) diff --git a/crates/feder-runtime-server/src/actor.rs b/crates/feder-runtime-server/src/actor.rs index e5a215c..d03198b 100644 --- a/crates/feder-runtime-server/src/actor.rs +++ b/crates/feder-runtime-server/src/actor.rs @@ -188,6 +188,7 @@ struct ActorDocument { id: Iri, inbox: Iri, outbox: Iri, + followers: Option, #[serde(rename = "preferredUsername")] preferred_username: Option, name: Option, @@ -204,6 +205,7 @@ impl ActorDocument { id: self.id, inbox: self.inbox, outbox: self.outbox, + followers: self.followers, preferred_username: self.preferred_username, name: self.name, endpoints: self.endpoints, diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs index ad11d97..0c8b4a9 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -50,6 +50,11 @@ impl AppState { let mut actor = Actor::person(config.actor_id, config.inbox, config.outbox); actor.preferred_username = Some(config.username.clone()); actor.name = Some(config.username.clone()); + actor.followers = Some( + format!("{}/followers", actor.id.as_str().trim_end_matches('/')) + .parse() + .expect("appending a followers path preserves a valid actor IRI"), + ); let mut store = match &config.storage { StorageConfig::InMemory => SqliteStore::open_in_memory()?, diff --git a/crates/feder-runtime-server/tests/cases/actor.rs b/crates/feder-runtime-server/tests/cases/actor.rs index 951c051..5c4e0b7 100644 --- a/crates/feder-runtime-server/tests/cases/actor.rs +++ b/crates/feder-runtime-server/tests/cases/actor.rs @@ -58,6 +58,10 @@ async fn returns_local_actor() { assert_eq!(json["id"], "http://127.0.0.1:3000/users/alice"); assert_eq!(json["inbox"], "http://127.0.0.1:3000/users/alice/inbox"); assert_eq!(json["outbox"], "http://127.0.0.1:3000/users/alice/outbox"); + assert_eq!( + json["followers"], + "http://127.0.0.1:3000/users/alice/followers" + ); assert_eq!(json["preferredUsername"], "alice"); assert_eq!(json["name"], "alice"); assert_eq!( diff --git a/crates/feder-runtime-server/tests/common/mod.rs b/crates/feder-runtime-server/tests/common/mod.rs index 196ed27..f837bda 100644 --- a/crates/feder-runtime-server/tests/common/mod.rs +++ b/crates/feder-runtime-server/tests/common/mod.rs @@ -94,6 +94,11 @@ pub fn test_app_state(config: RuntimeConfig) -> Result { let mut actor = Actor::person(config.actor_id, config.inbox, config.outbox); actor.preferred_username = Some(config.username.clone()); actor.name = Some(config.username.clone()); + actor.followers = Some( + format!("{}/followers", actor.id.as_str().trim_end_matches('/')) + .parse() + .expect("valid followers IRI"), + ); let mut store = match &config.storage { StorageConfig::InMemory => SqliteStore::open_in_memory()?, diff --git a/crates/feder-vocab/src/lib.rs b/crates/feder-vocab/src/lib.rs index 12cb62e..7591dfa 100644 --- a/crates/feder-vocab/src/lib.rs +++ b/crates/feder-vocab/src/lib.rs @@ -175,6 +175,7 @@ activitystreams_type!(FollowType, Follow); activitystreams_type!(AcceptType, Accept); activitystreams_type!(UndoType, Undo); activitystreams_type!(CreateType, Create); +activitystreams_type!(OrderedCollectionType, OrderedCollection); /// A JSON-LD context represented by one or more IRIs. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -263,6 +264,8 @@ pub struct Actor { pub id: Iri, pub inbox: Iri, pub outbox: Iri, + #[serde(skip_serializing_if = "Option::is_none")] + pub followers: Option, #[serde(rename = "preferredUsername", skip_serializing_if = "Option::is_none")] pub preferred_username: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -291,6 +294,7 @@ impl Actor { id, inbox, outbox, + followers: None, preferred_username: None, name: None, endpoints: None, @@ -465,6 +469,37 @@ impl Create { } } +/// A minimal ActivityStreams ordered collection. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct OrderedCollection { + #[serde(rename = "@context", skip_serializing_if = "Option::is_none")] + pub context: Option, + #[serde(rename = "type")] + pub kind: OrderedCollectionType, + pub id: Iri, + #[serde(rename = "totalItems")] + pub total_items: u64, + #[serde(rename = "orderedItems")] + pub ordered_items: Vec, +} + +impl OrderedCollection { + #[must_use] + pub fn new(id: Iri, total_items: u64, ordered_items: Vec) -> Self { + Self { + context: Some( + ACTIVITYSTREAMS_CONTEXT + .parse() + .expect("valid ActivityStreams IRI"), + ), + kind: OrderedCollectionType::default(), + id, + total_items, + ordered_items, + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/feder-vocab/tests/activitypub_serialization.rs b/crates/feder-vocab/tests/activitypub_serialization.rs index 8c68179..1494636 100644 --- a/crates/feder-vocab/tests/activitypub_serialization.rs +++ b/crates/feder-vocab/tests/activitypub_serialization.rs @@ -14,8 +14,8 @@ // along with this program. If not, see . use feder_vocab::{ - ACTIVITYSTREAMS_CONTEXT, Accept, Actor, Create, CryptographicKey, Follow, Iri, Note, Reference, - References, SECURITY_CONTEXT, Undo, + ACTIVITYSTREAMS_CONTEXT, Accept, Actor, Create, CryptographicKey, Follow, Iri, Note, + OrderedCollection, Reference, References, SECURITY_CONTEXT, Undo, }; use serde_json::{Value, json}; @@ -146,6 +146,32 @@ fn undo_activity_can_embed_follow_activity() { ); } +#[test] +fn ordered_collection_serializes_actor_iris() { + let collection = OrderedCollection::new( + iri("https://example.com/users/alice/followers"), + 2, + vec![ + iri("https://remote.example/users/bob"), + iri("https://another.example/users/carol"), + ], + ); + + assert_eq!( + serialize_to_value(collection), + json!({ + "@context": ACTIVITYSTREAMS_CONTEXT, + "type": "OrderedCollection", + "id": "https://example.com/users/alice/followers", + "totalItems": 2, + "orderedItems": [ + "https://remote.example/users/bob", + "https://another.example/users/carol" + ] + }) + ); +} + #[test] fn local_note_serializes_as_create_activity() { let mut note = Note::new(iri("https://example.com/notes/1")); From 86a31010bed8c72792e4cbb4ba69a3b8778e225a Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 20 Jul 2026 22:26:09 +0900 Subject: [PATCH 19/72] Serve the followers collection Add a storage-backed followers handler and expose it through the runtime router. Return an ActivityPub OrderedCollection with current follower IDs and counts, and cover collection updates, response headers, and unknown users. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/README.md | 4 +- crates/feder-runtime-server/src/app.rs | 2 + crates/feder-runtime-server/src/followers.rs | 62 ++++++++ crates/feder-runtime-server/src/lib.rs | 1 + .../tests/cases/followers.rs | 133 ++++++++++++++++++ crates/feder-runtime-server/tests/runtime.rs | 2 + 6 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 crates/feder-runtime-server/src/followers.rs create mode 100644 crates/feder-runtime-server/tests/cases/followers.rs diff --git a/crates/feder-runtime-server/README.md b/crates/feder-runtime-server/README.md index 77c4d03..17aed6e 100644 --- a/crates/feder-runtime-server/README.md +++ b/crates/feder-runtime-server/README.md @@ -6,8 +6,8 @@ systems. This crate builds an Axum router from caller-provided runtime configuration. 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. +route with its followers collection. The caller chooses concrete bind +addresses, actor IRIs, usernames, and handle hosts. ActivityPub inbox handling for Follow and embedded Undo(Follow) activities is included. The runtime can use in-memory storage for tests and examples, or diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs index 0c8b4a9..7ac9cf4 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -18,6 +18,7 @@ use std::sync::{Arc, Mutex}; use crate::Error; use crate::actor::ActorResolver; use crate::config::{InboxAuthPolicy, RuntimeConfig, StorageConfig}; +use crate::followers::followers; use crate::send::ActivitySender; use crate::storage::{RuntimeStore, SqliteStore}; use crate::webfinger::webfinger; @@ -111,6 +112,7 @@ pub fn router_with_state(state: AppState) -> Router { .route("/healthz", get(healthz)) .route("/.well-known/webfinger", get(webfinger)) .route("/users/{username}", get(actor)) + .route("/users/{username}/followers", get(followers)) .route("/users/{username}/inbox", post(inbox)) .layer(DefaultBodyLimit::max(1_048_576)) .with_state(state) diff --git a/crates/feder-runtime-server/src/followers.rs b/crates/feder-runtime-server/src/followers.rs new file mode 100644 index 0000000..f951c18 --- /dev/null +++ b/crates/feder-runtime-server/src/followers.rs @@ -0,0 +1,62 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use axum::{ + Json, + extract::{Path, State}, + http::{StatusCode, header}, + response::{IntoResponse, Response}, +}; +use feder_vocab::OrderedCollection; + +use crate::{app::AppState, storage::RuntimeStore}; + +/// Return the local actor's followers as a one-shot ordered collection. +pub async fn followers( + State(app_state): State, + Path(username): Path, +) -> Result { + if username != app_state.username { + return Err(StatusCode::NOT_FOUND); + } + + let followers = app_state + .store + .lock() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .list_followers(&app_state.local_actor.id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let total_items = + u64::try_from(followers.len()).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let ordered_items = followers + .into_iter() + .map(|follower| follower.follower) + .collect(); + let collection_id = app_state + .local_actor + .followers + .clone() + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + let collection = OrderedCollection::new(collection_id, total_items, ordered_items); + + Ok(( + [ + (header::CONTENT_TYPE, "application/activity+json"), + (header::VARY, "Accept"), + ], + Json(collection), + ) + .into_response()) +} diff --git a/crates/feder-runtime-server/src/lib.rs b/crates/feder-runtime-server/src/lib.rs index 975825b..94154a8 100644 --- a/crates/feder-runtime-server/src/lib.rs +++ b/crates/feder-runtime-server/src/lib.rs @@ -17,6 +17,7 @@ pub mod actor; pub mod app; pub mod config; pub mod error; +pub mod followers; pub mod inbox; pub mod send; pub mod storage; diff --git a/crates/feder-runtime-server/tests/cases/followers.rs b/crates/feder-runtime-server/tests/cases/followers.rs new file mode 100644 index 0000000..7abf6bd --- /dev/null +++ b/crates/feder-runtime-server/tests/cases/followers.rs @@ -0,0 +1,133 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, +}; +use feder_core::{Action, RemoveFollower, StoreFollower}; +use feder_runtime_server::{app::router_with_state, storage::RuntimeStore}; +use feder_vocab::{Iri, Reference}; +use serde_json::Value; +use tower::ServiceExt; + +use crate::common::{test_app_state, test_config, test_router}; + +fn iri(value: &str) -> Iri { + value.parse().expect("valid test IRI") +} + +async fn get_followers(app: axum::Router, username: &str) -> axum::response::Response { + app.oneshot( + Request::builder() + .uri(format!("/users/{username}/followers")) + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response") +} + +async fn response_json(response: axum::response::Response) -> Value { + let body = to_bytes(response.into_body(), 4096) + .await + .expect("read response body"); + serde_json::from_slice(&body).expect("valid JSON") +} + +fn store_follower(follower: &str) -> Action { + Action::StoreFollower(StoreFollower { + follower: Reference::id(iri(follower)), + following: Reference::id(iri("http://127.0.0.1:3000/users/alice")), + }) +} + +#[tokio::test] +async fn returns_empty_followers_collection_with_activitypub_headers() { + let response = get_followers(test_router(test_config()).expect("build router"), "alice").await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::CONTENT_TYPE).unwrap(), + "application/activity+json" + ); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); + + let json = response_json(response).await; + assert_eq!(json["@context"], "https://www.w3.org/ns/activitystreams"); + assert_eq!(json["type"], "OrderedCollection"); + assert_eq!(json["id"], "http://127.0.0.1:3000/users/alice/followers"); + assert_eq!(json["totalItems"], 0); + assert_eq!(json["orderedItems"], serde_json::json!([])); +} + +#[tokio::test] +async fn returns_stored_followers_and_count() { + let state = test_app_state(test_config()).expect("build app state"); + state + .store + .lock() + .expect("store lock") + .persist_actions(&[ + store_follower("https://remote.example/users/carol"), + store_follower("https://remote.example/users/bob"), + ]) + .expect("persist followers"); + + let response = get_followers(router_with_state(state), "alice").await; + assert_eq!(response.status(), StatusCode::OK); + + let json = response_json(response).await; + assert_eq!(json["totalItems"], 2); + assert_eq!( + json["orderedItems"], + serde_json::json!([ + "https://remote.example/users/bob", + "https://remote.example/users/carol" + ]) + ); +} + +#[tokio::test] +async fn reflects_follower_removal() { + let state = test_app_state(test_config()).expect("build app state"); + { + let mut store = state.store.lock().expect("store lock"); + store + .persist_actions(&[store_follower("https://remote.example/users/bob")]) + .expect("persist follower"); + store + .persist_actions(&[Action::RemoveFollower(RemoveFollower { + follower: iri("https://remote.example/users/bob"), + following: state.local_actor.id.clone(), + })]) + .expect("remove follower"); + } + + let response = get_followers(router_with_state(state), "alice").await; + assert_eq!(response.status(), StatusCode::OK); + + let json = response_json(response).await; + assert_eq!(json["totalItems"], 0); + assert_eq!(json["orderedItems"], serde_json::json!([])); +} + +#[tokio::test] +async fn rejects_unknown_username() { + let response = + get_followers(test_router(test_config()).expect("build router"), "unknown").await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} diff --git a/crates/feder-runtime-server/tests/runtime.rs b/crates/feder-runtime-server/tests/runtime.rs index 0b2690e..6a4bbdc 100644 --- a/crates/feder-runtime-server/tests/runtime.rs +++ b/crates/feder-runtime-server/tests/runtime.rs @@ -19,6 +19,8 @@ mod common; mod actor; #[path = "cases/app.rs"] mod app; +#[path = "cases/followers.rs"] +mod followers; #[path = "cases/inbox.rs"] mod inbox; #[path = "cases/send.rs"] From ac35ae56f3837c167c715f07fb618bc4af307853 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 20 Jul 2026 23:49:09 +0900 Subject: [PATCH 20/72] Negotiate ActivityPub actor and follower responses Require an explicit ActivityPub-compatible Accept header for actor and followers collection requests. Return 406 for HTML, wildcard-only, missing, or unsupported Accept headers, and mark successful responses with Vary: Accept. Add coverage for supported media types, quality preferences, HTML requests, and requests without an ActivityPub Accept header. Assisted-by: Codex:gpt-5.6-sol --- Cargo.lock | 1 + crates/feder-runtime-server/Cargo.toml | 1 + crates/feder-runtime-server/src/actor.rs | 13 +- crates/feder-runtime-server/src/followers.rs | 8 +- crates/feder-runtime-server/src/lib.rs | 1 + .../feder-runtime-server/src/negotiation.rs | 148 ++++++++++++++++++ .../feder-runtime-server/tests/cases/actor.rs | 35 +++++ .../tests/cases/followers.rs | 34 ++++ 8 files changed, 236 insertions(+), 5 deletions(-) create mode 100644 crates/feder-runtime-server/src/negotiation.rs diff --git a/Cargo.lock b/Cargo.lock index 0f6ef02..3776a94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -313,6 +313,7 @@ dependencies = [ "httpdate", "ipnet", "iri-string", + "mime", "percent-encoding", "rand_core 0.6.4", "reqwest", diff --git a/crates/feder-runtime-server/Cargo.toml b/crates/feder-runtime-server/Cargo.toml index 81adfbd..06a4aaf 100644 --- a/crates/feder-runtime-server/Cargo.toml +++ b/crates/feder-runtime-server/Cargo.toml @@ -15,6 +15,7 @@ iri-string.workspace = true axum = "0.8" httpdate = "1" ipnet = "2.11.0" +mime = "0.3" serde.workspace = true thiserror = "2" serde_json.workspace = true diff --git a/crates/feder-runtime-server/src/actor.rs b/crates/feder-runtime-server/src/actor.rs index d03198b..af03cf0 100644 --- a/crates/feder-runtime-server/src/actor.rs +++ b/crates/feder-runtime-server/src/actor.rs @@ -16,7 +16,7 @@ use axum::{ Json, extract::{Path, State}, - http::{StatusCode, header}, + http::{HeaderMap, StatusCode, header}, response::{IntoResponse, Response}, }; use feder_vocab::{Actor, ActorType, CryptographicKey, Endpoints, Iri, Reference}; @@ -26,7 +26,7 @@ use reqwest::{ }; use serde::Deserialize; -use crate::{app::AppState, config::OutboundAddressPolicy, url}; +use crate::{app::AppState, config::OutboundAddressPolicy, negotiation::accepts_activitypub, url}; const MAX_ACTOR_BODY_SIZE: usize = 1_048_576; const ACTIVITYPUB_ACCEPT: &str = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""; @@ -34,14 +34,21 @@ const ACTIVITYPUB_ACCEPT: &str = "application/activity+json, application/ld+json pub async fn actor( State(app_state): State, Path(username): Path, + headers: HeaderMap, ) -> Result { if username != app_state.username { return Err(StatusCode::NOT_FOUND); } + if !accepts_activitypub(&headers) { + return Err(StatusCode::NOT_ACCEPTABLE); + } let local_actor = app_state.local_actor.clone(); Ok(( - [(header::CONTENT_TYPE, "application/activity+json")], + [ + (header::CONTENT_TYPE, "application/activity+json"), + (header::VARY, "Accept"), + ], Json(local_actor), ) .into_response()) diff --git a/crates/feder-runtime-server/src/followers.rs b/crates/feder-runtime-server/src/followers.rs index f951c18..721846d 100644 --- a/crates/feder-runtime-server/src/followers.rs +++ b/crates/feder-runtime-server/src/followers.rs @@ -16,21 +16,25 @@ use axum::{ Json, extract::{Path, State}, - http::{StatusCode, header}, + http::{HeaderMap, StatusCode, header}, response::{IntoResponse, Response}, }; use feder_vocab::OrderedCollection; -use crate::{app::AppState, storage::RuntimeStore}; +use crate::{app::AppState, negotiation::accepts_activitypub, storage::RuntimeStore}; /// Return the local actor's followers as a one-shot ordered collection. pub async fn followers( State(app_state): State, Path(username): Path, + headers: HeaderMap, ) -> Result { if username != app_state.username { return Err(StatusCode::NOT_FOUND); } + if !accepts_activitypub(&headers) { + return Err(StatusCode::NOT_ACCEPTABLE); + } let followers = app_state .store diff --git a/crates/feder-runtime-server/src/lib.rs b/crates/feder-runtime-server/src/lib.rs index 94154a8..b41eb02 100644 --- a/crates/feder-runtime-server/src/lib.rs +++ b/crates/feder-runtime-server/src/lib.rs @@ -19,6 +19,7 @@ pub mod config; pub mod error; pub mod followers; pub mod inbox; +mod negotiation; pub mod send; pub mod storage; mod url; diff --git a/crates/feder-runtime-server/src/negotiation.rs b/crates/feder-runtime-server/src/negotiation.rs new file mode 100644 index 0000000..a185e4c --- /dev/null +++ b/crates/feder-runtime-server/src/negotiation.rs @@ -0,0 +1,148 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use axum::http::{HeaderMap, header::ACCEPT}; +use mime::Mime; + +const ACTIVITYPUB_MEDIA_TYPES: &[&str] = &[ + "application/activity+json", + "application/ld+json", + "application/json", +]; +const HTML_MEDIA_TYPES: &[&str] = &["text/html", "application/xhtml+xml"]; + +struct MediaRange { + media_type: Mime, + quality: u16, + order: usize, +} + +pub(crate) fn accepts_activitypub(headers: &HeaderMap) -> bool { + let mut ranges = headers + .get_all(ACCEPT) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .filter_map(|value| value.trim().parse::().ok()) + .enumerate() + .filter_map(|(order, media_range)| { + let quality = media_range + .get_param("q") + .map_or(Some(1000), |value| parse_quality(value.as_str()))?; + (quality > 0).then_some(MediaRange { + media_type: media_range, + quality, + order, + }) + }) + .collect::>(); + + ranges.sort_by_key(|range| (std::cmp::Reverse(range.quality), range.order)); + + if ranges + .first() + .is_some_and(|range| HTML_MEDIA_TYPES.contains(&range.media_type.essence_str())) + { + return false; + } + + ranges + .iter() + .any(|range| ACTIVITYPUB_MEDIA_TYPES.contains(&range.media_type.essence_str())) +} + +fn parse_quality(value: &str) -> Option { + let (whole, fraction) = value.split_once('.').unwrap_or((value, "")); + if fraction.len() > 3 || !fraction.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + + match whole { + "0" => { + let padding = 3 - fraction.len(); + let fraction = fraction.parse::().unwrap_or(0); + Some(fraction * 10_u16.pow(u32::try_from(padding).ok()?)) + } + "1" if fraction.bytes().all(|byte| byte == b'0') => Some(1000), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + + fn headers(accept: Option<&str>) -> HeaderMap { + let mut headers = HeaderMap::new(); + if let Some(accept) = accept { + headers.insert(ACCEPT, HeaderValue::from_str(accept).expect("valid header")); + } + headers + } + + #[test] + fn accepts_explicit_activitypub_media_types() { + for accept in [ + Some("application/activity+json"), + Some("application/ld+json"), + Some("application/json"), + ] { + assert!(accepts_activitypub(&headers(accept)), "Accept: {accept:?}"); + } + } + + #[test] + fn rejects_implicit_html_or_unsupported_media_types() { + for accept in [ + "", + "*/*", + "application/*", + "text/html", + "application/xhtml+xml", + "image/png", + "application/activity+json;q=0", + ] { + assert!( + !accepts_activitypub(&headers(Some(accept))), + "Accept: {accept}" + ); + } + assert!(!accepts_activitypub(&headers(None))); + } + + #[test] + fn respects_quality_and_order() { + assert_eq!(parse_quality("0.9"), Some(900)); + assert_eq!(parse_quality("0.08"), Some(80)); + assert_eq!(parse_quality("1.000"), Some(1000)); + assert!(!accepts_activitypub(&headers(Some( + "application/activity+json;q=0.5, text/html;q=0.8" + )))); + assert!(accepts_activitypub(&headers(Some( + "application/activity+json;q=0.9, text/html;q=0.8" + )))); + assert!(!accepts_activitypub(&headers(Some( + "text/html, application/activity+json" + )))); + assert!(accepts_activitypub(&headers(Some( + "application/activity+json, text/html" + )))); + assert!(!accepts_activitypub(&headers(Some("text/html, */*")))); + assert!(!accepts_activitypub(&headers(Some( + "application/activity+json;q=0, application/ld+json;q=0, application/json;q=0, */*;q=1" + )))); + } +} diff --git a/crates/feder-runtime-server/tests/cases/actor.rs b/crates/feder-runtime-server/tests/cases/actor.rs index 5c4e0b7..d96093f 100644 --- a/crates/feder-runtime-server/tests/cases/actor.rs +++ b/crates/feder-runtime-server/tests/cases/actor.rs @@ -30,6 +30,7 @@ async fn returns_local_actor() { .oneshot( Request::builder() .uri("/users/alice") + .header(header::ACCEPT, "application/activity+json") .body(Body::empty()) .expect("valid request"), ) @@ -41,6 +42,7 @@ async fn returns_local_actor() { response.headers().get(header::CONTENT_TYPE).unwrap(), "application/activity+json" ); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); let body = to_bytes(response.into_body(), 2048) .await @@ -75,6 +77,39 @@ async fn returns_local_actor() { ); } +#[tokio::test] +async fn rejects_actor_request_when_html_is_preferred() { + let response = test_router(test_config()) + .expect("build router") + .oneshot( + Request::builder() + .uri("/users/alice") + .header(header::ACCEPT, "text/html, application/activity+json;q=0.8") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); +} + +#[tokio::test] +async fn rejects_actor_request_without_activitypub_accept() { + let response = test_router(test_config()) + .expect("build router") + .oneshot( + Request::builder() + .uri("/users/alice") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); +} + #[tokio::test] async fn rejects_unknown_actor() { let app = test_router(test_config()).expect("build router"); diff --git a/crates/feder-runtime-server/tests/cases/followers.rs b/crates/feder-runtime-server/tests/cases/followers.rs index 7abf6bd..cbdd014 100644 --- a/crates/feder-runtime-server/tests/cases/followers.rs +++ b/crates/feder-runtime-server/tests/cases/followers.rs @@ -33,6 +33,7 @@ async fn get_followers(app: axum::Router, username: &str) -> axum::response::Res app.oneshot( Request::builder() .uri(format!("/users/{username}/followers")) + .header(header::ACCEPT, "application/activity+json") .body(Body::empty()) .expect("valid request"), ) @@ -131,3 +132,36 @@ async fn rejects_unknown_username() { assert_eq!(response.status(), StatusCode::NOT_FOUND); } + +#[tokio::test] +async fn rejects_followers_request_when_html_is_preferred() { + let response = test_router(test_config()) + .expect("build router") + .oneshot( + Request::builder() + .uri("/users/alice/followers") + .header(header::ACCEPT, "text/html, application/activity+json;q=0.8") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); +} + +#[tokio::test] +async fn rejects_followers_request_without_activitypub_accept() { + let response = test_router(test_config()) + .expect("build router") + .oneshot( + Request::builder() + .uri("/users/alice/followers") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); +} From 284774f4dd52cc88a43cfbe3af51abf4f57fc038 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 21 Jul 2026 01:00:10 +0900 Subject: [PATCH 21/72] Mark negotiated 406 responses with Vary Accept Add Vary: Accept to actor and followers responses that reject requests without an ActivityPub-compatible Accept header, preventing caches from reusing those responses across representations. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/src/actor.rs | 2 +- crates/feder-runtime-server/src/followers.rs | 2 +- crates/feder-runtime-server/tests/cases/actor.rs | 2 ++ crates/feder-runtime-server/tests/cases/followers.rs | 2 ++ 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/feder-runtime-server/src/actor.rs b/crates/feder-runtime-server/src/actor.rs index af03cf0..c74fa56 100644 --- a/crates/feder-runtime-server/src/actor.rs +++ b/crates/feder-runtime-server/src/actor.rs @@ -40,7 +40,7 @@ pub async fn actor( return Err(StatusCode::NOT_FOUND); } if !accepts_activitypub(&headers) { - return Err(StatusCode::NOT_ACCEPTABLE); + return Ok(([(header::VARY, "Accept")], StatusCode::NOT_ACCEPTABLE).into_response()); } let local_actor = app_state.local_actor.clone(); diff --git a/crates/feder-runtime-server/src/followers.rs b/crates/feder-runtime-server/src/followers.rs index 721846d..b42ac7e 100644 --- a/crates/feder-runtime-server/src/followers.rs +++ b/crates/feder-runtime-server/src/followers.rs @@ -33,7 +33,7 @@ pub async fn followers( return Err(StatusCode::NOT_FOUND); } if !accepts_activitypub(&headers) { - return Err(StatusCode::NOT_ACCEPTABLE); + return Ok(([(header::VARY, "Accept")], StatusCode::NOT_ACCEPTABLE).into_response()); } let followers = app_state diff --git a/crates/feder-runtime-server/tests/cases/actor.rs b/crates/feder-runtime-server/tests/cases/actor.rs index d96093f..e2ed22d 100644 --- a/crates/feder-runtime-server/tests/cases/actor.rs +++ b/crates/feder-runtime-server/tests/cases/actor.rs @@ -92,6 +92,7 @@ async fn rejects_actor_request_when_html_is_preferred() { .expect("response"); assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); } #[tokio::test] @@ -108,6 +109,7 @@ async fn rejects_actor_request_without_activitypub_accept() { .expect("response"); assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); } #[tokio::test] diff --git a/crates/feder-runtime-server/tests/cases/followers.rs b/crates/feder-runtime-server/tests/cases/followers.rs index cbdd014..bfeb6cb 100644 --- a/crates/feder-runtime-server/tests/cases/followers.rs +++ b/crates/feder-runtime-server/tests/cases/followers.rs @@ -148,6 +148,7 @@ async fn rejects_followers_request_when_html_is_preferred() { .expect("response"); assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); } #[tokio::test] @@ -164,4 +165,5 @@ async fn rejects_followers_request_without_activitypub_accept() { .expect("response"); assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); } From 97766307cac9d0719075b736b0d97fe454a55d75 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 21 Jul 2026 11:01:27 +0900 Subject: [PATCH 22/72] Compare supported representations during content negotiation Ignore unsupported Accept media types when choosing between HTML and ActivityPub. Compare the best quality in each supported representation group and use header order to resolve equal preferences. Assisted-by: Codex:gpt-5.6-sol --- .../feder-runtime-server/src/negotiation.rs | 62 ++++++++++++++++--- 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/crates/feder-runtime-server/src/negotiation.rs b/crates/feder-runtime-server/src/negotiation.rs index a185e4c..0ab2177 100644 --- a/crates/feder-runtime-server/src/negotiation.rs +++ b/crates/feder-runtime-server/src/negotiation.rs @@ -29,8 +29,14 @@ struct MediaRange { order: usize, } +#[derive(Clone, Copy)] +struct Preference { + quality: u16, + order: usize, +} + pub(crate) fn accepts_activitypub(headers: &HeaderMap) -> bool { - let mut ranges = headers + let ranges = headers .get_all(ACCEPT) .iter() .filter_map(|value| value.to_str().ok()) @@ -49,18 +55,35 @@ pub(crate) fn accepts_activitypub(headers: &HeaderMap) -> bool { }) .collect::>(); - ranges.sort_by_key(|range| (std::cmp::Reverse(range.quality), range.order)); + let activitypub = preferred(ACTIVITYPUB_MEDIA_TYPES, &ranges); + let html = preferred(HTML_MEDIA_TYPES, &ranges); - if ranges - .first() - .is_some_and(|range| HTML_MEDIA_TYPES.contains(&range.media_type.essence_str())) - { - return false; + match (activitypub, html) { + (Some(activitypub), Some(html)) => prefers(activitypub, html), + (Some(_), None) => true, + _ => false, } +} +fn preferred(media_types: &[&str], ranges: &[MediaRange]) -> Option { ranges .iter() - .any(|range| ACTIVITYPUB_MEDIA_TYPES.contains(&range.media_type.essence_str())) + .filter(|range| media_types.contains(&range.media_type.essence_str())) + .map(|range| Preference { + quality: range.quality, + order: range.order, + }) + .reduce(|current, candidate| { + if prefers(candidate, current) { + candidate + } else { + current + } + }) +} + +fn prefers(left: Preference, right: Preference) -> bool { + left.quality > right.quality || (left.quality == right.quality && left.order < right.order) } fn parse_quality(value: &str) -> Option { @@ -145,4 +168,27 @@ mod tests { "application/activity+json;q=0, application/ld+json;q=0, application/json;q=0, */*;q=1" )))); } + + #[test] + fn ignores_unsupported_types_when_comparing_supported_representations() { + assert!(!accepts_activitypub(&headers(Some( + "image/png, application/activity+json;q=0.5, text/html;q=0.8" + )))); + assert!(accepts_activitypub(&headers(Some( + "image/png, application/activity+json;q=0.8, text/html;q=0.5" + )))); + assert!(accepts_activitypub(&headers(Some( + "image/png, application/activity+json;q=0.5" + )))); + } + + #[test] + fn compares_the_best_type_in_each_supported_representation() { + assert!(!accepts_activitypub(&headers(Some( + "text/html;q=0.2, application/xhtml+xml;q=0.9, application/activity+json;q=0.8" + )))); + assert!(accepts_activitypub(&headers(Some( + "text/html;q=0.8, application/activity+json;q=0.2, application/ld+json;q=0.9" + )))); + } } From ad8d6fea4d32e4ee6415c7d5856ccf13ba1fdb2c Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 21 Jul 2026 12:48:19 +0900 Subject: [PATCH 23/72] Expand Note addressing and representation fields Add to, cc, url, and mediaType fields to the Note vocabulary model. Preserve ActivityStreams scalar-or-array recipient serialization and omit empty or absent values. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-vocab/src/lib.rs | 12 ++++++++++++ .../feder-vocab/tests/activitypub_serialization.rs | 10 +++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/feder-vocab/src/lib.rs b/crates/feder-vocab/src/lib.rs index 7591dfa..2f6c2a1 100644 --- a/crates/feder-vocab/src/lib.rs +++ b/crates/feder-vocab/src/lib.rs @@ -329,10 +329,18 @@ pub struct Note { pub id: Iri, #[serde(rename = "attributedTo", skip_serializing_if = "Option::is_none")] pub attributed_to: Option>, + #[serde(default, skip_serializing_if = "References::is_empty")] + pub to: References, + #[serde(default, skip_serializing_if = "References::is_empty")] + pub cc: References, #[serde(skip_serializing_if = "Option::is_none")] pub content: Option, + #[serde(rename = "mediaType", skip_serializing_if = "Option::is_none")] + pub media_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub published: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, } impl Note { @@ -347,8 +355,12 @@ impl Note { kind: NoteType::default(), id, attributed_to: None, + to: References::new(), + cc: References::new(), content: None, + media_type: None, published: None, + url: None, } } } diff --git a/crates/feder-vocab/tests/activitypub_serialization.rs b/crates/feder-vocab/tests/activitypub_serialization.rs index 1494636..633b9a8 100644 --- a/crates/feder-vocab/tests/activitypub_serialization.rs +++ b/crates/feder-vocab/tests/activitypub_serialization.rs @@ -176,8 +176,12 @@ fn ordered_collection_serializes_actor_iris() { fn local_note_serializes_as_create_activity() { let mut note = Note::new(iri("https://example.com/notes/1")); note.attributed_to = Some(Reference::id(iri("https://example.com/users/alice"))); + note.to = References::one(iri("https://www.w3.org/ns/activitystreams#Public")); + note.cc = References::one(iri("https://example.com/users/alice/followers")); note.content = Some("Hello from Feder.".to_string()); + note.media_type = Some("text/html".to_string()); note.published = Some("2026-06-02T00:00:00Z".to_string()); + note.url = Some(iri("https://example.com/@alice/1")); let create = Create::new( iri("https://example.com/activities/create/1"), @@ -197,8 +201,12 @@ fn local_note_serializes_as_create_activity() { "type": "Note", "id": "https://example.com/notes/1", "attributedTo": "https://example.com/users/alice", + "to": "https://www.w3.org/ns/activitystreams#Public", + "cc": "https://example.com/users/alice/followers", "content": "Hello from Feder.", - "published": "2026-06-02T00:00:00Z" + "mediaType": "text/html", + "published": "2026-06-02T00:00:00Z", + "url": "https://example.com/@alice/1" } }) ); From 1cd7c28db9ad10a724752c40a0f942e7b7ba1ab2 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 21 Jul 2026 21:15:11 +0900 Subject: [PATCH 24/72] Persist ActivityPub objects in the runtime store Store Note objects emitted through StoreObject actions in SQLite using their IRI, object type, and serialized payload. Add typed object loading, idempotent replacement, and persistence across database reopen. Assisted-by: Codex:gpt-5.6-sol --- .../feder-runtime-server/src/storage/mod.rs | 10 +- .../src/storage/sqlite.rs | 177 +++++++++++++++++- 2 files changed, 183 insertions(+), 4 deletions(-) diff --git a/crates/feder-runtime-server/src/storage/mod.rs b/crates/feder-runtime-server/src/storage/mod.rs index 740f057..673ff89 100644 --- a/crates/feder-runtime-server/src/storage/mod.rs +++ b/crates/feder-runtime-server/src/storage/mod.rs @@ -16,7 +16,7 @@ pub mod sqlite; use feder_core::{ - Action, + Action, Object, http_signatures::{ActorKeyPair, KeyError}, }; use feder_vocab::Iri; @@ -48,6 +48,12 @@ pub enum StoreError { #[error("invalid IRI: {0}")] InvalidIri(String), + #[error("unsupported runtime object type")] + UnsupportedObjectType, + + #[error("unsupported stored object type: {0}")] + UnsupportedStoredObjectType(String), + #[error(transparent)] ActorKey(#[from] KeyError), } @@ -59,6 +65,8 @@ pub trait RuntimeStore { fn list_follower_recipients(&self, actor_id: &Iri) -> Result, StoreError>; + fn load_object(&self, object_id: &Iri) -> Result, StoreError>; + fn insert_actor_key_pair( &mut self, actor_id: &Iri, diff --git a/crates/feder-runtime-server/src/storage/sqlite.rs b/crates/feder-runtime-server/src/storage/sqlite.rs index 16e42f1..cf861fb 100644 --- a/crates/feder-runtime-server/src/storage/sqlite.rs +++ b/crates/feder-runtime-server/src/storage/sqlite.rs @@ -15,8 +15,8 @@ use std::path::Path; -use feder_core::{Action, http_signatures::ActorKeyPair}; -use feder_vocab::{Actor, Iri, Reference}; +use feder_core::{Action, Object, http_signatures::ActorKeyPair}; +use feder_vocab::{Actor, Iri, Note, Reference}; use rusqlite::{Connection, OptionalExtension, params}; use crate::storage::{RuntimeStore, StoreError, StoredFollower, StoredRecipient}; @@ -63,6 +63,11 @@ impl SqliteStore { private_key_pem TEXT NOT NULL, public_key_pem TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS objects ( + object_id TEXT PRIMARY KEY NOT NULL, + object_type TEXT NOT NULL, + object_json TEXT NOT NULL + ); "#, )?; @@ -125,6 +130,19 @@ impl RuntimeStore for SqliteStore { params![action.target.actor.as_str(), action.target.inbox.as_str()], )?; } + Action::StoreObject(action) => { + let (object_id, object_type, object_json) = encode_object(&action.object)?; + tx.execute( + r#" + INSERT INTO objects (object_id, object_type, object_json) + VALUES (?1, ?2, ?3) + ON CONFLICT(object_id) DO UPDATE SET + object_type = excluded.object_type, + object_json = excluded.object_json + "#, + params![object_id.as_str(), object_type, object_json], + )?; + } _ => {} } } @@ -193,6 +211,25 @@ impl RuntimeStore for SqliteStore { .collect() } + fn load_object(&self, object_id: &Iri) -> Result, StoreError> { + let stored = self + .conn + .query_row( + r#" + SELECT object_type, object_json + FROM objects + WHERE object_id = ?1 + "#, + [object_id.as_str()], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?; + + stored + .map(|(object_type, object_json)| decode_object(&object_type, &object_json)) + .transpose() + } + fn insert_actor_key_pair( &mut self, actor_id: &Iri, @@ -236,6 +273,22 @@ impl RuntimeStore for SqliteStore { } } +fn encode_object(object: &Object) -> Result<(&Iri, &'static str, String), StoreError> { + match object { + Object::Note(note) => Ok((¬e.id, "Note", serde_json::to_string(note)?)), + _ => Err(StoreError::UnsupportedObjectType), + } +} + +fn decode_object(object_type: &str, object_json: &str) -> Result { + match object_type { + "Note" => Ok(Object::Note(serde_json::from_str::(object_json)?)), + object_type => Err(StoreError::UnsupportedStoredObjectType( + object_type.to_string(), + )), + } +} + fn actor_reference_id(reference: &Reference) -> &Iri { match reference { Reference::Id(id) => id, @@ -272,7 +325,7 @@ fn parse_optional_iri(value: Option) -> Result, StoreError> #[cfg(test)] mod tests { - use feder_core::{Action, RemoveFollower, StoreFollower}; + use feder_core::{Action, Object, RemoveFollower, StoreFollower, StoreObject}; use super::*; @@ -290,6 +343,16 @@ mod tests { }) } + fn store_note_action(content: &str) -> Action { + let mut note = Note::new(iri("https://example.com/users/alice/posts/1")); + note.attributed_to = Some(Reference::id(iri("https://example.com/users/alice"))); + note.content = Some(content.to_string()); + + Action::StoreObject(StoreObject { + object: Object::Note(note), + }) + } + fn actor(id: &str) -> Actor { Actor::person( iri(id), @@ -371,6 +434,114 @@ mod tests { ); } + #[test] + fn open_in_memory_initializes_objects_table() { + let store = SqliteStore::open_in_memory().expect("open in-memory store"); + + let columns: Vec = { + let mut stmt = store + .conn + .prepare("PRAGMA table_info(objects)") + .expect("prepare objects table info query"); + stmt.query_map([], |row| row.get("name")) + .expect("query objects table info") + .collect::>() + .expect("collect objects table columns") + }; + + assert_eq!( + columns, + vec![ + "object_id".to_string(), + "object_type".to_string(), + "object_json".to_string(), + ] + ); + } + + #[test] + fn persist_actions_stores_and_loads_note() { + let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); + let action = store_note_action("Hello from Feder."); + + store + .persist_actions(core::slice::from_ref(&action)) + .expect("persist note action"); + let object = store + .load_object(&iri("https://example.com/users/alice/posts/1")) + .expect("load note") + .expect("stored note"); + + let Action::StoreObject(expected) = action else { + panic!("expected store object action"); + }; + assert_eq!(object, expected.object); + } + + #[test] + fn persist_actions_replaces_object_with_same_id() { + let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); + + store + .persist_actions(&[store_note_action("Original")]) + .expect("persist original note"); + store + .persist_actions(&[store_note_action("Updated")]) + .expect("replace note"); + + let object = store + .load_object(&iri("https://example.com/users/alice/posts/1")) + .expect("load note") + .expect("stored note"); + let Object::Note(note) = object else { + panic!("expected stored note"); + }; + assert_eq!(note.content.as_deref(), Some("Updated")); + } + + #[test] + fn load_object_returns_none_for_unknown_id() { + let store = SqliteStore::open_in_memory().expect("open in-memory store"); + + let object = store + .load_object(&iri("https://example.com/users/alice/posts/unknown")) + .expect("load unknown object"); + + assert!(object.is_none()); + } + + #[test] + fn stored_note_persists_across_store_reopen() { + let path = std::env::temp_dir().join(format!( + "feder-object-test-{}-{}.sqlite3", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time after unix epoch") + .as_nanos() + )); + + { + let mut store = SqliteStore::open(&path).expect("open SQLite store"); + store + .persist_actions(&[store_note_action("Persistent note")]) + .expect("persist note action"); + } + + let store = SqliteStore::open(&path).expect("reopen SQLite store"); + let object = store + .load_object(&iri("https://example.com/users/alice/posts/1")) + .expect("load persisted note") + .expect("persisted note"); + let Object::Note(note) = object else { + panic!("expected stored note"); + }; + assert_eq!(note.content.as_deref(), Some("Persistent note")); + + drop(store); + let _ = std::fs::remove_file(path); + } + #[test] fn actor_key_pair_roundtrips_for_actor() { let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); From db9b196785e4592e775e332a439e572399c175de Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 21 Jul 2026 21:37:37 +0900 Subject: [PATCH 25/72] Serve persisted Notes through ActivityPub object routes Add the local post object route with ActivityPub content negotiation, SQLite-backed lookup, and coverage for persistence and error responses. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/src/app.rs | 2 + crates/feder-runtime-server/src/lib.rs | 1 + crates/feder-runtime-server/src/object.rs | 76 ++++++++ .../tests/cases/object.rs | 169 ++++++++++++++++++ crates/feder-runtime-server/tests/runtime.rs | 2 + 5 files changed, 250 insertions(+) create mode 100644 crates/feder-runtime-server/src/object.rs create mode 100644 crates/feder-runtime-server/tests/cases/object.rs diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs index 7ac9cf4..db6b22c 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -19,6 +19,7 @@ use crate::Error; use crate::actor::ActorResolver; use crate::config::{InboxAuthPolicy, RuntimeConfig, StorageConfig}; use crate::followers::followers; +use crate::object::get_object; use crate::send::ActivitySender; use crate::storage::{RuntimeStore, SqliteStore}; use crate::webfinger::webfinger; @@ -113,6 +114,7 @@ pub fn router_with_state(state: AppState) -> Router { .route("/.well-known/webfinger", get(webfinger)) .route("/users/{username}", get(actor)) .route("/users/{username}/followers", get(followers)) + .route("/users/{username}/posts/{id}", get(get_object)) .route("/users/{username}/inbox", post(inbox)) .layer(DefaultBodyLimit::max(1_048_576)) .with_state(state) diff --git a/crates/feder-runtime-server/src/lib.rs b/crates/feder-runtime-server/src/lib.rs index b41eb02..777c0b0 100644 --- a/crates/feder-runtime-server/src/lib.rs +++ b/crates/feder-runtime-server/src/lib.rs @@ -20,6 +20,7 @@ pub mod error; pub mod followers; pub mod inbox; mod negotiation; +pub mod object; pub mod send; pub mod storage; mod url; diff --git a/crates/feder-runtime-server/src/object.rs b/crates/feder-runtime-server/src/object.rs new file mode 100644 index 0000000..d808ff6 --- /dev/null +++ b/crates/feder-runtime-server/src/object.rs @@ -0,0 +1,76 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use axum::{ + Json, + extract::{Path, State}, + http::{HeaderMap, StatusCode, header}, + response::{IntoResponse, Response}, +}; +use feder_core::Object; +use feder_vocab::Iri; + +use crate::{app::AppState, negotiation::accepts_activitypub, storage::RuntimeStore}; + +/// Return a persisted local ActivityPub object. +pub async fn get_object( + State(app_state): State, + Path((username, post_id)): Path<(String, String)>, + headers: HeaderMap, +) -> Result { + if username != app_state.username { + return Err(StatusCode::NOT_FOUND); + } + + let object_id = note_id(&app_state.local_actor.id, &post_id)?; + let object = app_state + .store + .lock() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .load_object(&object_id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + let Object::Note(note) = object else { + return Err(StatusCode::NOT_FOUND); + }; + + if !accepts_activitypub(&headers) { + return Ok(([(header::VARY, "Accept")], StatusCode::NOT_ACCEPTABLE).into_response()); + } + + Ok(( + [ + (header::CONTENT_TYPE, "application/activity+json"), + (header::VARY, "Accept"), + ], + Json(note), + ) + .into_response()) +} + +fn note_id(actor_id: &Iri, post_id: &str) -> Result { + let mut url = + url::Url::parse(actor_id.as_str()).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + url.set_query(None); + url.set_fragment(None); + url.path_segments_mut() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .pop_if_empty() + .push("posts") + .push(post_id); + url.as_str() + .parse() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} diff --git a/crates/feder-runtime-server/tests/cases/object.rs b/crates/feder-runtime-server/tests/cases/object.rs new file mode 100644 index 0000000..6d8512a --- /dev/null +++ b/crates/feder-runtime-server/tests/cases/object.rs @@ -0,0 +1,169 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use axum::{ + Router, + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, +}; +use feder_core::{Action, Object, StoreObject}; +use feder_runtime_server::{app::router_with_state, config::StorageConfig, storage::RuntimeStore}; +use feder_vocab::{Iri, Note, Reference, References}; +use serde_json::Value; +use tower::ServiceExt; + +use crate::common::{temporary_database_path, test_app_state, test_config, test_router}; + +fn iri(value: &str) -> Iri { + value.parse().expect("valid test IRI") +} + +fn stored_note() -> Note { + let mut note = Note::new(iri("http://127.0.0.1:3000/users/alice/posts/1")); + note.attributed_to = Some(Reference::id(iri("http://127.0.0.1:3000/users/alice"))); + note.to = References::one(iri("https://www.w3.org/ns/activitystreams#Public")); + note.cc = References::one(iri("http://127.0.0.1:3000/users/alice/followers")); + note.content = Some("Hello from Feder.".to_string()); + note.media_type = Some("text/html".to_string()); + note.published = Some("2026-07-21T00:00:00Z".to_string()); + note.url = Some(note.id.clone()); + note +} + +fn router_with_note() -> Router { + let state = test_app_state(test_config()).expect("build app state"); + state + .store + .lock() + .expect("store lock") + .persist_actions(&[Action::StoreObject(StoreObject { + object: Object::Note(stored_note()), + })]) + .expect("persist note"); + router_with_state(state) +} + +async fn get_object(app: Router, uri: &str, accept: Option<&str>) -> axum::response::Response { + let mut request = Request::builder().uri(uri); + if let Some(accept) = accept { + request = request.header(header::ACCEPT, accept); + } + + app.oneshot(request.body(Body::empty()).expect("valid request")) + .await + .expect("response") +} + +#[tokio::test] +async fn returns_stored_note_with_activitypub_headers() { + let response = get_object( + router_with_note(), + "/users/alice/posts/1", + Some("application/activity+json"), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::CONTENT_TYPE).unwrap(), + "application/activity+json" + ); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); + + let body = to_bytes(response.into_body(), 4096) + .await + .expect("read response body"); + let json: Value = serde_json::from_slice(&body).expect("valid JSON"); + assert_eq!(json["type"], "Note"); + assert_eq!(json["id"], "http://127.0.0.1:3000/users/alice/posts/1"); + assert_eq!(json["content"], "Hello from Feder."); + assert_eq!(json["mediaType"], "text/html"); +} + +#[tokio::test] +async fn rejects_note_request_when_html_is_preferred() { + let response = get_object( + router_with_note(), + "/users/alice/posts/1", + Some("text/html, application/activity+json;q=0.8"), + ) + .await; + + assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); +} + +#[tokio::test] +async fn rejects_note_request_without_activitypub_accept() { + let response = get_object(router_with_note(), "/users/alice/posts/1", None).await; + + assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); +} + +#[tokio::test] +async fn returns_not_found_for_unknown_note() { + let response = get_object( + router_with_note(), + "/users/alice/posts/unknown", + Some("text/html"), + ) + .await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn returns_note_after_store_reopen() { + let path = temporary_database_path("feder-object-route-test"); + { + let mut config = test_config(); + config.storage = StorageConfig::Sqlite { path: path.clone() }; + let state = test_app_state(config).expect("build app state"); + state + .store + .lock() + .expect("store lock") + .persist_actions(&[Action::StoreObject(StoreObject { + object: Object::Note(stored_note()), + })]) + .expect("persist note"); + } + + let mut config = test_config(); + config.storage = StorageConfig::Sqlite { path: path.clone() }; + let response = get_object( + test_router(config).expect("reopen router"), + "/users/alice/posts/1", + Some("application/activity+json"), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + + let _ = std::fs::remove_file(path); +} + +#[tokio::test] +async fn returns_not_found_for_unknown_username() { + let response = get_object( + router_with_note(), + "/users/bob/posts/1", + Some("application/activity+json"), + ) + .await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} diff --git a/crates/feder-runtime-server/tests/runtime.rs b/crates/feder-runtime-server/tests/runtime.rs index 6a4bbdc..62f4457 100644 --- a/crates/feder-runtime-server/tests/runtime.rs +++ b/crates/feder-runtime-server/tests/runtime.rs @@ -23,6 +23,8 @@ mod app; mod followers; #[path = "cases/inbox.rs"] mod inbox; +#[path = "cases/object.rs"] +mod object; #[path = "cases/send.rs"] mod send; #[path = "cases/webfinger.rs"] From 998e6f95d8eb7d9ad95c24b0974a6a6f1dc9c7f3 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 21 Jul 2026 22:29:45 +0900 Subject: [PATCH 26/72] Coordinate local Note persistence and delivery in the runtime Add a runtime operation for local Note creation that applies storage actions before synchronously delivering Create activities. Reuse the same action pipeline for inbox processing and cover successful and failed delivery. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/src/error.rs | 8 +- crates/feder-runtime-server/src/inbox.rs | 35 ++---- crates/feder-runtime-server/src/lib.rs | 1 + crates/feder-runtime-server/src/operation.rs | 44 +++++++ .../tests/cases/operation.rs | 119 ++++++++++++++++++ crates/feder-runtime-server/tests/runtime.rs | 2 + 6 files changed, 181 insertions(+), 28 deletions(-) create mode 100644 crates/feder-runtime-server/src/operation.rs create mode 100644 crates/feder-runtime-server/tests/cases/operation.rs diff --git a/crates/feder-runtime-server/src/error.rs b/crates/feder-runtime-server/src/error.rs index d405636..b0f8ec3 100644 --- a/crates/feder-runtime-server/src/error.rs +++ b/crates/feder-runtime-server/src/error.rs @@ -15,6 +15,12 @@ #[derive(Debug, thiserror::Error)] pub enum Error { + #[error("runtime core state is unavailable")] + CoreStateUnavailable, + + #[error("runtime storage state is unavailable")] + StorageStateUnavailable, + #[error("failed to bind server socket")] Bind(#[source] std::io::Error), @@ -27,7 +33,7 @@ pub enum Error { #[error("actor key generation failed")] ActorKeyGeneration(#[from] feder_core::http_signatures::KeyError), - #[error("activity sender setup failed")] + #[error("activity sending failed")] ActivitySender(#[from] crate::send::SendError), #[error("actor resolver setup failed")] diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index b041b8b..252c446 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -32,10 +32,9 @@ use feder_core::{ use feder_vocab::{Actor, Follow, Iri, Reference, Undo}; use serde_json::{Value, from_slice, from_value}; -use crate::app::AppState; use crate::config::InboxAuthPolicy; use crate::send::SendError; -use crate::storage::RuntimeStore; +use crate::{Error, app::AppState}; const MAX_SIGNATURE_AGE: Duration = Duration::from_secs(65 * 60); const MAX_CLOCK_SKEW: Duration = Duration::from_secs(60 * 60); @@ -384,34 +383,16 @@ pub async fn inbox( _ => return Ok(StatusCode::ACCEPTED.into_response()), }; - let result = { - let mut core = app_state - .core - .lock() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - core.handle(input) - }; - - app_state - .store - .lock() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .persist_actions(&result.actions) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - app_state - .activity_sender - .send_actions(&result.actions) + .handle_input(input) .await .map_err(|error| match error { - SendError::PrivateInboxAddress { .. } - | SendError::Request(_) - | SendError::UnsuccessfulStatus { .. } => StatusCode::BAD_GATEWAY, - SendError::BuildClient(_) - | SendError::InvalidInbox(_) - | SendError::Serialize(_) - | SendError::Sign(_) - | SendError::UnsupportedActivity => StatusCode::INTERNAL_SERVER_ERROR, + Error::ActivitySender( + SendError::PrivateInboxAddress { .. } + | SendError::Request(_) + | SendError::UnsuccessfulStatus { .. }, + ) => StatusCode::BAD_GATEWAY, + _ => StatusCode::INTERNAL_SERVER_ERROR, })?; Ok(StatusCode::ACCEPTED.into_response()) diff --git a/crates/feder-runtime-server/src/lib.rs b/crates/feder-runtime-server/src/lib.rs index 777c0b0..9fa5339 100644 --- a/crates/feder-runtime-server/src/lib.rs +++ b/crates/feder-runtime-server/src/lib.rs @@ -21,6 +21,7 @@ pub mod followers; pub mod inbox; mod negotiation; pub mod object; +mod operation; pub mod send; pub mod storage; mod url; diff --git a/crates/feder-runtime-server/src/operation.rs b/crates/feder-runtime-server/src/operation.rs new file mode 100644 index 0000000..19a885a --- /dev/null +++ b/crates/feder-runtime-server/src/operation.rs @@ -0,0 +1,44 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use feder_core::{HandleResult, Input, UserCreateNote}; + +use crate::{Error, app::AppState, storage::RuntimeStore}; + +impl AppState { + /// Create, persist, and deliver a Note initiated by the local application. + /// + /// Persistence occurs before delivery. If delivery fails, this returns an + /// error while the created Note remains available from the runtime store. + pub async fn create_note(&self, input: UserCreateNote) -> Result { + self.handle_input(Input::UserCreateNote(input)).await + } + + pub(crate) async fn handle_input(&self, input: Input) -> Result { + let result = { + let mut core = self.core.lock().map_err(|_| Error::CoreStateUnavailable)?; + core.handle(input) + }; + + self.store + .lock() + .map_err(|_| Error::StorageStateUnavailable)? + .persist_actions(&result.actions)?; + + self.activity_sender.send_actions(&result.actions).await?; + + Ok(result) + } +} diff --git a/crates/feder-runtime-server/tests/cases/operation.rs b/crates/feder-runtime-server/tests/cases/operation.rs new file mode 100644 index 0000000..58530fd --- /dev/null +++ b/crates/feder-runtime-server/tests/cases/operation.rs @@ -0,0 +1,119 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use axum::http::StatusCode; +use feder_core::{Action, Input, Object, UserCreateNote}; +use feder_runtime_server::{Error, send::SendError, storage::RuntimeStore}; +use feder_vocab::{Actor, Follow, Iri, Reference}; + +use crate::common::{spawn_inbox_server, test_app_state, test_config}; + +fn iri(value: &str) -> Iri { + value.parse().expect("valid test IRI") +} + +fn create_note_input() -> UserCreateNote { + UserCreateNote { + note_id: iri("http://127.0.0.1:3000/users/alice/posts/1"), + create_id: iri("http://127.0.0.1:3000/users/alice/activities/create/1"), + actor: Reference::id(iri("http://127.0.0.1:3000/users/alice")), + content: "Hello from Feder.".to_string(), + published: Some("2026-07-21T00:00:00Z".to_string()), + } +} + +fn add_delivery_target(state: &feder_runtime_server::AppState, inbox: &str) { + let remote_actor_id = iri("https://remote.example/users/bob"); + let remote_actor = Actor::person( + remote_actor_id.clone(), + iri(inbox), + iri("https://remote.example/users/bob/outbox"), + ); + let follow = Follow::new( + iri("https://remote.example/activities/follow/1"), + Reference::object(remote_actor), + Reference::id(state.local_actor.id.clone()), + ); + + let _ = state + .core + .lock() + .expect("core lock") + .handle(Input::received_follow( + follow, + iri("http://127.0.0.1:3000/users/alice/activities/accept/1"), + )); +} + +#[tokio::test] +async fn create_note_persists_and_delivers_the_core_actions() { + let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; + let state = test_app_state(test_config()).expect("build app state"); + add_delivery_target(&state, &inbox); + + let result = state + .create_note(create_note_input()) + .await + .expect("create note"); + + assert_eq!(result.actions.len(), 2); + assert!(matches!(result.actions[0], Action::StoreObject(_))); + assert!(matches!(result.actions[1], Action::SendActivity(_))); + + let stored = state + .store + .lock() + .expect("store lock") + .load_object(&iri("http://127.0.0.1:3000/users/alice/posts/1")) + .expect("load note") + .expect("stored note"); + let Object::Note(note) = stored else { + panic!("expected stored Note"); + }; + assert_eq!(note.content.as_deref(), Some("Hello from Feder.")); + + let request = requests.recv().await.expect("receive Create request"); + let activity: serde_json::Value = + serde_json::from_slice(&request.body).expect("valid Create activity"); + assert_eq!(activity["type"], "Create"); + assert_eq!(activity["object"]["id"], note.id.as_str()); + inbox_server.abort(); +} + +#[tokio::test] +async fn create_note_keeps_the_persisted_object_when_delivery_fails() { + let (inbox, mut requests, inbox_server) = + spawn_inbox_server(StatusCode::INTERNAL_SERVER_ERROR).await; + let state = test_app_state(test_config()).expect("build app state"); + add_delivery_target(&state, &inbox); + + let result = state.create_note(create_note_input()).await; + + assert!(matches!( + result, + Err(Error::ActivitySender(SendError::UnsuccessfulStatus { .. })) + )); + requests.recv().await.expect("receive Create request"); + assert!( + state + .store + .lock() + .expect("store lock") + .load_object(&iri("http://127.0.0.1:3000/users/alice/posts/1")) + .expect("load note") + .is_some() + ); + inbox_server.abort(); +} diff --git a/crates/feder-runtime-server/tests/runtime.rs b/crates/feder-runtime-server/tests/runtime.rs index 62f4457..7e1d253 100644 --- a/crates/feder-runtime-server/tests/runtime.rs +++ b/crates/feder-runtime-server/tests/runtime.rs @@ -25,6 +25,8 @@ mod followers; mod inbox; #[path = "cases/object.rs"] mod object; +#[path = "cases/operation.rs"] +mod operation; #[path = "cases/send.rs"] mod send; #[path = "cases/webfinger.rs"] From e5a3ed0f90e2249154536dde62d93eab464006d9 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 22 Jul 2026 00:16:43 +0900 Subject: [PATCH 27/72] Resolve Note recipients from persisted followers Remove the in-memory delivery target cache and resolve current follower inboxes from SQLite when delivering Create activities. Keep delivery synchronous, deduplicate shared inboxes, and refresh inbox data through repeated Follow persistence. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-core/src/lib.rs | 273 ++++-------------- crates/feder-runtime-server/src/operation.rs | 48 ++- .../src/storage/sqlite.rs | 28 +- .../feder-runtime-server/tests/cases/inbox.rs | 9 +- .../tests/cases/operation.rs | 98 +++++-- 5 files changed, 181 insertions(+), 275 deletions(-) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 5957c09..bfa9978 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -85,7 +85,6 @@ impl FederConfig { pub struct FederState { local_actor: vocab::Actor, followers: Vec, - delivery_targets: Vec, objects: Vec, activities: Vec, } @@ -96,7 +95,6 @@ impl FederState { Self { local_actor: config.local_actor, followers: Vec::new(), - delivery_targets: Vec::new(), objects: Vec::new(), activities: Vec::new(), } @@ -112,15 +110,6 @@ impl FederState { &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 @@ -153,46 +142,17 @@ impl FederState { 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; - } - - if should_store_target { - actions.push(Action::StoreDeliveryTarget(StoreDeliveryTarget { target })); - } + actions.push(Action::StoreFollower(StoreFollower { + follower: follow.actor.clone(), + following: follow.object.clone(), + })); - inbox = Some(actor.inbox.clone()); - } + let inbox = match &follow.actor { + vocab::Reference::Object(actor) => Some(actor.inbox.clone()), + vocab::Reference::Id(_) => None, + }; if let Some(inbox) = inbox { let accept = vocab::Accept::new( @@ -234,14 +194,6 @@ impl FederState { following: following.clone(), }; self.followers.retain(|existing| existing != &relation); - if !self - .followers - .iter() - .any(|existing| existing.follower == *follower) - { - self.delivery_targets - .retain(|target| target.actor != *follower); - } Vec::from([Action::RemoveFollower(RemoveFollower { follower: follower.clone(), @@ -275,16 +227,13 @@ impl FederState { 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 + Vec::from([ + Action::StoreObject(StoreObject { object }), + Action::SendActivityToFollowers(SendActivityToFollowers { + activity: Activity::CreateNote(create), + actor: self.local_actor.id.clone(), + }), + ]) } } @@ -362,25 +311,15 @@ pub struct Follower { pub following: vocab::Iri, } -/// 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, -} - /// Something the runtime should perform after core handling. #[derive(Clone, Debug, Eq, PartialEq)] #[non_exhaustive] pub enum Action { StoreFollower(StoreFollower), RemoveFollower(RemoveFollower), - StoreDeliveryTarget(StoreDeliveryTarget), StoreObject(StoreObject), SendActivity(SendActivity), + SendActivityToFollowers(SendActivityToFollowers), } #[derive(Clone, Debug, Eq, PartialEq)] @@ -397,11 +336,6 @@ pub struct RemoveFollower { pub following: vocab::Iri, } -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct StoreDeliveryTarget { - pub target: DeliveryTarget, -} - #[derive(Clone, Debug, Eq, PartialEq)] pub struct StoreObject { pub object: Object, @@ -413,6 +347,14 @@ pub struct SendActivity { pub inbox: vocab::Iri, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SendActivityToFollowers { + /// Activity to deliver to the actor's current followers. + pub activity: Activity, + /// Local actor whose followers should receive the activity. + pub actor: vocab::Iri, +} + #[derive(Clone, Debug, Eq, PartialEq)] #[non_exhaustive] pub enum Activity { @@ -489,7 +431,6 @@ mod tests { 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()); } @@ -508,7 +449,7 @@ mod tests { "https://example.com/activities/accept/1", )); - assert_eq!(result.actions.len(), 3); + assert_eq!(result.actions.len(), 2); assert_eq!( core.state().followers(), &[Follower { @@ -516,13 +457,6 @@ mod tests { 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 { @@ -530,17 +464,7 @@ mod tests { 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 { + let Action::SendActivity(send) = &result.actions[1] else { panic!("expected SendActivity action"); }; assert_eq!(send.inbox, iri("https://remote.example/users/bob/inbox")); @@ -563,7 +487,7 @@ mod tests { } #[test] - fn received_follow_updates_existing_delivery_target_by_actor() { + fn received_follow_refreshes_the_stored_follower_actor() { let mut core = core(); let first_follow = vocab::Follow::new( iri("https://remote.example/activities/follow/1"), @@ -588,15 +512,17 @@ mod tests { "https://example.com/activities/accept/2", )); - assert_eq!(first_result.actions.len(), 3); + assert_eq!(first_result.actions.len(), 2); 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"), - }, + Action::StoreFollower(StoreFollower { + follower: vocab::Reference::object({ + let mut actor = actor("https://remote.example/users/bob"); + actor.inbox = iri("https://remote.example/inboxes/bob"); + actor + }), + following: vocab::Reference::id(iri("https://example.com/users/alice")), }) ); @@ -617,17 +543,10 @@ mod tests { 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() { + fn received_follow_with_actor_id_records_follower_without_accept_delivery() { let mut core = core(); let follow = vocab::Follow::new( iri("https://remote.example/activities/follow/1"), @@ -654,7 +573,6 @@ mod tests { following: iri("https://example.com/users/alice"), }] ); - assert!(core.state().delivery_targets().is_empty()); } #[test] @@ -673,11 +591,10 @@ mod tests { assert!(result.is_empty()); assert!(core.state().followers().is_empty()); - assert!(core.state().delivery_targets().is_empty()); } #[test] - fn received_undo_follow_removes_follower_and_delivery_target() { + fn received_undo_follow_removes_follower() { let mut core = core(); let follow = vocab::Follow::new( iri("https://remote.example/activities/follow/1"), @@ -702,7 +619,6 @@ mod tests { })]) ); assert!(core.state().followers().is_empty()); - assert!(core.state().delivery_targets().is_empty()); } #[test] @@ -725,7 +641,6 @@ mod tests { assert!(result.is_empty()); assert_eq!(core.state().followers().len(), 1); - assert_eq!(core.state().delivery_targets().len(), 1); } #[test] @@ -752,7 +667,7 @@ mod tests { } #[test] - fn user_create_note_records_created_object_and_emits_store_action() { + fn user_create_note_records_object_and_emits_followers_delivery() { let input = UserCreateNote { note_id: iri("https://example.com/notes/1"), create_id: iri("https://example.com/activities/create/1"), @@ -764,7 +679,7 @@ mod tests { let mut core = core(); let result = core.handle(Input::UserCreateNote(input)); - assert_eq!(result.actions.len(), 1); + assert_eq!(result.actions.len(), 2); assert_eq!(core.state().objects().len(), 1); assert_eq!(core.state().activities().len(), 1); @@ -794,105 +709,20 @@ mod tests { 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"); + let Action::SendActivityToFollowers(send) = &result.actions[1] else { + panic!("expected followers delivery action"); }; - assert_eq!(send.inbox, iri("https://remote.example/users/bob/inbox")); - + assert_eq!(send.actor, iri("https://example.com/users/alice")); 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(); @@ -907,13 +737,9 @@ mod tests { "https://example.com/activities/accept/1", )); - assert_eq!(follow_result.actions.len(), 3); + assert_eq!(follow_result.actions.len(), 2); 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 { + let Action::SendActivity(accept_delivery) = &follow_result.actions[1] else { panic!("expected Accept delivery action"); }; assert_eq!( @@ -932,17 +758,16 @@ mod tests { 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"); + let Action::SendActivityToFollowers(create_delivery) = &create_result.actions[1] else { + panic!("expected followers delivery action"); }; assert_eq!( - create_delivery.inbox, - iri("https://remote.example/users/bob/inbox") + create_delivery.actor, + iri("https://example.com/users/alice") ); 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); } @@ -963,7 +788,7 @@ mod tests { let mut core = core(); let result = core.handle(Input::UserCreateNote(input)); - assert_eq!(result.actions.len(), 1); + assert_eq!(result.actions.len(), 2); let Object::Note(note) = &core.state().objects()[0]; assert_eq!( diff --git a/crates/feder-runtime-server/src/operation.rs b/crates/feder-runtime-server/src/operation.rs index 19a885a..ee748ed 100644 --- a/crates/feder-runtime-server/src/operation.rs +++ b/crates/feder-runtime-server/src/operation.rs @@ -13,7 +13,11 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use feder_core::{HandleResult, Input, UserCreateNote}; +use std::collections::HashSet; + +use feder_core::{ + Action, HandleResult, Input, SendActivity, SendActivityToFollowers, UserCreateNote, +}; use crate::{Error, app::AppState, storage::RuntimeStore}; @@ -32,13 +36,45 @@ impl AppState { core.handle(input) }; - self.store - .lock() - .map_err(|_| Error::StorageStateUnavailable)? - .persist_actions(&result.actions)?; + let delivery_actions = { + let mut store = self + .store + .lock() + .map_err(|_| Error::StorageStateUnavailable)?; + store.persist_actions(&result.actions)?; + resolve_delivery_actions(&*store, &result.actions)? + }; - self.activity_sender.send_actions(&result.actions).await?; + self.activity_sender.send_actions(&delivery_actions).await?; Ok(result) } } + +fn resolve_delivery_actions( + store: &impl RuntimeStore, + actions: &[Action], +) -> Result, Error> { + let mut resolved = Vec::new(); + + for action in actions { + match action { + Action::SendActivity(_) => resolved.push(action.clone()), + Action::SendActivityToFollowers(SendActivityToFollowers { activity, actor }) => { + let mut seen_inboxes = HashSet::new(); + for recipient in store.list_follower_recipients(actor)? { + let inbox = recipient.shared_inbox.unwrap_or(recipient.inbox); + if seen_inboxes.insert(inbox.clone()) { + resolved.push(Action::SendActivity(SendActivity { + activity: activity.clone(), + inbox, + })); + } + } + } + _ => {} + } + } + + Ok(resolved) +} diff --git a/crates/feder-runtime-server/src/storage/sqlite.rs b/crates/feder-runtime-server/src/storage/sqlite.rs index cf861fb..c00c369 100644 --- a/crates/feder-runtime-server/src/storage/sqlite.rs +++ b/crates/feder-runtime-server/src/storage/sqlite.rs @@ -120,16 +120,6 @@ impl RuntimeStore for SqliteStore { params![action.follower.as_str(), action.following.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()], - )?; - } Action::StoreObject(action) => { let (object_id, object_type, object_json) = encode_object(&action.object)?; tx.execute( @@ -754,22 +744,20 @@ mod tests { } #[test] - fn persist_actions_updates_follower_inbox_from_delivery_target() { + fn persist_actions_updates_follower_inbox_from_repeated_follow() { let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); store .persist_actions(&[store_follower_action()]) .expect("persist ID-only follower action"); + let mut follower = actor("https://remote.example/users/bob"); + follower.inbox = iri("https://remote.example/users/bob/updated-inbox"); 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"); + .persist_actions(&[Action::StoreFollower(StoreFollower { + follower: Reference::object(follower), + following: Reference::id(iri("https://example.com/users/alice")), + })]) + .expect("persist repeated follower action"); let recipients = store .list_follower_recipients(&iri("https://example.com/users/alice")) diff --git a/crates/feder-runtime-server/tests/cases/inbox.rs b/crates/feder-runtime-server/tests/cases/inbox.rs index 0208e78..3952ef6 100644 --- a/crates/feder-runtime-server/tests/cases/inbox.rs +++ b/crates/feder-runtime-server/tests/cases/inbox.rs @@ -265,9 +265,14 @@ async fn valid_follow_reaches_core() { 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!(core.state().delivery_targets()[0].inbox.as_str(), inbox); } + let followers = state + .store + .lock() + .expect("store lock") + .list_followers(&state.local_actor.id) + .expect("list followers"); + assert_eq!(followers[0].inbox.as_ref().unwrap().as_str(), inbox); let request = requests.recv().await.expect("receive Accept request"); assert_eq!( diff --git a/crates/feder-runtime-server/tests/cases/operation.rs b/crates/feder-runtime-server/tests/cases/operation.rs index 58530fd..6318fc4 100644 --- a/crates/feder-runtime-server/tests/cases/operation.rs +++ b/crates/feder-runtime-server/tests/cases/operation.rs @@ -14,11 +14,11 @@ // along with this program. If not, see . use axum::http::StatusCode; -use feder_core::{Action, Input, Object, UserCreateNote}; -use feder_runtime_server::{Error, send::SendError, storage::RuntimeStore}; -use feder_vocab::{Actor, Follow, Iri, Reference}; +use feder_core::{Action, Object, StoreFollower, UserCreateNote}; +use feder_runtime_server::{Error, config::StorageConfig, send::SendError, storage::RuntimeStore}; +use feder_vocab::{Actor, Iri, Reference}; -use crate::common::{spawn_inbox_server, test_app_state, test_config}; +use crate::common::{spawn_inbox_server, temporary_database_path, test_app_state, test_config}; fn iri(value: &str) -> Iri { value.parse().expect("valid test IRI") @@ -34,34 +34,29 @@ fn create_note_input() -> UserCreateNote { } } -fn add_delivery_target(state: &feder_runtime_server::AppState, inbox: &str) { - let remote_actor_id = iri("https://remote.example/users/bob"); +fn store_follower(state: &feder_runtime_server::AppState, remote_actor_id: &str, inbox: &str) { + let remote_actor_id = iri(remote_actor_id); let remote_actor = Actor::person( remote_actor_id.clone(), iri(inbox), - iri("https://remote.example/users/bob/outbox"), + iri(&format!("{remote_actor_id}/outbox")), ); - let follow = Follow::new( - iri("https://remote.example/activities/follow/1"), - Reference::object(remote_actor), - Reference::id(state.local_actor.id.clone()), - ); - - let _ = state - .core + state + .store .lock() - .expect("core lock") - .handle(Input::received_follow( - follow, - iri("http://127.0.0.1:3000/users/alice/activities/accept/1"), - )); + .expect("store lock") + .persist_actions(&[Action::StoreFollower(StoreFollower { + follower: Reference::object(remote_actor), + following: Reference::id(state.local_actor.id.clone()), + })]) + .expect("persist follower"); } #[tokio::test] async fn create_note_persists_and_delivers_the_core_actions() { let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; let state = test_app_state(test_config()).expect("build app state"); - add_delivery_target(&state, &inbox); + store_follower(&state, "https://remote.example/users/bob", &inbox); let result = state .create_note(create_note_input()) @@ -70,7 +65,10 @@ async fn create_note_persists_and_delivers_the_core_actions() { assert_eq!(result.actions.len(), 2); assert!(matches!(result.actions[0], Action::StoreObject(_))); - assert!(matches!(result.actions[1], Action::SendActivity(_))); + assert!(matches!( + result.actions[1], + Action::SendActivityToFollowers(_) + )); let stored = state .store @@ -92,12 +90,32 @@ async fn create_note_persists_and_delivers_the_core_actions() { inbox_server.abort(); } +#[tokio::test] +async fn create_note_delivers_to_each_persisted_follower() { + let (bob_inbox, mut bob_requests, bob_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; + let (carol_inbox, mut carol_requests, carol_server) = + spawn_inbox_server(StatusCode::ACCEPTED).await; + let state = test_app_state(test_config()).expect("build app state"); + store_follower(&state, "https://remote.example/users/bob", &bob_inbox); + store_follower(&state, "https://another.example/users/carol", &carol_inbox); + + state + .create_note(create_note_input()) + .await + .expect("create note"); + + bob_requests.recv().await.expect("receive Bob delivery"); + carol_requests.recv().await.expect("receive Carol delivery"); + bob_server.abort(); + carol_server.abort(); +} + #[tokio::test] async fn create_note_keeps_the_persisted_object_when_delivery_fails() { let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::INTERNAL_SERVER_ERROR).await; let state = test_app_state(test_config()).expect("build app state"); - add_delivery_target(&state, &inbox); + store_follower(&state, "https://remote.example/users/bob", &inbox); let result = state.create_note(create_note_input()).await; @@ -117,3 +135,37 @@ async fn create_note_keeps_the_persisted_object_when_delivery_fails() { ); inbox_server.abort(); } + +#[tokio::test] +async fn create_note_resolves_persisted_followers_after_restart() { + let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; + let path = temporary_database_path("feder-create-note-recipients-test"); + let mut first_config = test_config(); + first_config.storage = StorageConfig::Sqlite { path: path.clone() }; + { + let state = test_app_state(first_config).expect("build app state"); + store_follower(&state, "https://remote.example/users/bob", &inbox); + } + + let mut second_config = test_config(); + second_config.storage = StorageConfig::Sqlite { path: path.clone() }; + let state = test_app_state(second_config).expect("reopen app state"); + assert!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); + + state + .create_note(create_note_input()) + .await + .expect("create note after restart"); + + requests.recv().await.expect("receive Create request"); + inbox_server.abort(); + let _ = std::fs::remove_file(path); +} From ad07e50ae3be264e10528fc5400b3ab267762469 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 22 Jul 2026 01:18:20 +0900 Subject: [PATCH 28/72] Address Note delivery recipients Populate Note addressing and metadata, mirror to and cc onto Create, and use one SendActivity action with typed inbox or follower recipients. Resolve follower recipients into concrete inbox deliveries at runtime. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-core/src/lib.rs | 86 ++++++++++++++----- crates/feder-runtime-server/src/operation.rs | 39 ++++----- crates/feder-runtime-server/src/send.rs | 25 +++--- .../tests/cases/operation.rs | 23 ++++- .../feder-runtime-server/tests/cases/send.rs | 24 ++++-- crates/feder-vocab/src/lib.rs | 10 ++- .../tests/activitypub_serialization.rs | 6 +- 7 files changed, 153 insertions(+), 60 deletions(-) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index bfa9978..4be7dd7 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -163,7 +163,7 @@ impl FederState { actions.push(Action::SendActivity(SendActivity { activity: Activity::Accept(accept), - inbox, + recipients: Recipients::Inbox(inbox), })); } @@ -214,14 +214,20 @@ impl FederState { let mut note = vocab::Note::new(input.note_id); note.attributed_to = Some(actor.clone()); + note.to = input.to; + note.cc = input.cc; note.content = Some(input.content); + note.media_type = input.media_type; note.published = input.published; + note.url = input.url; - let create = vocab::Create::new( + let mut create = vocab::Create::new( input.create_id, actor, vocab::Reference::object(note.clone()), ); + create.to = note.to.clone(); + create.cc = note.cc.clone(); let object = Object::Note(note); self.objects.push(object.clone()); @@ -229,9 +235,9 @@ impl FederState { Vec::from([ Action::StoreObject(StoreObject { object }), - Action::SendActivityToFollowers(SendActivityToFollowers { + Action::SendActivity(SendActivity { activity: Activity::CreateNote(create), - actor: self.local_actor.id.clone(), + recipients: Recipients::Followers(self.local_actor.id.clone()), }), ]) } @@ -291,8 +297,12 @@ pub struct UserCreateNote { pub note_id: vocab::Iri, pub create_id: vocab::Iri, pub actor: vocab::Reference, + pub to: vocab::References, + pub cc: vocab::References, pub content: String, + pub media_type: Option, pub published: Option, + pub url: Option, } impl Input { @@ -319,7 +329,6 @@ pub enum Action { RemoveFollower(RemoveFollower), StoreObject(StoreObject), SendActivity(SendActivity), - SendActivityToFollowers(SendActivityToFollowers), } #[derive(Clone, Debug, Eq, PartialEq)] @@ -344,15 +353,15 @@ pub struct StoreObject { #[derive(Clone, Debug, Eq, PartialEq)] pub struct SendActivity { pub activity: Activity, - pub inbox: vocab::Iri, + pub recipients: Recipients, } #[derive(Clone, Debug, Eq, PartialEq)] -pub struct SendActivityToFollowers { - /// Activity to deliver to the actor's current followers. - pub activity: Activity, - /// Local actor whose followers should receive the activity. - pub actor: vocab::Iri, +pub enum Recipients { + /// Deliver directly to this inbox. + Inbox(vocab::Iri), + /// Deliver to the current followers of this local actor. + Followers(vocab::Iri), } #[derive(Clone, Debug, Eq, PartialEq)] @@ -467,7 +476,10 @@ mod tests { let Action::SendActivity(send) = &result.actions[1] else { panic!("expected SendActivity action"); }; - assert_eq!(send.inbox, iri("https://remote.example/users/bob/inbox")); + assert_eq!( + send.recipients, + Recipients::Inbox(iri("https://remote.example/users/bob/inbox")) + ); let Activity::Accept(accept) = &send.activity else { panic!("expected Accept activity"); @@ -529,7 +541,10 @@ mod tests { let Action::SendActivity(send) = &second_result.actions[1] else { panic!("expected SendActivity action"); }; - assert_eq!(send.inbox, iri("https://remote.example/inboxes/bob")); + assert_eq!( + send.recipients, + Recipients::Inbox(iri("https://remote.example/inboxes/bob")) + ); let Activity::Accept(accept) = &send.activity else { panic!("expected Accept activity"); @@ -672,8 +687,12 @@ mod tests { 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")), + to: vocab::References::one(iri("https://www.w3.org/ns/activitystreams#Public")), + cc: vocab::References::one(iri("https://example.com/users/alice/followers")), content: "Hello from Feder.".to_string(), + media_type: Some("text/html".to_string()), published: Some("2026-06-10T00:00:00Z".to_string()), + url: Some(iri("https://example.com/@alice/1")), }; let mut core = core(); @@ -690,7 +709,17 @@ mod tests { Some(vocab::Reference::id(iri("https://example.com/users/alice"))) ); assert_eq!(note.content, Some("Hello from Feder.".to_string())); + assert_eq!( + note.to, + vocab::References::one(iri("https://www.w3.org/ns/activitystreams#Public")) + ); + assert_eq!( + note.cc, + vocab::References::one(iri("https://example.com/users/alice/followers")) + ); + assert_eq!(note.media_type.as_deref(), Some("text/html")); assert_eq!(note.published, Some("2026-06-10T00:00:00Z".to_string())); + assert_eq!(note.url, Some(iri("https://example.com/@alice/1"))); match &core.state().activities()[0] { Activity::CreateNote(create) => { @@ -699,6 +728,8 @@ mod tests { create.actor, vocab::Reference::id(iri("https://example.com/users/alice")) ); + assert_eq!(create.to, note.to); + assert_eq!(create.cc, note.cc); } Activity::Accept(_) => panic!("expected Create activity"), } @@ -709,10 +740,13 @@ mod tests { object: Object::Note(note.clone()), }) ); - let Action::SendActivityToFollowers(send) = &result.actions[1] else { + let Action::SendActivity(send) = &result.actions[1] else { panic!("expected followers delivery action"); }; - assert_eq!(send.actor, iri("https://example.com/users/alice")); + assert_eq!( + send.recipients, + Recipients::Followers(iri("https://example.com/users/alice")) + ); let Activity::CreateNote(create) = &send.activity else { panic!("expected Create activity"); }; @@ -743,8 +777,8 @@ mod tests { panic!("expected Accept delivery action"); }; assert_eq!( - accept_delivery.inbox, - iri("https://remote.example/users/bob/inbox") + accept_delivery.recipients, + Recipients::Inbox(iri("https://remote.example/users/bob/inbox")) ); assert!(matches!(accept_delivery.activity, Activity::Accept(_))); @@ -752,18 +786,22 @@ mod tests { 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")), + to: vocab::References::new(), + cc: vocab::References::new(), content: "Hello from Feder.".to_string(), + media_type: None, published: Some("2026-06-10T00:00:00Z".to_string()), + url: None, })); assert_eq!(create_result.actions.len(), 2); assert!(matches!(create_result.actions[0], Action::StoreObject(_))); - let Action::SendActivityToFollowers(create_delivery) = &create_result.actions[1] else { + let Action::SendActivity(create_delivery) = &create_result.actions[1] else { panic!("expected followers delivery action"); }; assert_eq!( - create_delivery.actor, - iri("https://example.com/users/alice") + create_delivery.recipients, + Recipients::Followers(iri("https://example.com/users/alice")) ); assert!(matches!(create_delivery.activity, Activity::CreateNote(_))); @@ -781,8 +819,12 @@ mod tests { note_id: iri("https://example.com/notes/1"), create_id: iri("https://example.com/activities/create/1"), actor: vocab::Reference::object(supplied_actor), + to: vocab::References::new(), + cc: vocab::References::new(), content: "Hello from Feder.".to_string(), + media_type: None, published: None, + url: None, }; let mut core = core(); @@ -811,8 +853,12 @@ mod tests { 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")), + to: vocab::References::new(), + cc: vocab::References::new(), content: "Hello from elsewhere.".to_string(), + media_type: None, published: Some("2026-06-10T00:00:00Z".to_string()), + url: None, }; let mut core = core(); diff --git a/crates/feder-runtime-server/src/operation.rs b/crates/feder-runtime-server/src/operation.rs index ee748ed..e803e46 100644 --- a/crates/feder-runtime-server/src/operation.rs +++ b/crates/feder-runtime-server/src/operation.rs @@ -15,9 +15,7 @@ use std::collections::HashSet; -use feder_core::{ - Action, HandleResult, Input, SendActivity, SendActivityToFollowers, UserCreateNote, -}; +use feder_core::{Action, HandleResult, Input, Recipients, SendActivity, UserCreateNote}; use crate::{Error, app::AppState, storage::RuntimeStore}; @@ -36,43 +34,44 @@ impl AppState { core.handle(input) }; - let delivery_actions = { + let deliveries = { let mut store = self .store .lock() .map_err(|_| Error::StorageStateUnavailable)?; store.persist_actions(&result.actions)?; - resolve_delivery_actions(&*store, &result.actions)? + resolve_outbound_deliveries(&*store, &result.actions)? }; - self.activity_sender.send_actions(&delivery_actions).await?; + self.activity_sender.send_actions(&deliveries).await?; Ok(result) } } -fn resolve_delivery_actions( +fn resolve_outbound_deliveries( store: &impl RuntimeStore, actions: &[Action], -) -> Result, Error> { +) -> Result, Error> { let mut resolved = Vec::new(); for action in actions { - match action { - Action::SendActivity(_) => resolved.push(action.clone()), - Action::SendActivityToFollowers(SendActivityToFollowers { activity, actor }) => { - let mut seen_inboxes = HashSet::new(); - for recipient in store.list_follower_recipients(actor)? { - let inbox = recipient.shared_inbox.unwrap_or(recipient.inbox); - if seen_inboxes.insert(inbox.clone()) { - resolved.push(Action::SendActivity(SendActivity { - activity: activity.clone(), - inbox, - })); + if let Action::SendActivity(send) = action { + match &send.recipients { + Recipients::Inbox(_) => resolved.push(send.clone()), + Recipients::Followers(actor) => { + let mut seen_inboxes = HashSet::new(); + for recipient in store.list_follower_recipients(actor)? { + let inbox = recipient.shared_inbox.unwrap_or(recipient.inbox); + if seen_inboxes.insert(inbox.clone()) { + resolved.push(SendActivity { + activity: send.activity.clone(), + recipients: Recipients::Inbox(inbox), + }); + } } } } - _ => {} } } diff --git a/crates/feder-runtime-server/src/send.rs b/crates/feder-runtime-server/src/send.rs index a8fc26f..f588b5b 100644 --- a/crates/feder-runtime-server/src/send.rs +++ b/crates/feder-runtime-server/src/send.rs @@ -17,7 +17,7 @@ use std::{sync::Arc, time::SystemTime}; use crate::{config::OutboundAddressPolicy, url}; use feder_core::{ - Action, Activity, SendActivity, + Activity, Recipients, SendActivity, http_signatures::{ ActorKeyPair, HttpSignatureError, create_sha256_digest_header, sign_draft_cavage, }, @@ -54,12 +54,11 @@ impl ActivitySender { } /// Attempts every send action and returns the first error encountered. - pub async fn send_actions(&self, actions: &[Action]) -> Result<(), SendError> { + pub async fn send_actions(&self, actions: &[SendActivity]) -> Result<(), SendError> { let mut first_error = None; for action in actions { - if let Action::SendActivity(send) = action - && let Err(error) = self.send(send).await + if let Err(error) = self.send(action).await && first_error.is_none() { first_error = Some(error); @@ -76,20 +75,23 @@ impl ActivitySender { _ => return Err(SendError::UnsupportedActivity), } .map_err(SendError::Serialize)?; - let url = Url::parse(send.inbox.as_str()) - .map_err(|_| SendError::InvalidInbox(send.inbox.to_string()))?; + let Recipients::Inbox(inbox) = &send.recipients else { + return Err(SendError::UnresolvedRecipients); + }; + let url = + Url::parse(inbox.as_str()).map_err(|_| SendError::InvalidInbox(inbox.to_string()))?; if !matches!(url.scheme(), "http" | "https") { - return Err(SendError::InvalidInbox(send.inbox.to_string())); + return Err(SendError::InvalidInbox(inbox.to_string())); } crate::url::validate_literal_host(&url, self.address_policy).map_err(|address| { SendError::PrivateInboxAddress { - inbox: send.inbox.to_string(), + inbox: inbox.to_string(), address, } })?; let mut host = url .host() - .ok_or_else(|| SendError::InvalidInbox(send.inbox.to_string()))? + .ok_or_else(|| SendError::InvalidInbox(inbox.to_string()))? .to_string(); if let Some(port) = url.port() { host = format!("{host}:{port}"); @@ -131,7 +133,7 @@ impl ActivitySender { if !response.status().is_success() { return Err(SendError::UnsuccessfulStatus { - inbox: send.inbox.to_string(), + inbox: inbox.to_string(), status: response.status(), }); } @@ -168,4 +170,7 @@ pub enum SendError { #[error("activity type is not supported for sending")] UnsupportedActivity, + + #[error("activity recipients must resolve to an inbox before sending")] + UnresolvedRecipients, } diff --git a/crates/feder-runtime-server/tests/cases/operation.rs b/crates/feder-runtime-server/tests/cases/operation.rs index 6318fc4..cb54fc8 100644 --- a/crates/feder-runtime-server/tests/cases/operation.rs +++ b/crates/feder-runtime-server/tests/cases/operation.rs @@ -14,7 +14,7 @@ // along with this program. If not, see . use axum::http::StatusCode; -use feder_core::{Action, Object, StoreFollower, UserCreateNote}; +use feder_core::{Action, Object, Recipients, StoreFollower, UserCreateNote}; use feder_runtime_server::{Error, config::StorageConfig, send::SendError, storage::RuntimeStore}; use feder_vocab::{Actor, Iri, Reference}; @@ -29,8 +29,12 @@ fn create_note_input() -> UserCreateNote { note_id: iri("http://127.0.0.1:3000/users/alice/posts/1"), create_id: iri("http://127.0.0.1:3000/users/alice/activities/create/1"), actor: Reference::id(iri("http://127.0.0.1:3000/users/alice")), + to: feder_vocab::References::one(iri("https://www.w3.org/ns/activitystreams#Public")), + cc: feder_vocab::References::one(iri("http://127.0.0.1:3000/users/alice/followers")), content: "Hello from Feder.".to_string(), + media_type: Some("text/html".to_string()), published: Some("2026-07-21T00:00:00Z".to_string()), + url: Some(iri("http://127.0.0.1:3000/@alice/1")), } } @@ -66,8 +70,9 @@ async fn create_note_persists_and_delivers_the_core_actions() { assert_eq!(result.actions.len(), 2); assert!(matches!(result.actions[0], Action::StoreObject(_))); assert!(matches!( - result.actions[1], - Action::SendActivityToFollowers(_) + &result.actions[1], + Action::SendActivity(send) + if matches!(&send.recipients, Recipients::Followers(_)) )); let stored = state @@ -86,7 +91,19 @@ async fn create_note_persists_and_delivers_the_core_actions() { let activity: serde_json::Value = serde_json::from_slice(&request.body).expect("valid Create activity"); assert_eq!(activity["type"], "Create"); + assert_eq!( + activity["to"], + "https://www.w3.org/ns/activitystreams#Public" + ); + assert_eq!( + activity["cc"], + "http://127.0.0.1:3000/users/alice/followers" + ); assert_eq!(activity["object"]["id"], note.id.as_str()); + assert_eq!(activity["object"]["to"], activity["to"]); + assert_eq!(activity["object"]["cc"], activity["cc"]); + assert_eq!(activity["object"]["mediaType"], "text/html"); + assert_eq!(activity["object"]["url"], "http://127.0.0.1:3000/@alice/1"); inbox_server.abort(); } diff --git a/crates/feder-runtime-server/tests/cases/send.rs b/crates/feder-runtime-server/tests/cases/send.rs index fdf0750..d874f18 100644 --- a/crates/feder-runtime-server/tests/cases/send.rs +++ b/crates/feder-runtime-server/tests/cases/send.rs @@ -15,7 +15,7 @@ use axum::http::StatusCode; use feder_core::{ - Action, Activity, SendActivity, + Activity, Recipients, SendActivity, http_signatures::{ActorKeyPair, sign_draft_cavage}, }; use feder_runtime_server::{OutboundAddressPolicy, send::SendError}; @@ -23,7 +23,7 @@ use feder_vocab::{Create, Note, Reference}; use crate::common::{spawn_inbox_server, test_activity_sender, test_activity_sender_with_policy}; -fn create_note_send_action(inbox: &str) -> Action { +fn create_note_send_action(inbox: &str) -> SendActivity { let actor_id = "https://local.example/users/alice" .parse() .expect("valid actor IRI"); @@ -40,10 +40,10 @@ fn create_note_send_action(inbox: &str) -> Action { Reference::object(note), ); - Action::SendActivity(SendActivity { + SendActivity { activity: Activity::CreateNote(create), - inbox: inbox.parse().expect("valid inbox IRI"), - }) + recipients: Recipients::Inbox(inbox.parse().expect("valid inbox IRI")), + } } #[tokio::test] @@ -103,6 +103,20 @@ async fn sends_create_note_action() { inbox_server.abort(); } +#[tokio::test] +async fn rejects_unresolved_follower_recipients() { + let mut action = create_note_send_action("https://remote.example/inbox"); + action.recipients = Recipients::Followers( + "https://local.example/users/alice" + .parse() + .expect("valid actor IRI"), + ); + + let result = test_activity_sender().send_actions(&[action]).await; + + assert!(matches!(result, Err(SendError::UnresolvedRecipients))); +} + #[tokio::test] async fn attempts_later_sends_after_failure() { let (failed_inbox, mut failed_requests, failed_server) = diff --git a/crates/feder-vocab/src/lib.rs b/crates/feder-vocab/src/lib.rs index 2f6c2a1..a22cb34 100644 --- a/crates/feder-vocab/src/lib.rs +++ b/crates/feder-vocab/src/lib.rs @@ -462,6 +462,10 @@ pub struct Create { pub id: Iri, pub actor: Reference, pub object: Reference, + #[serde(default, skip_serializing_if = "References::is_empty")] + pub to: References, + #[serde(default, skip_serializing_if = "References::is_empty")] + pub cc: References, } impl Create { @@ -477,6 +481,8 @@ impl Create { id, actor, object, + to: References::new(), + cc: References::new(), } } } @@ -625,11 +631,13 @@ mod tests { note.content = Some("Hello, fediverse.".to_string()); note.published = Some("2026-05-29T06:30:00Z".to_string()); - let create = Create::new( + let mut create = Create::new( iri("https://example.com/activities/create/1"), Reference::id(iri("https://example.com/users/alice")), Reference::object(note), ); + create.to = References::one(iri("https://www.w3.org/ns/activitystreams#Public")); + create.cc = References::one(iri("https://example.com/users/alice/followers")); assert_eq!(roundtrip(&create), create); } diff --git a/crates/feder-vocab/tests/activitypub_serialization.rs b/crates/feder-vocab/tests/activitypub_serialization.rs index 633b9a8..ba1bf3a 100644 --- a/crates/feder-vocab/tests/activitypub_serialization.rs +++ b/crates/feder-vocab/tests/activitypub_serialization.rs @@ -183,11 +183,13 @@ fn local_note_serializes_as_create_activity() { note.published = Some("2026-06-02T00:00:00Z".to_string()); note.url = Some(iri("https://example.com/@alice/1")); - let create = Create::new( + let mut create = Create::new( iri("https://example.com/activities/create/1"), Reference::id(iri("https://example.com/users/alice")), Reference::object(note), ); + create.to = References::one(iri("https://www.w3.org/ns/activitystreams#Public")); + create.cc = References::one(iri("https://example.com/users/alice/followers")); assert_eq!( serialize_to_value(create), @@ -196,6 +198,8 @@ fn local_note_serializes_as_create_activity() { "type": "Create", "id": "https://example.com/activities/create/1", "actor": "https://example.com/users/alice", + "to": "https://www.w3.org/ns/activitystreams#Public", + "cc": "https://example.com/users/alice/followers", "object": { "@context": ACTIVITYSTREAMS_CONTEXT, "type": "Note", From 2f37216535ec706b3271c6a0961d8d81c5fb74f3 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 22 Jul 2026 15:30:53 +0900 Subject: [PATCH 29/72] Add Follow type and signed sender --- crates/feder-core/src/lib.rs | 5 ++++- crates/feder-runtime-server/src/send.rs | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 4be7dd7..35ed021 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -369,6 +369,7 @@ pub enum Recipients { pub enum Activity { Accept(vocab::Accept), CreateNote(vocab::Create), + Follow(vocab::Follow), } #[derive(Clone, Debug, Eq, PartialEq)] @@ -731,7 +732,9 @@ mod tests { assert_eq!(create.to, note.to); assert_eq!(create.cc, note.cc); } - Activity::Accept(_) => panic!("expected Create activity"), + Activity::Accept(_) | Activity::Follow(_) => { + panic!("expected Create activity") + } } assert_eq!( diff --git a/crates/feder-runtime-server/src/send.rs b/crates/feder-runtime-server/src/send.rs index f588b5b..851598b 100644 --- a/crates/feder-runtime-server/src/send.rs +++ b/crates/feder-runtime-server/src/send.rs @@ -72,6 +72,7 @@ impl ActivitySender { let body = match &send.activity { Activity::Accept(activity) => serde_json::to_vec(activity), Activity::CreateNote(activity) => serde_json::to_vec(activity), + Activity::Follow(activity) => serde_json::to_vec(activity), _ => return Err(SendError::UnsupportedActivity), } .map_err(SendError::Serialize)?; From d34c1f8eeeabea2f61e19435a89cb15375f37394 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 22 Jul 2026 15:31:23 +0900 Subject: [PATCH 30/72] Add Follow test Assisted-by: Codex:gpt-5.6-sol --- .../feder-runtime-server/tests/cases/send.rs | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/feder-runtime-server/tests/cases/send.rs b/crates/feder-runtime-server/tests/cases/send.rs index d874f18..93bf867 100644 --- a/crates/feder-runtime-server/tests/cases/send.rs +++ b/crates/feder-runtime-server/tests/cases/send.rs @@ -19,7 +19,7 @@ use feder_core::{ http_signatures::{ActorKeyPair, sign_draft_cavage}, }; use feder_runtime_server::{OutboundAddressPolicy, send::SendError}; -use feder_vocab::{Create, Note, Reference}; +use feder_vocab::{Create, Follow, Note, Reference}; use crate::common::{spawn_inbox_server, test_activity_sender, test_activity_sender_with_policy}; @@ -46,6 +46,29 @@ fn create_note_send_action(inbox: &str) -> SendActivity { } } +fn follow_send_action(inbox: &str) -> SendActivity { + let follow = Follow::new( + "https://local.example/activities/follow-1" + .parse() + .expect("valid activity IRI"), + Reference::id( + "https://local.example/users/alice" + .parse() + .expect("valid actor IRI"), + ), + Reference::id( + "https://remote.example/users/bob" + .parse() + .expect("valid actor IRI"), + ), + ); + + SendActivity { + activity: Activity::Follow(follow), + recipients: Recipients::Inbox(inbox.parse().expect("valid inbox IRI")), + } +} + #[tokio::test] async fn sends_create_note_action() { let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; @@ -103,6 +126,27 @@ async fn sends_create_note_action() { inbox_server.abort(); } +#[tokio::test] +async fn sends_follow_action() { + let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; + + test_activity_sender() + .send_actions(&[follow_send_action(&inbox)]) + .await + .expect("send Follow activity"); + + let request = requests.recv().await.expect("receive Follow request"); + assert_eq!(request.uri, "/inbox"); + assert_eq!(request.headers["content-type"], "application/activity+json"); + assert!(request.headers.contains_key("signature")); + let activity: serde_json::Value = + serde_json::from_slice(&request.body).expect("valid sent activity"); + assert_eq!(activity["type"], "Follow"); + assert_eq!(activity["actor"], "https://local.example/users/alice"); + assert_eq!(activity["object"], "https://remote.example/users/bob"); + inbox_server.abort(); +} + #[tokio::test] async fn rejects_unresolved_follower_recipients() { let mut action = create_note_send_action("https://remote.example/inbox"); From d05b50472cdf4ab85fc32af5b023afa0c694248b Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 27 Jul 2026 19:03:17 +0900 Subject: [PATCH 31/72] Add sending activities when actor is given --- crates/feder-core/src/lib.rs | 1 + crates/feder-runtime-server/src/operation.rs | 62 ++++++++++++-------- 2 files changed, 39 insertions(+), 24 deletions(-) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 35ed021..6a48bbe 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -362,6 +362,7 @@ pub enum Recipients { Inbox(vocab::Iri), /// Deliver to the current followers of this local actor. Followers(vocab::Iri), + Actor(vocab::Iri), } #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/crates/feder-runtime-server/src/operation.rs b/crates/feder-runtime-server/src/operation.rs index e803e46..b14a6c3 100644 --- a/crates/feder-runtime-server/src/operation.rs +++ b/crates/feder-runtime-server/src/operation.rs @@ -33,47 +33,61 @@ impl AppState { let mut core = self.core.lock().map_err(|_| Error::CoreStateUnavailable)?; core.handle(input) }; - - let deliveries = { + { let mut store = self .store .lock() .map_err(|_| Error::StorageStateUnavailable)?; store.persist_actions(&result.actions)?; - resolve_outbound_deliveries(&*store, &result.actions)? }; + let deliveries = self.resolve_outbound_deliveries(&result.actions).await?; self.activity_sender.send_actions(&deliveries).await?; Ok(result) } -} -fn resolve_outbound_deliveries( - store: &impl RuntimeStore, - actions: &[Action], -) -> Result, Error> { - let mut resolved = Vec::new(); + async fn resolve_outbound_deliveries( + &self, + actions: &[Action], + ) -> Result, Error> { + let mut resolved = Vec::new(); - for action in actions { - if let Action::SendActivity(send) = action { - match &send.recipients { - Recipients::Inbox(_) => resolved.push(send.clone()), - Recipients::Followers(actor) => { - let mut seen_inboxes = HashSet::new(); - for recipient in store.list_follower_recipients(actor)? { - let inbox = recipient.shared_inbox.unwrap_or(recipient.inbox); - if seen_inboxes.insert(inbox.clone()) { - resolved.push(SendActivity { - activity: send.activity.clone(), - recipients: Recipients::Inbox(inbox), - }); + for action in actions { + if let Action::SendActivity(send) = action { + match &send.recipients { + Recipients::Inbox(_) => resolved.push(send.clone()), + Recipients::Followers(actor_id) => { + let mut seen_inboxes = HashSet::new(); + let recipients = { + let store = self + .store + .lock() + .map_err(|_| Error::StorageStateUnavailable)?; + store.list_follower_recipients(actor_id)? + }; + for recipient in recipients { + let inbox = recipient.shared_inbox.unwrap_or(recipient.inbox); + if seen_inboxes.insert(inbox.clone()) { + resolved.push(SendActivity { + activity: send.activity.clone(), + recipients: Recipients::Inbox(inbox), + }); + } } } + Recipients::Actor(actor_id) => { + let actor = self.actor_resolver.resolve(actor_id).await?; + + resolved.push(SendActivity { + activity: send.activity.clone(), + recipients: Recipients::Inbox(actor.inbox), + }) + } } } } - } - Ok(resolved) + Ok(resolved) + } } From 7c021a6b36e05016b427877f928a40fd78b5b50a Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 27 Jul 2026 19:34:40 +0900 Subject: [PATCH 32/72] Send activities to to and cc --- crates/feder-core/src/lib.rs | 45 ++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 6a48bbe..9163b05 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -25,6 +25,8 @@ pub use feder_vocab as vocab; #[cfg(feature = "http-signatures")] pub mod http_signatures; +const PUBLIC_COLLECTION: &str = "https://www.w3.org/ns/activitystreams#Public"; + /// Portable core state and decision logic. #[derive(Debug)] pub struct FederCore { @@ -229,18 +231,47 @@ impl FederState { create.to = note.to.clone(); create.cc = note.cc.clone(); + let recipients = note_recipients(&self.local_actor, ¬e); + let object = Object::Note(note); self.objects.push(object.clone()); self.activities.push(Activity::CreateNote(create.clone())); - Vec::from([ - Action::StoreObject(StoreObject { object }), - Action::SendActivity(SendActivity { - activity: Activity::CreateNote(create), - recipients: Recipients::Followers(self.local_actor.id.clone()), - }), - ]) + let mut actions = Vec::new(); + + actions.push(Action::StoreObject(StoreObject { object })); + + for recipient in recipients { + actions.push(Action::SendActivity(SendActivity { + activity: Activity::CreateNote(create.clone()), + recipients: recipient, + })); + } + + actions + } +} + +fn note_recipients(local_actor: &vocab::Actor, note: &vocab::Note) -> Vec { + let mut recipients = Vec::new(); + + for address in note.to.iter().chain(note.cc.iter()) { + let recipient = if address.as_str() == PUBLIC_COLLECTION { + // Public describes visibility. It cannot receive an activity. + continue; + } else if local_actor.followers.as_ref() == Some(address) { + Recipients::Followers(local_actor.id.clone()) + } else if address == &local_actor.id { + continue; + } else { + Recipients::Actor(address.clone()) + }; + + if !recipients.contains(&recipient) { + recipients.push(recipient); + } } + recipients } fn reference_id(reference: &vocab::Reference) -> Option<&vocab::Iri> From ef1cb71ee4be8e681823dbc605f4eb5210064382 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 27 Jul 2026 19:52:38 +0900 Subject: [PATCH 33/72] Add to and cc delivery test Assisted-by: Codex:gpt-5.6-sol --- crates/feder-core/src/lib.rs | 56 ++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 9163b05..0dc1d59 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -446,7 +446,9 @@ mod tests { } fn core() -> FederCore { - FederCore::new(FederConfig::new(actor("https://example.com/users/alice"))) + let mut local_actor = actor("https://example.com/users/alice"); + local_actor.followers = Some(iri("https://example.com/users/alice/followers")); + FederCore::new(FederConfig::new(local_actor)) } fn received_follow(follow: vocab::Follow, id: &str) -> Input { @@ -822,7 +824,7 @@ mod tests { create_id: iri("https://example.com/activities/create/1"), actor: vocab::Reference::id(iri("https://example.com/users/alice")), to: vocab::References::new(), - cc: vocab::References::new(), + cc: vocab::References::one(iri("https://example.com/users/alice/followers")), content: "Hello from Feder.".to_string(), media_type: None, published: Some("2026-06-10T00:00:00Z".to_string()), @@ -865,7 +867,8 @@ mod tests { let mut core = core(); let result = core.handle(Input::UserCreateNote(input)); - assert_eq!(result.actions.len(), 2); + assert_eq!(result.actions.len(), 1); + assert!(matches!(result.actions[0], Action::StoreObject(_))); let Object::Note(note) = &core.state().objects()[0]; assert_eq!( @@ -882,6 +885,53 @@ mod tests { ); } + #[test] + fn user_create_note_emits_one_direct_delivery_for_duplicate_actor_addresses() { + let bob = iri("https://remote.example/users/bob"); + 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")), + to: vocab::References::one(bob.clone()), + cc: vocab::References::one(bob.clone()), + content: "Hello Bob.".to_string(), + media_type: None, + published: None, + url: None, + }; + + let mut core = core(); + let result = core.handle(Input::UserCreateNote(input)); + + assert_eq!(result.actions.len(), 2); + assert!(matches!(result.actions[0], Action::StoreObject(_))); + let Action::SendActivity(send) = &result.actions[1] else { + panic!("expected direct delivery action"); + }; + assert_eq!(send.recipients, Recipients::Actor(bob)); + } + + #[test] + fn user_create_note_does_not_deliver_to_public_collection() { + 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")), + to: vocab::References::one(iri(PUBLIC_COLLECTION)), + cc: vocab::References::new(), + content: "Hello everyone.".to_string(), + media_type: None, + published: None, + url: None, + }; + + let mut core = core(); + let result = core.handle(Input::UserCreateNote(input)); + + assert_eq!(result.actions.len(), 1); + assert!(matches!(result.actions[0], Action::StoreObject(_))); + } + #[test] fn user_create_note_for_non_local_actor_is_ignored() { let input = UserCreateNote { From 44156616c711709d5d80a7148fee26d1867f9670 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 27 Jul 2026 21:41:57 +0900 Subject: [PATCH 34/72] Add public only guard when getting object --- crates/feder-core/src/lib.rs | 2 +- crates/feder-runtime-server/src/object.rs | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 0dc1d59..449e123 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -25,7 +25,7 @@ pub use feder_vocab as vocab; #[cfg(feature = "http-signatures")] pub mod http_signatures; -const PUBLIC_COLLECTION: &str = "https://www.w3.org/ns/activitystreams#Public"; +pub const PUBLIC_COLLECTION: &str = "https://www.w3.org/ns/activitystreams#Public"; /// Portable core state and decision logic. #[derive(Debug)] diff --git a/crates/feder-runtime-server/src/object.rs b/crates/feder-runtime-server/src/object.rs index d808ff6..0a282b5 100644 --- a/crates/feder-runtime-server/src/object.rs +++ b/crates/feder-runtime-server/src/object.rs @@ -19,7 +19,7 @@ use axum::{ http::{HeaderMap, StatusCode, header}, response::{IntoResponse, Response}, }; -use feder_core::Object; +use feder_core::{Object, PUBLIC_COLLECTION}; use feder_vocab::Iri; use crate::{app::AppState, negotiation::accepts_activitypub, storage::RuntimeStore}; @@ -46,6 +46,14 @@ pub async fn get_object( return Err(StatusCode::NOT_FOUND); }; + if !note + .to + .iter() + .chain(note.cc.iter()) + .any(|recipient| recipient.as_str() == PUBLIC_COLLECTION) + { + return Err(StatusCode::NOT_FOUND); + } if !accepts_activitypub(&headers) { return Ok(([(header::VARY, "Accept")], StatusCode::NOT_ACCEPTABLE).into_response()); } From 0dffa37711d257a2d5368ef45d3f314e475136dd Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 27 Jul 2026 21:48:28 +0900 Subject: [PATCH 35/72] Add visibility tests Assisted-by: Codex:gpt-5.6-sol --- .../tests/cases/object.rs | 76 ++++++++++++++++++- 1 file changed, 72 insertions(+), 4 deletions(-) diff --git a/crates/feder-runtime-server/tests/cases/object.rs b/crates/feder-runtime-server/tests/cases/object.rs index 6d8512a..55729d3 100644 --- a/crates/feder-runtime-server/tests/cases/object.rs +++ b/crates/feder-runtime-server/tests/cases/object.rs @@ -18,7 +18,7 @@ use axum::{ body::{Body, to_bytes}, http::{Request, StatusCode, header}, }; -use feder_core::{Action, Object, StoreObject}; +use feder_core::{Action, Object, PUBLIC_COLLECTION, StoreObject}; use feder_runtime_server::{app::router_with_state, config::StorageConfig, storage::RuntimeStore}; use feder_vocab::{Iri, Note, Reference, References}; use serde_json::Value; @@ -33,7 +33,7 @@ fn iri(value: &str) -> Iri { fn stored_note() -> Note { let mut note = Note::new(iri("http://127.0.0.1:3000/users/alice/posts/1")); note.attributed_to = Some(Reference::id(iri("http://127.0.0.1:3000/users/alice"))); - note.to = References::one(iri("https://www.w3.org/ns/activitystreams#Public")); + note.to = References::one(iri(PUBLIC_COLLECTION)); note.cc = References::one(iri("http://127.0.0.1:3000/users/alice/followers")); note.content = Some("Hello from Feder.".to_string()); note.media_type = Some("text/html".to_string()); @@ -42,19 +42,23 @@ fn stored_note() -> Note { note } -fn router_with_note() -> Router { +fn router_with_stored_note(note: Note) -> Router { let state = test_app_state(test_config()).expect("build app state"); state .store .lock() .expect("store lock") .persist_actions(&[Action::StoreObject(StoreObject { - object: Object::Note(stored_note()), + object: Object::Note(note), })]) .expect("persist note"); router_with_state(state) } +fn router_with_note() -> Router { + router_with_stored_note(stored_note()) +} + async fn get_object(app: Router, uri: &str, accept: Option<&str>) -> axum::response::Response { let mut request = Request::builder().uri(uri); if let Some(accept) = accept { @@ -92,6 +96,70 @@ async fn returns_stored_note_with_activitypub_headers() { assert_eq!(json["mediaType"], "text/html"); } +#[tokio::test] +async fn returns_note_when_public_is_in_cc() { + let mut note = stored_note(); + note.to = References::one(iri("https://remote.example/users/bob")); + note.cc = References::one(iri(PUBLIC_COLLECTION)); + + let response = get_object( + router_with_stored_note(note), + "/users/alice/posts/1", + Some("application/activity+json"), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); +} + +#[tokio::test] +async fn returns_not_found_for_direct_note() { + let mut note = stored_note(); + note.to = References::one(iri("https://remote.example/users/bob")); + note.cc = References::new(); + + let response = get_object( + router_with_stored_note(note), + "/users/alice/posts/1", + Some("application/activity+json"), + ) + .await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn returns_not_found_for_followers_only_note() { + let mut note = stored_note(); + note.to = References::one(iri("http://127.0.0.1:3000/users/alice/followers")); + note.cc = References::new(); + + let response = get_object( + router_with_stored_note(note), + "/users/alice/posts/1", + Some("application/activity+json"), + ) + .await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn returns_not_found_for_note_without_audience() { + let mut note = stored_note(); + note.to = References::new(); + note.cc = References::new(); + + let response = get_object( + router_with_stored_note(note), + "/users/alice/posts/1", + Some("application/activity+json"), + ) + .await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + #[tokio::test] async fn rejects_note_request_when_html_is_preferred() { let response = get_object( From 0cbc265a0a822ec17b4e7e91f09ba44c2b9c71b3 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Mon, 27 Jul 2026 23:17:40 +0900 Subject: [PATCH 36/72] Clear stale shared inbox on actor refresh Assisted-by: Codex:gpt-5.6-sol --- .../src/storage/sqlite.rs | 83 +++++++++++++++++-- 1 file changed, 78 insertions(+), 5 deletions(-) diff --git a/crates/feder-runtime-server/src/storage/sqlite.rs b/crates/feder-runtime-server/src/storage/sqlite.rs index c00c369..e24949c 100644 --- a/crates/feder-runtime-server/src/storage/sqlite.rs +++ b/crates/feder-runtime-server/src/storage/sqlite.rs @@ -86,6 +86,7 @@ impl RuntimeStore for SqliteStore { let following = actor_reference_id(&action.following); let inbox = actor_reference_inbox(&action.follower); let shared_inbox = actor_reference_shared_inbox(&action.follower); + let refresh_actor = matches!(&action.follower, Reference::Object(_)); tx.execute( r#" @@ -97,17 +98,21 @@ impl RuntimeStore for SqliteStore { ) 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 - ) + inbox_url = CASE + WHEN ?5 THEN excluded.inbox_url + ELSE followers.inbox_url + END, + shared_inbox_url = CASE + WHEN ?5 THEN excluded.shared_inbox_url + ELSE followers.shared_inbox_url + END "#, params![ follower.as_str(), following.as_str(), inbox.map(|inbox| inbox.as_str()), shared_inbox.map(|shared_inbox| shared_inbox.as_str()), + refresh_actor, ], )?; } @@ -773,6 +778,74 @@ mod tests { ); } + #[test] + fn persist_actions_clears_removed_shared_inbox_from_embedded_actor() { + 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")), + }); + store + .persist_actions(&[Action::StoreFollower(StoreFollower { + follower: Reference::object(follower), + following: Reference::id(iri("https://example.com/users/alice")), + })]) + .expect("persist follower with shared inbox"); + + let mut updated_follower = actor("https://remote.example/users/bob"); + updated_follower.inbox = iri("https://remote.example/users/bob/updated-inbox"); + store + .persist_actions(&[Action::StoreFollower(StoreFollower { + follower: Reference::object(updated_follower), + following: Reference::id(iri("https://example.com/users/alice")), + })]) + .expect("persist follower without shared inbox"); + + let recipients = store + .list_follower_recipients(&iri("https://example.com/users/alice")) + .expect("list follower recipients"); + + 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, + }] + ); + } + + #[test] + fn persist_actions_preserves_inboxes_from_id_only_repeated_follow() { + 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")), + }); + store + .persist_actions(&[Action::StoreFollower(StoreFollower { + follower: Reference::object(follower), + following: Reference::id(iri("https://example.com/users/alice")), + })]) + .expect("persist embedded follower"); + store + .persist_actions(&[store_follower_action()]) + .expect("persist ID-only repeated follower"); + + let recipients = store + .list_follower_recipients(&iri("https://example.com/users/alice")) + .expect("list follower recipients"); + + assert_eq!( + recipients, + vec![StoredRecipient { + actor_id: iri("https://remote.example/users/bob"), + inbox: iri("https://remote.example/users/bob/inbox"), + shared_inbox: Some(iri("https://remote.example/inbox")), + }] + ); + } + #[test] fn list_followers_returns_stored_followers() { let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); From 5866ea44836e54f1b776b29438936d57573cd42a Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 29 Jul 2026 01:28:55 +0900 Subject: [PATCH 37/72] Protect SQLite signing keys on Unix Create SQLite database files with owner-only permissions and restrict existing files before opening them. Document Linux as the currently supported runtime target. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/README.md | 15 +++- .../feder-runtime-server/src/storage/mod.rs | 3 + .../src/storage/sqlite.rs | 90 +++++++++++++++++++ examples/single-user-server/README.md | 14 +-- 4 files changed, 107 insertions(+), 15 deletions(-) diff --git a/crates/feder-runtime-server/README.md b/crates/feder-runtime-server/README.md index 17aed6e..a1da47c 100644 --- a/crates/feder-runtime-server/README.md +++ b/crates/feder-runtime-server/README.md @@ -1,8 +1,7 @@ Feder Runtime Server ==================== -Reusable Axum/Tokio server integration for Feder on standard operating -systems. +Reusable Axum/Tokio server integration for Feder. This crate builds an Axum router from caller-provided runtime configuration. It provides a health check endpoint, WebFinger discovery, and a local actor @@ -17,6 +16,18 @@ ActivityPub JSON signed with the actor's draft-Cavage RSA key. Incoming inbox requests can require verification with the same signature scheme. +Platform support +---------------- + +This runtime currently targets Linux for development and deployment. Other +platforms may compile, but they are not currently supported. + +On Unix targets, file-backed SQLite databases are created with owner-only +permissions, and existing database files are restricted to owner-only +permissions when opened. This protects the actor signing keys stored in the +database. Equivalent Windows ACL hardening is not currently implemented. + + Example ------- diff --git a/crates/feder-runtime-server/src/storage/mod.rs b/crates/feder-runtime-server/src/storage/mod.rs index 673ff89..b3757c6 100644 --- a/crates/feder-runtime-server/src/storage/mod.rs +++ b/crates/feder-runtime-server/src/storage/mod.rs @@ -39,6 +39,9 @@ pub struct StoredRecipient { #[derive(Debug, thiserror::Error)] pub enum StoreError { + #[error("I/O error")] + Io(#[from] std::io::Error), + #[error("sqlite error")] Sqlite(#[from] rusqlite::Error), diff --git a/crates/feder-runtime-server/src/storage/sqlite.rs b/crates/feder-runtime-server/src/storage/sqlite.rs index e24949c..682e4db 100644 --- a/crates/feder-runtime-server/src/storage/sqlite.rs +++ b/crates/feder-runtime-server/src/storage/sqlite.rs @@ -15,6 +15,12 @@ use std::path::Path; +#[cfg(unix)] +use std::{ + fs::OpenOptions, + os::unix::fs::{OpenOptionsExt, PermissionsExt}, +}; + use feder_core::{Action, Object, http_signatures::ActorKeyPair}; use feder_vocab::{Actor, Iri, Note, Reference}; use rusqlite::{Connection, OptionalExtension, params}; @@ -27,10 +33,16 @@ pub struct SqliteStore { impl SqliteStore { pub fn open(path: &Path) -> Result { + #[cfg(unix)] + let database_file = prepare_database_file(path)?; + let store = Self { conn: Connection::open(path)?, }; + #[cfg(unix)] + drop(database_file); + store.init()?; Ok(store) @@ -75,6 +87,23 @@ impl SqliteStore { } } +#[cfg(unix)] +fn prepare_database_file(path: &Path) -> Result { + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(path)?; + + let mut permissions = file.metadata()?.permissions(); + permissions.set_mode(0o600); + file.set_permissions(permissions)?; + + Ok(file) +} + impl RuntimeStore for SqliteStore { fn persist_actions(&mut self, actions: &[Action]) -> Result<(), StoreError> { let tx = self.conn.transaction()?; @@ -537,6 +566,67 @@ mod tests { let _ = std::fs::remove_file(path); } + #[cfg(unix)] + #[test] + fn open_creates_database_with_owner_only_permissions() { + use std::os::unix::fs::PermissionsExt; + + let temp_dir = std::env::temp_dir().join(format!( + "feder-database-permissions-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time after unix epoch") + .as_nanos() + )); + std::fs::create_dir(&temp_dir).expect("create temporary directory"); + let path = temp_dir.join("store.sqlite3"); + + let store = SqliteStore::open(&path).expect("open SQLite store"); + + let mode = std::fs::metadata(&path) + .expect("read database metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + + drop(store); + std::fs::remove_dir_all(temp_dir).expect("remove temporary directory"); + } + + #[cfg(unix)] + #[test] + fn open_restricts_existing_database_permissions() { + use std::os::unix::fs::PermissionsExt; + + let temp_dir = std::env::temp_dir().join(format!( + "feder-existing-database-permissions-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time after unix epoch") + .as_nanos() + )); + std::fs::create_dir(&temp_dir).expect("create temporary directory"); + let path = temp_dir.join("store.sqlite3"); + std::fs::write(&path, []).expect("create permissive database file"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)) + .expect("make database file permissive"); + + let store = SqliteStore::open(&path).expect("open SQLite store"); + + let mode = std::fs::metadata(&path) + .expect("read database metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + + drop(store); + std::fs::remove_dir_all(temp_dir).expect("remove temporary directory"); + } + #[test] fn actor_key_pair_roundtrips_for_actor() { let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); diff --git a/examples/single-user-server/README.md b/examples/single-user-server/README.md index b0a3297..e5527cc 100644 --- a/examples/single-user-server/README.md +++ b/examples/single-user-server/README.md @@ -7,24 +7,12 @@ Demo app using `feder-runtime-server` with one hardcoded local actor. Run --- -On Unix shells: +The example currently targets Linux: ~~~~ sh RUST_LOG=info cargo run -p single-user-server ~~~~ -On PowerShell: - -~~~~ powershell -$env:RUST_LOG = "info"; cargo run -p single-user-server -~~~~ - -On cmd.exe: - -~~~~ bat -set RUST_LOG=info && cargo run -p single-user-server -~~~~ - The demo actor is: ~~~~ text From fe967fe0871bedfba9e6035df9a3d1a533c35754 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 29 Jul 2026 01:37:58 +0900 Subject: [PATCH 38/72] Continue delivery after actor resolution failures Resolve direct recipients independently so an unavailable actor does not prevent delivery to recipients that resolve successfully. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/src/operation.rs | 35 ++++++---- .../tests/cases/operation.rs | 69 ++++++++++++++++++- 2 files changed, 90 insertions(+), 14 deletions(-) diff --git a/crates/feder-runtime-server/src/operation.rs b/crates/feder-runtime-server/src/operation.rs index b14a6c3..f2934cc 100644 --- a/crates/feder-runtime-server/src/operation.rs +++ b/crates/feder-runtime-server/src/operation.rs @@ -17,13 +17,15 @@ use std::collections::HashSet; use feder_core::{Action, HandleResult, Input, Recipients, SendActivity, UserCreateNote}; -use crate::{Error, app::AppState, storage::RuntimeStore}; +use crate::{Error, actor::ActorResolveError, app::AppState, storage::RuntimeStore}; impl AppState { /// Create, persist, and deliver a Note initiated by the local application. /// - /// Persistence occurs before delivery. If delivery fails, this returns an - /// error while the created Note remains available from the runtime store. + /// Persistence occurs before delivery. Recipient resolution and delivery + /// continue independently after individual failures. If any attempt fails, + /// this returns an error while the created Note remains available from the + /// runtime store and successful deliveries remain completed. pub async fn create_note(&self, input: UserCreateNote) -> Result { self.handle_input(Input::UserCreateNote(input)).await } @@ -40,9 +42,13 @@ impl AppState { .map_err(|_| Error::StorageStateUnavailable)?; store.persist_actions(&result.actions)?; }; - let deliveries = self.resolve_outbound_deliveries(&result.actions).await?; + let (deliveries, actor_resolve_error) = + self.resolve_outbound_deliveries(&result.actions).await?; self.activity_sender.send_actions(&deliveries).await?; + if let Some(error) = actor_resolve_error { + return Err(error.into()); + } Ok(result) } @@ -50,8 +56,9 @@ impl AppState { async fn resolve_outbound_deliveries( &self, actions: &[Action], - ) -> Result, Error> { + ) -> Result<(Vec, Option), Error> { let mut resolved = Vec::new(); + let mut first_actor_resolve_error = None; for action in actions { if let Action::SendActivity(send) = action { @@ -77,17 +84,21 @@ impl AppState { } } Recipients::Actor(actor_id) => { - let actor = self.actor_resolver.resolve(actor_id).await?; - - resolved.push(SendActivity { - activity: send.activity.clone(), - recipients: Recipients::Inbox(actor.inbox), - }) + match self.actor_resolver.resolve(actor_id).await { + Ok(actor) => resolved.push(SendActivity { + activity: send.activity.clone(), + recipients: Recipients::Inbox(actor.inbox), + }), + Err(error) if first_actor_resolve_error.is_none() => { + first_actor_resolve_error = Some(error); + } + Err(_) => {} + } } } } } - Ok(resolved) + Ok((resolved, first_actor_resolve_error)) } } diff --git a/crates/feder-runtime-server/tests/cases/operation.rs b/crates/feder-runtime-server/tests/cases/operation.rs index cb54fc8..87291f5 100644 --- a/crates/feder-runtime-server/tests/cases/operation.rs +++ b/crates/feder-runtime-server/tests/cases/operation.rs @@ -13,10 +13,17 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use axum::http::StatusCode; +use axum::{ + Json, Router, + http::{StatusCode, header}, + routing::get, +}; use feder_core::{Action, Object, Recipients, StoreFollower, UserCreateNote}; -use feder_runtime_server::{Error, config::StorageConfig, send::SendError, storage::RuntimeStore}; +use feder_runtime_server::{ + Error, actor::ActorResolveError, config::StorageConfig, send::SendError, storage::RuntimeStore, +}; use feder_vocab::{Actor, Iri, Reference}; +use tokio::task::JoinHandle; use crate::common::{spawn_inbox_server, temporary_database_path, test_app_state, test_config}; @@ -56,6 +63,39 @@ fn store_follower(state: &feder_runtime_server::AppState, remote_actor_id: &str, .expect("persist follower"); } +async fn spawn_actor_server(inbox: &str) -> (Iri, Iri, JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind actor server"); + let address = listener.local_addr().expect("actor server address"); + let actor_id = iri(&format!("http://{address}/users/bob")); + let missing_actor_id = iri(&format!("http://{address}/users/missing")); + let actor = Actor::person( + actor_id.clone(), + iri(inbox), + iri(&format!("http://{address}/users/bob/outbox")), + ); + let app = Router::new().route( + "/users/bob", + get(move || { + let actor = actor.clone(); + async move { + ( + [(header::CONTENT_TYPE, "application/activity+json")], + Json(actor), + ) + } + }), + ); + let task = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("serve actor endpoint"); + }); + + (actor_id, missing_actor_id, task) +} + #[tokio::test] async fn create_note_persists_and_delivers_the_core_actions() { let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; @@ -127,6 +167,31 @@ async fn create_note_delivers_to_each_persisted_follower() { carol_server.abort(); } +#[tokio::test] +async fn create_note_delivers_to_resolvable_actor_when_another_actor_cannot_be_resolved() { + let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; + let (actor_id, missing_actor_id, actor_server) = spawn_actor_server(&inbox).await; + let state = test_app_state(test_config()).expect("build app state"); + let mut input = create_note_input(); + input.to = feder_vocab::References::one(missing_actor_id); + input.cc = feder_vocab::References::one(actor_id); + + let result = state.create_note(input).await; + + assert!(matches!( + result, + Err(Error::ActorResolver( + ActorResolveError::UnsuccessfulStatus { .. } + )) + )); + requests + .recv() + .await + .expect("receive delivery for resolvable actor"); + actor_server.abort(); + inbox_server.abort(); +} + #[tokio::test] async fn create_note_keeps_the_persisted_object_when_delivery_fails() { let (inbox, mut requests, inbox_server) = From b13d924ca5d35bd75168eb9a1ea7bcd019e3b79f Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 29 Jul 2026 01:47:42 +0900 Subject: [PATCH 39/72] Deduplicate inboxes across outbound recipients Track resolved inboxes across follower, direct actor, and pre-resolved recipients so each inbox receives an activity only once. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/src/operation.rs | 20 +++++++++----- .../tests/cases/operation.rs | 26 ++++++++++++++++++- .../feder-runtime-server/tests/common/mod.rs | 2 +- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/crates/feder-runtime-server/src/operation.rs b/crates/feder-runtime-server/src/operation.rs index f2934cc..5dcd24d 100644 --- a/crates/feder-runtime-server/src/operation.rs +++ b/crates/feder-runtime-server/src/operation.rs @@ -59,13 +59,17 @@ impl AppState { ) -> Result<(Vec, Option), Error> { let mut resolved = Vec::new(); let mut first_actor_resolve_error = None; + let mut seen_inboxes = HashSet::new(); for action in actions { if let Action::SendActivity(send) = action { match &send.recipients { - Recipients::Inbox(_) => resolved.push(send.clone()), + Recipients::Inbox(inbox) => { + if seen_inboxes.insert(inbox.clone()) { + resolved.push(send.clone()); + } + } Recipients::Followers(actor_id) => { - let mut seen_inboxes = HashSet::new(); let recipients = { let store = self .store @@ -85,10 +89,14 @@ impl AppState { } Recipients::Actor(actor_id) => { match self.actor_resolver.resolve(actor_id).await { - Ok(actor) => resolved.push(SendActivity { - activity: send.activity.clone(), - recipients: Recipients::Inbox(actor.inbox), - }), + Ok(actor) => { + if seen_inboxes.insert(actor.inbox.clone()) { + resolved.push(SendActivity { + activity: send.activity.clone(), + recipients: Recipients::Inbox(actor.inbox), + }); + } + } Err(error) if first_actor_resolve_error.is_none() => { first_actor_resolve_error = Some(error); } diff --git a/crates/feder-runtime-server/tests/cases/operation.rs b/crates/feder-runtime-server/tests/cases/operation.rs index 87291f5..6d7f80c 100644 --- a/crates/feder-runtime-server/tests/cases/operation.rs +++ b/crates/feder-runtime-server/tests/cases/operation.rs @@ -23,7 +23,7 @@ use feder_runtime_server::{ Error, actor::ActorResolveError, config::StorageConfig, send::SendError, storage::RuntimeStore, }; use feder_vocab::{Actor, Iri, Reference}; -use tokio::task::JoinHandle; +use tokio::{sync::mpsc::error::TryRecvError, task::JoinHandle}; use crate::common::{spawn_inbox_server, temporary_database_path, test_app_state, test_config}; @@ -167,6 +167,30 @@ async fn create_note_delivers_to_each_persisted_follower() { carol_server.abort(); } +#[tokio::test] +async fn create_note_delivers_once_when_direct_actor_is_also_a_follower() { + let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; + let (actor_id, _missing_actor_id, actor_server) = spawn_actor_server(&inbox).await; + let state = test_app_state(test_config()).expect("build app state"); + store_follower(&state, actor_id.as_str(), &inbox); + let mut input = create_note_input(); + input.to = feder_vocab::References::one( + state + .local_actor + .followers + .clone() + .expect("local actor has followers collection"), + ); + input.cc = feder_vocab::References::one(actor_id); + + state.create_note(input).await.expect("create note"); + + requests.recv().await.expect("receive Create delivery"); + assert!(matches!(requests.try_recv(), Err(TryRecvError::Empty))); + actor_server.abort(); + inbox_server.abort(); +} + #[tokio::test] async fn create_note_delivers_to_resolvable_actor_when_another_actor_cannot_be_resolved() { let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; diff --git a/crates/feder-runtime-server/tests/common/mod.rs b/crates/feder-runtime-server/tests/common/mod.rs index f837bda..399fc04 100644 --- a/crates/feder-runtime-server/tests/common/mod.rs +++ b/crates/feder-runtime-server/tests/common/mod.rs @@ -43,7 +43,7 @@ pub struct RecordedRequest { pub async fn spawn_inbox_server( response_status: StatusCode, ) -> (String, mpsc::Receiver, JoinHandle<()>) { - let (sender, receiver) = mpsc::channel(1); + let (sender, receiver) = mpsc::channel(2); let app = Router::new().route( "/inbox", post(move |headers: HeaderMap, uri: Uri, body: Bytes| { From 61449c03ed91837fa581a9e5be454571826327ae Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 29 Jul 2026 02:35:22 +0900 Subject: [PATCH 40/72] Avoid duplicate delivery to direct followers Expand follower recipients first and track their actor IDs so directly addressed followers are not also sent to their personal inboxes. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/src/operation.rs | 83 +++++++++++-------- .../tests/cases/operation.rs | 53 +++++++++--- 2 files changed, 92 insertions(+), 44 deletions(-) diff --git a/crates/feder-runtime-server/src/operation.rs b/crates/feder-runtime-server/src/operation.rs index 5dcd24d..59c6471 100644 --- a/crates/feder-runtime-server/src/operation.rs +++ b/crates/feder-runtime-server/src/operation.rs @@ -59,49 +59,66 @@ impl AppState { ) -> Result<(Vec, Option), Error> { let mut resolved = Vec::new(); let mut first_actor_resolve_error = None; + let mut covered_actor_ids = HashSet::new(); let mut seen_inboxes = HashSet::new(); + // Expand followers first so direct recipients already covered by + // follower delivery are not also sent to their personal inbox. for action in actions { - if let Action::SendActivity(send) = action { - match &send.recipients { - Recipients::Inbox(inbox) => { - if seen_inboxes.insert(inbox.clone()) { - resolved.push(send.clone()); - } + let Action::SendActivity(send) = action else { + continue; + }; + let Recipients::Followers(actor_id) = &send.recipients else { + continue; + }; + let recipients = { + let store = self + .store + .lock() + .map_err(|_| Error::StorageStateUnavailable)?; + store.list_follower_recipients(actor_id)? + }; + for recipient in recipients { + covered_actor_ids.insert(recipient.actor_id); + let inbox = recipient.shared_inbox.unwrap_or(recipient.inbox); + if seen_inboxes.insert(inbox.clone()) { + resolved.push(SendActivity { + activity: send.activity.clone(), + recipients: Recipients::Inbox(inbox), + }); + } + } + } + + for action in actions { + let Action::SendActivity(send) = action else { + continue; + }; + match &send.recipients { + Recipients::Inbox(inbox) => { + if seen_inboxes.insert(inbox.clone()) { + resolved.push(send.clone()); } - Recipients::Followers(actor_id) => { - let recipients = { - let store = self - .store - .lock() - .map_err(|_| Error::StorageStateUnavailable)?; - store.list_follower_recipients(actor_id)? - }; - for recipient in recipients { - let inbox = recipient.shared_inbox.unwrap_or(recipient.inbox); - if seen_inboxes.insert(inbox.clone()) { + } + Recipients::Followers(_) => {} + Recipients::Actor(actor_id) => { + if covered_actor_ids.contains(actor_id) { + continue; + } + match self.actor_resolver.resolve(actor_id).await { + Ok(actor) => { + covered_actor_ids.insert(actor_id.clone()); + if seen_inboxes.insert(actor.inbox.clone()) { resolved.push(SendActivity { activity: send.activity.clone(), - recipients: Recipients::Inbox(inbox), + recipients: Recipients::Inbox(actor.inbox), }); } } - } - Recipients::Actor(actor_id) => { - match self.actor_resolver.resolve(actor_id).await { - Ok(actor) => { - if seen_inboxes.insert(actor.inbox.clone()) { - resolved.push(SendActivity { - activity: send.activity.clone(), - recipients: Recipients::Inbox(actor.inbox), - }); - } - } - Err(error) if first_actor_resolve_error.is_none() => { - first_actor_resolve_error = Some(error); - } - Err(_) => {} + Err(error) if first_actor_resolve_error.is_none() => { + first_actor_resolve_error = Some(error); } + Err(_) => {} } } } diff --git a/crates/feder-runtime-server/tests/cases/operation.rs b/crates/feder-runtime-server/tests/cases/operation.rs index 6d7f80c..ca25836 100644 --- a/crates/feder-runtime-server/tests/cases/operation.rs +++ b/crates/feder-runtime-server/tests/cases/operation.rs @@ -22,7 +22,7 @@ use feder_core::{Action, Object, Recipients, StoreFollower, UserCreateNote}; use feder_runtime_server::{ Error, actor::ActorResolveError, config::StorageConfig, send::SendError, storage::RuntimeStore, }; -use feder_vocab::{Actor, Iri, Reference}; +use feder_vocab::{Actor, Endpoints, Iri, Reference}; use tokio::{sync::mpsc::error::TryRecvError, task::JoinHandle}; use crate::common::{spawn_inbox_server, temporary_database_path, test_app_state, test_config}; @@ -46,12 +46,24 @@ fn create_note_input() -> UserCreateNote { } fn store_follower(state: &feder_runtime_server::AppState, remote_actor_id: &str, inbox: &str) { + store_follower_with_shared_inbox(state, remote_actor_id, inbox, None); +} + +fn store_follower_with_shared_inbox( + state: &feder_runtime_server::AppState, + remote_actor_id: &str, + inbox: &str, + shared_inbox: Option<&str>, +) { let remote_actor_id = iri(remote_actor_id); - let remote_actor = Actor::person( + let mut remote_actor = Actor::person( remote_actor_id.clone(), iri(inbox), iri(&format!("{remote_actor_id}/outbox")), ); + remote_actor.endpoints = shared_inbox.map(|shared_inbox| Endpoints { + shared_inbox: Some(iri(shared_inbox)), + }); state .store .lock() @@ -168,27 +180,46 @@ async fn create_note_delivers_to_each_persisted_follower() { } #[tokio::test] -async fn create_note_delivers_once_when_direct_actor_is_also_a_follower() { - let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; - let (actor_id, _missing_actor_id, actor_server) = spawn_actor_server(&inbox).await; +async fn create_note_delivers_once_to_shared_inbox_when_direct_actor_is_also_a_follower() { + let (personal_inbox, mut personal_requests, personal_inbox_server) = + spawn_inbox_server(StatusCode::ACCEPTED).await; + let (shared_inbox, mut shared_requests, shared_inbox_server) = + spawn_inbox_server(StatusCode::ACCEPTED).await; + let (actor_id, _missing_actor_id, actor_server) = spawn_actor_server(&personal_inbox).await; let state = test_app_state(test_config()).expect("build app state"); - store_follower(&state, actor_id.as_str(), &inbox); + store_follower_with_shared_inbox( + &state, + actor_id.as_str(), + &personal_inbox, + Some(&shared_inbox), + ); let mut input = create_note_input(); - input.to = feder_vocab::References::one( + input.to = feder_vocab::References::one(actor_id); + input.cc = feder_vocab::References::one( state .local_actor .followers .clone() .expect("local actor has followers collection"), ); - input.cc = feder_vocab::References::one(actor_id); state.create_note(input).await.expect("create note"); - requests.recv().await.expect("receive Create delivery"); - assert!(matches!(requests.try_recv(), Err(TryRecvError::Empty))); + shared_requests + .recv() + .await + .expect("receive shared inbox delivery"); + assert!(matches!( + shared_requests.try_recv(), + Err(TryRecvError::Empty) + )); + assert!(matches!( + personal_requests.try_recv(), + Err(TryRecvError::Empty) + )); actor_server.abort(); - inbox_server.abort(); + shared_inbox_server.abort(); + personal_inbox_server.abort(); } #[tokio::test] From 5180800c7693560871934117d164ed737cc2a41e Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 29 Jul 2026 02:40:28 +0900 Subject: [PATCH 41/72] Parse inbox Content-Type as a media type Assisted-by: Codex:GPT-5.6-sol --- crates/feder-runtime-server/src/inbox.rs | 8 ++- .../feder-runtime-server/tests/cases/inbox.rs | 67 ++++++++++++++----- 2 files changed, 55 insertions(+), 20 deletions(-) diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index 252c446..c46434f 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -30,6 +30,7 @@ use feder_core::{ http_signatures::{create_sha256_digest_header, verify_draft_cavage}, }; use feder_vocab::{Actor, Follow, Iri, Reference, Undo}; +use mime::Mime; use serde_json::{Value, from_slice, from_value}; use crate::config::InboxAuthPolicy; @@ -38,6 +39,7 @@ use crate::{Error, app::AppState}; const MAX_SIGNATURE_AGE: Duration = Duration::from_secs(65 * 60); const MAX_CLOCK_SKEW: Duration = Duration::from_secs(60 * 60); +const ACTIVITYPUB_CONTENT_TYPES: &[&str] = &["application/activity+json", "application/ld+json"]; pub struct InboxRequest { pub username: String, @@ -316,10 +318,10 @@ pub async fn inbox( let content_type = headers .get(CONTENT_TYPE) .and_then(|value| value.to_str().ok()) - .unwrap_or(""); + .and_then(|value| value.parse::().ok()); - if !content_type.starts_with("application/activity+json") - && !content_type.starts_with("application/ld+json") + if !content_type + .is_some_and(|media_type| ACTIVITYPUB_CONTENT_TYPES.contains(&media_type.essence_str())) { return Err(StatusCode::UNSUPPORTED_MEDIA_TYPE); } diff --git a/crates/feder-runtime-server/tests/cases/inbox.rs b/crates/feder-runtime-server/tests/cases/inbox.rs index 3952ef6..c409a80 100644 --- a/crates/feder-runtime-server/tests/cases/inbox.rs +++ b/crates/feder-runtime-server/tests/cases/inbox.rs @@ -624,25 +624,58 @@ async fn rejects_unknown_inbox_actor() { #[tokio::test] async fn rejects_unsupported_content_type() { - let state = test_app_state(test_config()).expect("build app state"); - let response = post_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", + for content_type in [ "application/json", - follow_body(), - ) - .await; + "application/activity+jsonp", + "not a media type", + ] { + let state = test_app_state(test_config()).expect("build app state"); + let response = post_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + content_type, + follow_body(), + ) + .await; - assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); - assert!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); + assert_eq!( + response.status(), + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "Content-Type: {content_type}" + ); + assert!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); + } +} + +#[tokio::test] +async fn accepts_case_insensitive_content_type_with_parameters() { + for content_type in [ + "Application/Activity+JSON; Charset=UTF-8", + "Application/LD+JSON; Profile=\"https://www.w3.org/ns/activitystreams\"", + ] { + let state = test_app_state(test_config()).expect("build app state"); + let response = post_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + content_type, + "{not json", + ) + .await; + + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "Content-Type: {content_type}" + ); + } } #[tokio::test] From cea6b890319bfb842cfe4c7dfaaa56f08d5695c3 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 29 Jul 2026 14:20:01 +0900 Subject: [PATCH 42/72] Bind inbox signatures to the configured host Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/src/inbox.rs | 95 ++++++++++++++++++- .../feder-runtime-server/tests/cases/inbox.rs | 50 +++++++++- 2 files changed, 143 insertions(+), 2 deletions(-) diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index c46434f..0594c0d 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -21,7 +21,11 @@ use std::{ use axum::{ body::Bytes, extract::{Path, State}, - http::{HeaderMap, Method, StatusCode, Uri, header::CONTENT_TYPE}, + http::{ + HeaderMap, Method, StatusCode, Uri, + header::{CONTENT_TYPE, HOST}, + uri::Authority, + }, response::{IntoResponse, Response}, }; @@ -116,6 +120,11 @@ async fn verify_signed_request( return Err(StatusCode::UNAUTHORIZED); } + verify_request_host( + &req.headers, + &app_state.handle_host, + app_state.local_actor.inbox.scheme_str(), + )?; verify_request_date(&req.headers)?; verify_request_digest(&req.headers, &req.body)?; @@ -166,6 +175,55 @@ async fn verify_signed_request( Ok(actor) } +fn verify_request_host( + headers: &HeaderMap, + expected_host: &str, + inbox_scheme: &str, +) -> Result<(), StatusCode> { + let signed_host = headers + .get(HOST) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .filter(|authority| !authority.as_str().contains('@')) + .ok_or(StatusCode::UNAUTHORIZED)?; + let expected_host = expected_host + .parse::() + .ok() + .filter(|authority| !authority.as_str().contains('@')) + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + let default_port = if inbox_scheme.eq_ignore_ascii_case("http") { + Some(80) + } else if inbox_scheme.eq_ignore_ascii_case("https") { + Some(443) + } else { + None + }; + let signed_port = effective_port(&signed_host, default_port).ok_or(StatusCode::UNAUTHORIZED)?; + let expected_port = + effective_port(&expected_host, default_port).ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + + if signed_host + .host() + .eq_ignore_ascii_case(expected_host.host()) + && signed_port == expected_port + { + Ok(()) + } else { + Err(StatusCode::UNAUTHORIZED) + } +} + +fn effective_port(authority: &Authority, default_port: Option) -> Option> { + let suffix = authority.as_str().get(authority.host().len()..)?; + if suffix.is_empty() { + Some(default_port) + } else if suffix.starts_with(':') { + authority.port_u16().map(Some) + } else { + None + } +} + fn activity_actor_id(value: &Value) -> Option { let actor = value.get("actor")?; let actor_id = actor @@ -399,3 +457,38 @@ pub async fn inbox( Ok(StatusCode::ACCEPTED.into_response()) } + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + + fn headers_with_host(host: &'static str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(HOST, HeaderValue::from_static(host)); + headers + } + + #[test] + fn request_host_accepts_case_and_default_port_equivalence() { + assert_eq!( + verify_request_host( + &headers_with_host("EXAMPLE.COM:443"), + "example.com", + "https" + ), + Ok(()) + ); + } + + #[test] + fn request_host_rejects_wrong_or_invalid_authorities() { + for host in ["other.example", "example.com:8443", "example.com:99999"] { + assert_eq!( + verify_request_host(&headers_with_host(host), "example.com", "https"), + Err(StatusCode::UNAUTHORIZED), + "Host: {host}" + ); + } + } +} diff --git a/crates/feder-runtime-server/tests/cases/inbox.rs b/crates/feder-runtime-server/tests/cases/inbox.rs index c409a80..55fe467 100644 --- a/crates/feder-runtime-server/tests/cases/inbox.rs +++ b/crates/feder-runtime-server/tests/cases/inbox.rs @@ -205,10 +205,28 @@ async fn post_signed_inbox( key_id: &str, signed_body: &[u8], delivered_body: impl Into, +) -> axum::response::Response { + post_signed_inbox_with_host( + app, + uri, + key_id, + signed_body, + delivered_body, + "127.0.0.1:3000", + ) + .await +} + +async fn post_signed_inbox_with_host( + app: Router, + uri: &str, + key_id: &str, + signed_body: &[u8], + delivered_body: impl Into, + host: &str, ) -> axum::response::Response { let date = httpdate::fmt_http_date(std::time::SystemTime::now()); let digest = create_sha256_digest_header(signed_body); - let host = "local.example"; let headers = [ ("content-type", "application/activity+json"), ("date", date.as_str()), @@ -361,6 +379,36 @@ async fn verifies_signed_id_only_follow() { actor_server.abort(); } +#[tokio::test] +async fn signed_follow_rejects_host_for_another_authority() { + let (actor_id, _requests, actor_server) = spawn_actor_server().await; + let mut config = test_config(); + config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; + let state = test_app_state(config).expect("build app state"); + let body = id_only_follow_body(&actor_id); + let response = post_signed_inbox_with_host( + router_with_state(state.clone()), + "/users/alice/inbox", + &format!("{actor_id}#main-key"), + &body, + body.clone(), + "other.example", + ) + .await; + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); + actor_server.abort(); +} + #[tokio::test] async fn verifies_signed_follow_with_independent_key_id() { let (actor_id, key_id, mut requests, actor_server) = From 43340eeabf429554df15d11636f448f3272944c6 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 29 Jul 2026 14:46:18 +0900 Subject: [PATCH 43/72] Return 202 Accepted before Follow-specific deserialization Assisted-by: Codex:gpt-5.6-sol --- crates/feder-runtime-server/src/inbox.rs | 8 +++++ .../feder-runtime-server/tests/cases/inbox.rs | 36 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index 0594c0d..47fcc07 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -420,6 +420,14 @@ pub async fn inbox( Input::received_follow(follow, accept_id) } Some("Undo") => { + if value + .get("object") + .and_then(|object| object.get("type")) + .and_then(Value::as_str) + != Some("Follow") + { + return Ok(StatusCode::ACCEPTED.into_response()); + } let undo: Undo = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; let Reference::Object(follow) = &undo.object else { return Ok(StatusCode::ACCEPTED.into_response()); diff --git a/crates/feder-runtime-server/tests/cases/inbox.rs b/crates/feder-runtime-server/tests/cases/inbox.rs index 55fe467..4192167 100644 --- a/crates/feder-runtime-server/tests/cases/inbox.rs +++ b/crates/feder-runtime-server/tests/cases/inbox.rs @@ -783,6 +783,42 @@ async fn ignores_unsupported_activity_without_mutating_core() { ); } +#[tokio::test] +async fn ignores_undo_of_unsupported_activity_without_mutating_core() { + let state = test_app_state(test_config()).expect("build app state"); + let body = serde_json::to_vec(&json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Undo", + "id": "https://remote.example/activities/undo-like-1", + "actor": "https://remote.example/users/bob", + "object": { + "type": "Like", + "id": "https://remote.example/activities/like-1", + "actor": "https://remote.example/users/bob", + "object": "http://127.0.0.1:3000/users/alice/notes/1" + } + })) + .expect("serialize Undo Like"); + let response = post_inbox( + router_with_state(state.clone()), + "/users/alice/inbox", + "application/activity+json", + body, + ) + .await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert!( + state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); +} + #[tokio::test] async fn rejects_oversized_inbox_body() { let response = post_inbox( From f56aaaf6af2b41daf773c357aa9ff7f7e3e81128 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 29 Jul 2026 22:56:26 +0900 Subject: [PATCH 44/72] Add temporary crates for refactoring Assisted-by: Codex-gpt-5.6-sol --- Cargo.lock | 13 ++++++++++++ Cargo.toml | 4 ++++ crates/ref-feder-core/Cargo.toml | 16 +++++++++++++++ crates/ref-feder-core/src/lib.rs | 23 ++++++++++++++++++++++ crates/ref-feder-runtime-server/Cargo.toml | 16 +++++++++++++++ crates/ref-feder-runtime-server/src/lib.rs | 22 +++++++++++++++++++++ 6 files changed, 94 insertions(+) create mode 100644 crates/ref-feder-core/Cargo.toml create mode 100644 crates/ref-feder-core/src/lib.rs create mode 100644 crates/ref-feder-runtime-server/Cargo.toml create mode 100644 crates/ref-feder-runtime-server/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 3776a94..62ae866 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1111,6 +1111,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ "rand_core 0.10.1", + +[[package]] +name = "ref-feder-core" +version = "0.1.0" +dependencies = [ + "feder-vocab", +] + +[[package]] +name = "ref-feder-runtime-server" +version = "0.1.0" +dependencies = [ + "ref-feder-core", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 492eb2c..dbb0d67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,8 @@ members = [ "crates/feder-core", "crates/feder-vocab", "crates/feder-runtime-server", + "crates/ref-feder-core", + "crates/ref-feder-runtime-server", "examples/single-user-server", ] resolver = "3" @@ -22,6 +24,8 @@ base64 = { version = "0.22.1", default-features = false, features = ["alloc"] } feder-core = { version = "0.1.0", path = "crates/feder-core" } feder-runtime-server = { version = "0.1.0", path = "crates/feder-runtime-server" } feder-vocab = { version = "0.1.0", path = "crates/feder-vocab" } +ref-feder-core = { version = "0.1.0", path = "crates/ref-feder-core" } +ref-feder-runtime-server = { version = "0.1.0", path = "crates/ref-feder-runtime-server" } iri-string = { version = "0.7.12", default-features = false, features = ["alloc", "serde"] } rand_chacha = { version = "0.3.1", default-features = false } rand_core = { version = "0.6.4", features = ["getrandom"] } diff --git a/crates/ref-feder-core/Cargo.toml b/crates/ref-feder-core/Cargo.toml new file mode 100644 index 0000000..289c4e5 --- /dev/null +++ b/crates/ref-feder-core/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "ref-feder-core" +description = "Experimental reference implementation of Feder's protocol core." +publish = false +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[dependencies] +feder-vocab.workspace = true + +[lints] +workspace = true diff --git a/crates/ref-feder-core/src/lib.rs b/crates/ref-feder-core/src/lib.rs new file mode 100644 index 0000000..78aa673 --- /dev/null +++ b/crates/ref-feder-core/src/lib.rs @@ -0,0 +1,23 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Experimental reference implementation of Feder's protocol core. +//! +//! This crate develops the replacement architecture alongside `feder-core`. +//! Its API is intentionally unstable until the core ownership boundary has +//! been proven against the existing runtime and Federog. +#![no_std] + +pub use feder_vocab as vocab; diff --git a/crates/ref-feder-runtime-server/Cargo.toml b/crates/ref-feder-runtime-server/Cargo.toml new file mode 100644 index 0000000..f3e782b --- /dev/null +++ b/crates/ref-feder-runtime-server/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "ref-feder-runtime-server" +description = "Experimental reference implementation of Feder's server runtime." +publish = false +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[dependencies] +ref-feder-core.workspace = true + +[lints] +workspace = true diff --git a/crates/ref-feder-runtime-server/src/lib.rs b/crates/ref-feder-runtime-server/src/lib.rs new file mode 100644 index 0000000..5c79834 --- /dev/null +++ b/crates/ref-feder-runtime-server/src/lib.rs @@ -0,0 +1,22 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Experimental reference implementation of Feder's server runtime. +//! +//! This crate develops runtime orchestration against `ref-feder-core` while +//! the production `feder-runtime-server` remains operational. Its API is +//! intentionally unstable during the architecture refactoring. + +pub use ref_feder_core as core; From 8f3f20fcb18d223f9091bef5c7195600eddd3e92 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Thu, 30 Jul 2026 17:27:33 +0900 Subject: [PATCH 45/72] Add new architecture applied crates Add new minimal core and runtime architecture Add actor endpoint with generics Add negotiation features Assisted-by: Codex:gpt-5.6-sol --- Cargo.lock | 50 ++++++--- crates/feder-core/src/lib.rs | 1 + crates/ref-feder-core/src/actor.rs | 29 +++++ crates/ref-feder-core/src/lib.rs | 5 + crates/ref-feder-runtime-server/Cargo.toml | 4 + crates/ref-feder-runtime-server/src/lib.rs | 95 +++++++++++++++- .../src/negotiation.rs | 104 ++++++++++++++++++ cspell.json | 6 + 8 files changed, 276 insertions(+), 18 deletions(-) create mode 100644 crates/ref-feder-core/src/actor.rs create mode 100644 crates/ref-feder-runtime-server/src/negotiation.rs create mode 100644 cspell.json diff --git a/Cargo.lock b/Cargo.lock index 62ae866..232c703 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -271,7 +271,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -714,7 +714,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.117", ] [[package]] @@ -733,7 +733,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1111,6 +1111,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ "rand_core 0.10.1", +] [[package]] name = "ref-feder-core" @@ -1123,7 +1124,11 @@ dependencies = [ name = "ref-feder-runtime-server" version = "0.1.0" dependencies = [ + "axum", + "feder-vocab", + "mime", "ref-feder-core", + "thiserror", ] [[package]] @@ -1414,7 +1419,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1589,6 +1594,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -1606,27 +1622,27 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -1686,7 +1702,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1765,7 +1781,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1934,7 +1950,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -2098,7 +2114,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -2119,7 +2135,7 @@ checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2139,7 +2155,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -2179,7 +2195,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 449e123..18f0d15 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -83,6 +83,7 @@ impl FederConfig { } /// In-memory state used by portable core flows. +// FIXME: Massive heap growth detected. #[derive(Clone, Debug, Eq, PartialEq)] pub struct FederState { local_actor: vocab::Actor, diff --git a/crates/ref-feder-core/src/actor.rs b/crates/ref-feder-core/src/actor.rs new file mode 100644 index 0000000..85b80fd --- /dev/null +++ b/crates/ref-feder-core/src/actor.rs @@ -0,0 +1,29 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use feder_vocab::Actor; + +pub trait ActorProvider { + type Error; + + fn find_actor(&self, identifier: &str) -> Result, Self::Error>; +} + +pub fn find_actor(runtime: &R, identifier: &str) -> Result, R::Error> +where + R: ActorProvider, +{ + runtime.find_actor(identifier) +} diff --git a/crates/ref-feder-core/src/lib.rs b/crates/ref-feder-core/src/lib.rs index 78aa673..835b32c 100644 --- a/crates/ref-feder-core/src/lib.rs +++ b/crates/ref-feder-core/src/lib.rs @@ -21,3 +21,8 @@ #![no_std] pub use feder_vocab as vocab; + +pub mod actor; + +#[derive(Debug, Default)] +pub struct FederCore; diff --git a/crates/ref-feder-runtime-server/Cargo.toml b/crates/ref-feder-runtime-server/Cargo.toml index f3e782b..1ca51d1 100644 --- a/crates/ref-feder-runtime-server/Cargo.toml +++ b/crates/ref-feder-runtime-server/Cargo.toml @@ -10,7 +10,11 @@ homepage.workspace = true repository.workspace = true [dependencies] +axum = "=0.8" +feder-vocab.workspace = true +mime = "0.3.17" ref-feder-core.workspace = true +thiserror = "2.0.19" [lints] workspace = true diff --git a/crates/ref-feder-runtime-server/src/lib.rs b/crates/ref-feder-runtime-server/src/lib.rs index 5c79834..7276881 100644 --- a/crates/ref-feder-runtime-server/src/lib.rs +++ b/crates/ref-feder-runtime-server/src/lib.rs @@ -19,4 +19,97 @@ //! the production `feder-runtime-server` remains operational. Its API is //! intentionally unstable during the architecture refactoring. -pub use ref_feder_core as core; +use std::sync::Arc; + +use axum::{ + Json, Router, + extract::{Path, State}, + http::{HeaderMap, StatusCode, header}, + response::{IntoResponse, Response}, + routing::get, +}; +use feder_vocab::Actor; + +pub use ref_feder_core::actor::ActorProvider; +use ref_feder_core::actor::find_actor; + +use crate::negotiation::accepts_activitypub; + +mod negotiation; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("failed to bind server socket")] + Bind(#[source] std::io::Error), + + #[error("server failed")] + Serve(#[source] std::io::Error), + // #[error("storage failed")] + // Storage(#[from] crate::storage::StoreError), +} + +pub struct FederServer { + actors: Arc, +} + +impl Clone for FederServer { + fn clone(&self) -> Self { + Self { + actors: Arc::clone(&self.actors), + } + } +} + +impl FederServer { + pub fn new(actors: A) -> Self { + Self { + actors: Arc::new(actors), + } + } +} + +impl ActorProvider for FederServer +where + A: ActorProvider, +{ + type Error = A::Error; + + fn find_actor(&self, identifier: &str) -> Result, Self::Error> { + self.actors.find_actor(identifier) + } +} + +pub async fn actor( + State(server): State>, + Path(identifier): Path, + headers: HeaderMap, +) -> Result +where + A: ActorProvider, +{ + if !accepts_activitypub(&headers) { + return Ok(([(header::VARY, "Accept")], StatusCode::NOT_ACCEPTABLE).into_response()); + } + + let actor = find_actor(&server, &identifier) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + Ok(( + [ + (header::CONTENT_TYPE, "application/activity+json"), + (header::VARY, "Accept"), + ], + Json(actor), + ) + .into_response()) +} + +pub fn build_router(server: FederServer) -> Router +where + A: ActorProvider + Send + Sync + 'static, +{ + Router::new() + .route("/users/{identifier}", get(actor::)) + .with_state(server) +} diff --git a/crates/ref-feder-runtime-server/src/negotiation.rs b/crates/ref-feder-runtime-server/src/negotiation.rs new file mode 100644 index 0000000..143905f --- /dev/null +++ b/crates/ref-feder-runtime-server/src/negotiation.rs @@ -0,0 +1,104 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use axum::http::{HeaderMap, header::ACCEPT}; +use mime::Mime; + +const ACTIVITYPUB_MEDIA_TYPES: &[&str] = &[ + "application/activity+json", + "application/ld+json", + "application/json", +]; +const HTML_MEDIA_TYPES: &[&str] = &["text/html", "application/xhtml+xml"]; + +struct MediaRange { + media_type: Mime, + quality: u16, + order: usize, +} + +#[derive(Clone, Copy)] +struct Preference { + quality: u16, + order: usize, +} + +pub(crate) fn accepts_activitypub(headers: &HeaderMap) -> bool { + let ranges = headers + .get_all(ACCEPT) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .filter_map(|value| value.trim().parse::().ok()) + .enumerate() + .filter_map(|(order, media_range)| { + let quality = media_range + .get_param("q") + .map_or(Some(1000), |value| parse_quality(value.as_str()))?; + (quality > 0).then_some(MediaRange { + media_type: media_range, + quality, + order, + }) + }) + .collect::>(); + + let activitypub = preferred(ACTIVITYPUB_MEDIA_TYPES, &ranges); + let html = preferred(HTML_MEDIA_TYPES, &ranges); + + match (activitypub, html) { + (Some(activitypub), Some(html)) => prefers(activitypub, html), + (Some(_), None) => true, + _ => false, + } +} + +fn preferred(media_types: &[&str], ranges: &[MediaRange]) -> Option { + ranges + .iter() + .filter(|range| media_types.contains(&range.media_type.essence_str())) + .map(|range| Preference { + quality: range.quality, + order: range.order, + }) + .reduce(|current, candidate| { + if prefers(candidate, current) { + candidate + } else { + current + } + }) +} + +fn prefers(left: Preference, right: Preference) -> bool { + left.quality > right.quality || (left.quality == right.quality && left.order < right.order) +} + +fn parse_quality(value: &str) -> Option { + let (whole, fraction) = value.split_once('.').unwrap_or((value, "")); + if fraction.len() > 3 || !fraction.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + + match whole { + "0" => { + let padding = 3 - fraction.len(); + let fraction = fraction.parse::().unwrap_or(0); + Some(fraction * 10_u16.pow(u32::try_from(padding).ok()?)) + } + "1" if fraction.bytes().all(|byte| byte == b'0') => Some(1000), + _ => None, + } +} diff --git a/cspell.json b/cspell.json new file mode 100644 index 0000000..5141c51 --- /dev/null +++ b/cspell.json @@ -0,0 +1,6 @@ +{ + "words": [ + "Feder", + "activitypub" + ] +} \ No newline at end of file From 6ecc7c711c789591264d5944bfe82d48df8a9bce Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Thu, 30 Jul 2026 17:38:38 +0900 Subject: [PATCH 46/72] Move actor related features to actor.rs --- crates/ref-feder-runtime-server/src/actor.rs | 63 ++++++++++++++++++++ crates/ref-feder-runtime-server/src/lib.rs | 57 ++---------------- 2 files changed, 67 insertions(+), 53 deletions(-) create mode 100644 crates/ref-feder-runtime-server/src/actor.rs diff --git a/crates/ref-feder-runtime-server/src/actor.rs b/crates/ref-feder-runtime-server/src/actor.rs new file mode 100644 index 0000000..e8f3a71 --- /dev/null +++ b/crates/ref-feder-runtime-server/src/actor.rs @@ -0,0 +1,63 @@ +use axum::{Json, extract::{Path, State}, http::{HeaderMap, StatusCode, header}, response::{IntoResponse, Response}}; +use feder_vocab::Actor; +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// +use ref_feder_core::actor::{ + ActorProvider, + find_actor, +}; + +use crate::{ + FederServer, + negotiation::accepts_activitypub, +}; + +impl ActorProvider for FederServer +where + A: ActorProvider, +{ + type Error = A::Error; + + fn find_actor(&self, identifier: &str) -> Result, Self::Error> { + self.actors.find_actor(identifier) + } +} + +pub async fn actor( + State(server): State>, + Path(identifier): Path, + headers: HeaderMap, +) -> Result +where + A: ActorProvider, +{ + if !accepts_activitypub(&headers) { + return Ok(([(header::VARY, "Accept")], StatusCode::NOT_ACCEPTABLE).into_response()); + } + + let actor = find_actor(&server, &identifier) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + Ok(( + [ + (header::CONTENT_TYPE, "application/activity+json"), + (header::VARY, "Accept"), + ], + Json(actor), + ) + .into_response()) +} diff --git a/crates/ref-feder-runtime-server/src/lib.rs b/crates/ref-feder-runtime-server/src/lib.rs index 7276881..b9496fb 100644 --- a/crates/ref-feder-runtime-server/src/lib.rs +++ b/crates/ref-feder-runtime-server/src/lib.rs @@ -21,21 +21,11 @@ use std::sync::Arc; -use axum::{ - Json, Router, - extract::{Path, State}, - http::{HeaderMap, StatusCode, header}, - response::{IntoResponse, Response}, - routing::get, -}; -use feder_vocab::Actor; - -pub use ref_feder_core::actor::ActorProvider; -use ref_feder_core::actor::find_actor; - -use crate::negotiation::accepts_activitypub; +use axum::{Router, routing::get}; +use ref_feder_core::actor::ActorProvider; mod negotiation; +pub mod actor; #[derive(Debug, thiserror::Error)] pub enum Error { @@ -44,8 +34,6 @@ pub enum Error { #[error("server failed")] Serve(#[source] std::io::Error), - // #[error("storage failed")] - // Storage(#[from] crate::storage::StoreError), } pub struct FederServer { @@ -68,48 +56,11 @@ impl FederServer { } } -impl ActorProvider for FederServer -where - A: ActorProvider, -{ - type Error = A::Error; - - fn find_actor(&self, identifier: &str) -> Result, Self::Error> { - self.actors.find_actor(identifier) - } -} - -pub async fn actor( - State(server): State>, - Path(identifier): Path, - headers: HeaderMap, -) -> Result -where - A: ActorProvider, -{ - if !accepts_activitypub(&headers) { - return Ok(([(header::VARY, "Accept")], StatusCode::NOT_ACCEPTABLE).into_response()); - } - - let actor = find_actor(&server, &identifier) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .ok_or(StatusCode::NOT_FOUND)?; - - Ok(( - [ - (header::CONTENT_TYPE, "application/activity+json"), - (header::VARY, "Accept"), - ], - Json(actor), - ) - .into_response()) -} - pub fn build_router(server: FederServer) -> Router where A: ActorProvider + Send + Sync + 'static, { Router::new() - .route("/users/{identifier}", get(actor::)) + .route("/users/{identifier}", get(actor::actor::)) .with_state(server) } From f6283b266516703b98ea72594e11b3bee5e09027 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Thu, 30 Jul 2026 17:53:12 +0900 Subject: [PATCH 47/72] Change function name Assisted-by: Codex:gpt-5.6-sol --- crates/ref-feder-core/src/actor.rs | 10 +++---- crates/ref-feder-runtime-server/src/actor.rs | 29 ++++++++++---------- crates/ref-feder-runtime-server/src/lib.rs | 6 ++-- 3 files changed, 22 insertions(+), 23 deletions(-) diff --git a/crates/ref-feder-core/src/actor.rs b/crates/ref-feder-core/src/actor.rs index 85b80fd..f9d94ca 100644 --- a/crates/ref-feder-core/src/actor.rs +++ b/crates/ref-feder-core/src/actor.rs @@ -15,15 +15,15 @@ use feder_vocab::Actor; -pub trait ActorProvider { +pub trait ActorDispatcher { type Error; - fn find_actor(&self, identifier: &str) -> Result, Self::Error>; + fn get_actor(&self, identifier: &str) -> Result, Self::Error>; } -pub fn find_actor(runtime: &R, identifier: &str) -> Result, R::Error> +pub fn get_actor(runtime: &R, identifier: &str) -> Result, R::Error> where - R: ActorProvider, + R: ActorDispatcher, { - runtime.find_actor(identifier) + runtime.get_actor(identifier) } diff --git a/crates/ref-feder-runtime-server/src/actor.rs b/crates/ref-feder-runtime-server/src/actor.rs index e8f3a71..b7e1461 100644 --- a/crates/ref-feder-runtime-server/src/actor.rs +++ b/crates/ref-feder-runtime-server/src/actor.rs @@ -1,4 +1,9 @@ -use axum::{Json, extract::{Path, State}, http::{HeaderMap, StatusCode, header}, response::{IntoResponse, Response}}; +use axum::{ + Json, + extract::{Path, State}, + http::{HeaderMap, StatusCode, header}, + response::{IntoResponse, Response}, +}; use feder_vocab::Actor; // Feder: A portable ActivityPub core for many runtimes. // Copyright (C) 2026 Feder contributors @@ -15,24 +20,18 @@ use feder_vocab::Actor; // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . // -use ref_feder_core::actor::{ - ActorProvider, - find_actor, -}; +use ref_feder_core::actor::{ActorDispatcher, get_actor}; -use crate::{ - FederServer, - negotiation::accepts_activitypub, -}; +use crate::{FederServer, negotiation::accepts_activitypub}; -impl ActorProvider for FederServer +impl ActorDispatcher for FederServer where - A: ActorProvider, + A: ActorDispatcher, { type Error = A::Error; - fn find_actor(&self, identifier: &str) -> Result, Self::Error> { - self.actors.find_actor(identifier) + fn get_actor(&self, identifier: &str) -> Result, Self::Error> { + self.actors.get_actor(identifier) } } @@ -42,13 +41,13 @@ pub async fn actor( headers: HeaderMap, ) -> Result where - A: ActorProvider, + A: ActorDispatcher, { if !accepts_activitypub(&headers) { return Ok(([(header::VARY, "Accept")], StatusCode::NOT_ACCEPTABLE).into_response()); } - let actor = find_actor(&server, &identifier) + let actor = get_actor(&server, &identifier) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; diff --git a/crates/ref-feder-runtime-server/src/lib.rs b/crates/ref-feder-runtime-server/src/lib.rs index b9496fb..9bbed5f 100644 --- a/crates/ref-feder-runtime-server/src/lib.rs +++ b/crates/ref-feder-runtime-server/src/lib.rs @@ -22,10 +22,10 @@ use std::sync::Arc; use axum::{Router, routing::get}; -use ref_feder_core::actor::ActorProvider; +pub use ref_feder_core::actor::ActorDispatcher; -mod negotiation; pub mod actor; +mod negotiation; #[derive(Debug, thiserror::Error)] pub enum Error { @@ -58,7 +58,7 @@ impl FederServer { pub fn build_router(server: FederServer) -> Router where - A: ActorProvider + Send + Sync + 'static, + A: ActorDispatcher + Send + Sync + 'static, { Router::new() .route("/users/{identifier}", get(actor::actor::)) From 6eecb47feefe80e660a23c301f84157a14148aeb Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Fri, 31 Jul 2026 15:47:41 +0900 Subject: [PATCH 48/72] Add actor key pair primitives to reference core Port RSA actor key generation and persisted PEM validation into ref-feder-core. Redact private key material from debug output and keep actor dispatch as a minimal capability called directly by the server runtime. Assisted-by: Codex:gpt-5.6-sol --- Cargo.lock | 14 +++ Cargo.toml | 1 + crates/ref-feder-core/Cargo.toml | 2 + crates/ref-feder-core/src/actor.rs | 7 -- crates/ref-feder-core/src/key.rs | 116 +++++++++++++++++++ crates/ref-feder-core/src/lib.rs | 3 + crates/ref-feder-runtime-server/src/actor.rs | 22 ++-- examples/ref-actor-server/Cargo.toml | 16 +++ examples/ref-actor-server/README.md | 25 ++++ examples/ref-actor-server/src/main.rs | 85 ++++++++++++++ 10 files changed, 274 insertions(+), 17 deletions(-) create mode 100644 crates/ref-feder-core/src/key.rs create mode 100644 examples/ref-actor-server/Cargo.toml create mode 100644 examples/ref-actor-server/README.md create mode 100644 examples/ref-actor-server/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 232c703..d7fc661 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1113,11 +1113,25 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "ref-actor-server" +version = "0.1.0" +dependencies = [ + "axum", + "feder-vocab", + "ref-feder-runtime-server", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "ref-feder-core" version = "0.1.0" dependencies = [ "feder-vocab", + "rsa", + "zeroize", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index dbb0d67..4ae5ddc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/feder-runtime-server", "crates/ref-feder-core", "crates/ref-feder-runtime-server", + "examples/ref-actor-server", "examples/single-user-server", ] resolver = "3" diff --git a/crates/ref-feder-core/Cargo.toml b/crates/ref-feder-core/Cargo.toml index 289c4e5..413850c 100644 --- a/crates/ref-feder-core/Cargo.toml +++ b/crates/ref-feder-core/Cargo.toml @@ -11,6 +11,8 @@ repository.workspace = true [dependencies] feder-vocab.workspace = true +rsa.workspace = true +zeroize.workspace = true [lints] workspace = true diff --git a/crates/ref-feder-core/src/actor.rs b/crates/ref-feder-core/src/actor.rs index f9d94ca..41f3961 100644 --- a/crates/ref-feder-core/src/actor.rs +++ b/crates/ref-feder-core/src/actor.rs @@ -20,10 +20,3 @@ pub trait ActorDispatcher { fn get_actor(&self, identifier: &str) -> Result, Self::Error>; } - -pub fn get_actor(runtime: &R, identifier: &str) -> Result, R::Error> -where - R: ActorDispatcher, -{ - runtime.get_actor(identifier) -} diff --git a/crates/ref-feder-core/src/key.rs b/crates/ref-feder-core/src/key.rs new file mode 100644 index 0000000..1501208 --- /dev/null +++ b/crates/ref-feder-core/src/key.rs @@ -0,0 +1,116 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use alloc::string::String; +use core::fmt; + +use rsa::{ + RsaPrivateKey, RsaPublicKey, + pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey, LineEnding}, + rand_core::CryptoRngCore, +}; +use zeroize::Zeroizing; + +const ACTOR_RSA_BITS: usize = 4096; + +#[derive(Clone, Eq, PartialEq)] +pub struct ActorKeyPair { + private_key_pem: Zeroizing, + public_key_pem: String, +} + +impl ActorKeyPair { + pub fn from_pem(private_key_pem: String, public_key_pem: String) -> Result { + let private_key_pem = Zeroizing::new(private_key_pem); + let private_key = + RsaPrivateKey::from_pkcs8_pem(&private_key_pem).map_err(KeyError::InvalidPrivateKey)?; + let public_key = RsaPublicKey::from_public_key_pem(&public_key_pem) + .map_err(KeyError::InvalidPublicKey)?; + + if RsaPublicKey::from(&private_key) != public_key { + return Err(KeyError::MismatchedKeyPair); + } + + Ok(Self { + private_key_pem, + public_key_pem, + }) + } + + #[must_use] + pub fn private_key_pem(&self) -> &str { + &self.private_key_pem + } + + #[must_use] + pub fn public_key_pem(&self) -> &str { + &self.public_key_pem + } +} + +impl fmt::Debug for ActorKeyPair { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ActorKeyPair") + .field("private_key_pem", &"[REDACTED]") + .field("public_key_pem", &self.public_key_pem) + .finish() + } +} + +/// Generates a 4096-bit RSA actor key pair for draft-Cavage HTTP signatures. +/// The caller must supply a cryptographically secure random number generator for the target runtime. +pub fn generate_actor_key_pair( + rng: &mut (impl CryptoRngCore + ?Sized), +) -> Result { + let private_key = RsaPrivateKey::new(rng, ACTOR_RSA_BITS).map_err(KeyError::Generation)?; + let public_key = RsaPublicKey::from(&private_key); + let private_key_pem = private_key + .to_pkcs8_pem(LineEnding::LF) + .map_err(KeyError::PrivateKeyEncoding)?; + let public_key_pem = public_key + .to_public_key_pem(LineEnding::LF) + .map_err(KeyError::PublicKeyEncoding)?; + + Ok(ActorKeyPair { + private_key_pem, + public_key_pem, + }) +} + +#[derive(Debug)] +pub enum KeyError { + Generation(rsa::Error), + PrivateKeyEncoding(rsa::pkcs8::Error), + PublicKeyEncoding(rsa::pkcs8::spki::Error), + InvalidPrivateKey(rsa::pkcs8::Error), + InvalidPublicKey(rsa::pkcs8::spki::Error), + MismatchedKeyPair, +} + +impl fmt::Display for KeyError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Generation(_) => formatter.write_str("failed to generate RSA actor key"), + Self::PrivateKeyEncoding(_) => formatter.write_str("failed to encode RSA private key"), + Self::PublicKeyEncoding(_) => formatter.write_str("failed to encode RSA public key"), + Self::InvalidPrivateKey(_) => formatter.write_str("invalid RSA private key PEM"), + Self::InvalidPublicKey(_) => formatter.write_str("invalid RSA public key PEM"), + Self::MismatchedKeyPair => formatter.write_str("RSA actor keys do not match"), + } + } +} + +impl core::error::Error for KeyError {} diff --git a/crates/ref-feder-core/src/lib.rs b/crates/ref-feder-core/src/lib.rs index 835b32c..e2e8ab1 100644 --- a/crates/ref-feder-core/src/lib.rs +++ b/crates/ref-feder-core/src/lib.rs @@ -20,9 +20,12 @@ //! been proven against the existing runtime and Federog. #![no_std] +extern crate alloc; + pub use feder_vocab as vocab; pub mod actor; +pub mod key; #[derive(Debug, Default)] pub struct FederCore; diff --git a/crates/ref-feder-runtime-server/src/actor.rs b/crates/ref-feder-runtime-server/src/actor.rs index b7e1461..de1898b 100644 --- a/crates/ref-feder-runtime-server/src/actor.rs +++ b/crates/ref-feder-runtime-server/src/actor.rs @@ -1,10 +1,3 @@ -use axum::{ - Json, - extract::{Path, State}, - http::{HeaderMap, StatusCode, header}, - response::{IntoResponse, Response}, -}; -use feder_vocab::Actor; // Feder: A portable ActivityPub core for many runtimes. // Copyright (C) 2026 Feder contributors // @@ -19,8 +12,16 @@ use feder_vocab::Actor; // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -// -use ref_feder_core::actor::{ActorDispatcher, get_actor}; + +use axum::{ + Json, + extract::{Path, State}, + http::{HeaderMap, StatusCode, header}, + response::{IntoResponse, Response}, +}; +use feder_vocab::Actor; + +use ref_feder_core::actor::ActorDispatcher; use crate::{FederServer, negotiation::accepts_activitypub}; @@ -47,7 +48,8 @@ where return Ok(([(header::VARY, "Accept")], StatusCode::NOT_ACCEPTABLE).into_response()); } - let actor = get_actor(&server, &identifier) + let actor = server + .get_actor(&identifier) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; diff --git a/examples/ref-actor-server/Cargo.toml b/examples/ref-actor-server/Cargo.toml new file mode 100644 index 0000000..27272a7 --- /dev/null +++ b/examples/ref-actor-server/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "ref-actor-server" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +axum = "0.8" +feder-vocab.workspace = true +ref-feder-runtime-server = { path = "../../crates/ref-feder-runtime-server" } +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[lints] +workspace = true diff --git a/examples/ref-actor-server/README.md b/examples/ref-actor-server/README.md new file mode 100644 index 0000000..7d95d97 --- /dev/null +++ b/examples/ref-actor-server/README.md @@ -0,0 +1,25 @@ +Reference Actor Server Example +============================== + +Minimal actor endpoint using `ref-feder-core` capabilities through +`ref-feder-runtime-server`. + + +Run +--- + +~~~~ sh +RUST_LOG=info cargo run -p ref-actor-server +~~~~ + +Request the hardcoded local actor: + +~~~~ sh +curl -i \ + -H 'Accept: application/activity+json' \ + http://127.0.0.1:3000/users/alice +~~~~ + +The endpoint returns `200 OK` with an ActivityPub actor document. Requests for +another identifier return `404 Not Found`, and requests that do not prefer an +ActivityPub representation return `406 Not Acceptable`. diff --git a/examples/ref-actor-server/src/main.rs b/examples/ref-actor-server/src/main.rs new file mode 100644 index 0000000..07f1db2 --- /dev/null +++ b/examples/ref-actor-server/src/main.rs @@ -0,0 +1,85 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::{convert::Infallible, net::SocketAddr}; + +use feder_vocab::{Actor, Endpoints}; +use ref_feder_runtime_server::{ActorDispatcher, Error, FederServer, build_router}; + +const IDENTIFIER: &str = "alice"; +const ORIGIN: &str = "http://127.0.0.1:3000"; + +struct SingleActorDispatcher { + actor: Actor, +} + +impl ActorDispatcher for SingleActorDispatcher { + type Error = Infallible; + + fn get_actor(&self, identifier: &str) -> Result, Self::Error> { + Ok((identifier == IDENTIFIER).then(|| self.actor.clone())) + } +} + +fn local_actor() -> Actor { + let actor_id = format!("{ORIGIN}/users/{IDENTIFIER}"); + let mut actor = Actor::person( + actor_id.parse().expect("valid actor IRI"), + format!("{actor_id}/inbox") + .parse() + .expect("valid inbox IRI"), + format!("{actor_id}/outbox") + .parse() + .expect("valid outbox IRI"), + ); + actor.preferred_username = Some(IDENTIFIER.to_string()); + actor.name = Some("Alice".to_string()); + actor.endpoints = Some(Endpoints { + shared_inbox: Some( + format!("{ORIGIN}/inbox") + .parse() + .expect("valid shared inbox IRI"), + ), + }); + actor +} + +#[tokio::main] +async fn main() -> Result<(), Error> { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let bind: SocketAddr = "127.0.0.1:3000" + .parse() + .expect("valid default bind address"); + let dispatcher = SingleActorDispatcher { + actor: local_actor(), + }; + let app = build_router(FederServer::new(dispatcher)); + + tracing::info!( + bind = %bind, + actor = %format!("{ORIGIN}/users/{IDENTIFIER}"), + "starting reference actor endpoint example" + ); + + let listener = tokio::net::TcpListener::bind(bind) + .await + .map_err(Error::Bind)?; + axum::serve(listener, app).await.map_err(Error::Serve)?; + + Ok(()) +} From 8f4f8b05e92c5667f7aa54a1bf96f02f7bff104f Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Fri, 31 Jul 2026 16:38:04 +0900 Subject: [PATCH 49/72] Add WebFinger endpoint to reference server runtime Parse and validate local acct resources, resolve actors through the actor dispatcher, and return JRD discovery responses from the experimental server router. Assisted-by: Codex:gpt-5.6-sol --- Cargo.lock | 1 + crates/ref-feder-runtime-server/Cargo.toml | 1 + crates/ref-feder-runtime-server/src/lib.rs | 4 +- .../ref-feder-runtime-server/src/webfinger.rs | 99 +++++++++++++++++++ 4 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 crates/ref-feder-runtime-server/src/webfinger.rs diff --git a/Cargo.lock b/Cargo.lock index d7fc661..3d00c99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1142,6 +1142,7 @@ dependencies = [ "feder-vocab", "mime", "ref-feder-core", + "serde", "thiserror", ] diff --git a/crates/ref-feder-runtime-server/Cargo.toml b/crates/ref-feder-runtime-server/Cargo.toml index 1ca51d1..6038f0a 100644 --- a/crates/ref-feder-runtime-server/Cargo.toml +++ b/crates/ref-feder-runtime-server/Cargo.toml @@ -14,6 +14,7 @@ axum = "=0.8" feder-vocab.workspace = true mime = "0.3.17" ref-feder-core.workspace = true +serde.workspace = true thiserror = "2.0.19" [lints] diff --git a/crates/ref-feder-runtime-server/src/lib.rs b/crates/ref-feder-runtime-server/src/lib.rs index 9bbed5f..1d64648 100644 --- a/crates/ref-feder-runtime-server/src/lib.rs +++ b/crates/ref-feder-runtime-server/src/lib.rs @@ -25,7 +25,8 @@ use axum::{Router, routing::get}; pub use ref_feder_core::actor::ActorDispatcher; pub mod actor; -mod negotiation; +pub mod negotiation; +pub mod webfinger; #[derive(Debug, thiserror::Error)] pub enum Error { @@ -62,5 +63,6 @@ where { Router::new() .route("/users/{identifier}", get(actor::actor::)) + .route("/.well-known/webfinger", get(webfinger::webfinger::)) .with_state(server) } diff --git a/crates/ref-feder-runtime-server/src/webfinger.rs b/crates/ref-feder-runtime-server/src/webfinger.rs new file mode 100644 index 0000000..c4e737f --- /dev/null +++ b/crates/ref-feder-runtime-server/src/webfinger.rs @@ -0,0 +1,99 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use crate::FederServer; +use axum::{ + Json, + extract::{Query, State}, + http::{HeaderMap, StatusCode, header}, + response::{IntoResponse, Response}, +}; +use ref_feder_core::actor::ActorDispatcher; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize)] +pub struct WebFingerQuery { + resource: Option, +} + +#[derive(Serialize)] +pub struct WebFingerLink { + rel: &'static str, + #[serde(rename = "type")] + media_type: &'static str, + href: String, +} + +#[derive(Serialize)] +pub struct WebFingerResponse { + subject: String, + aliases: Vec, + links: Vec, +} + +pub async fn webfinger( + State(server): State>, + headers: HeaderMap, + Query(query): Query, +) -> Result +where + A: ActorDispatcher, +{ + let resource = query.resource.ok_or(StatusCode::BAD_REQUEST)?; + + let account = resource + .strip_prefix("acct:") + .ok_or(StatusCode::BAD_REQUEST)?; + + let (identifier, resource_host) = account.rsplit_once('@').ok_or(StatusCode::BAD_REQUEST)?; + + if identifier.is_empty() || resource_host.is_empty() { + return Err(StatusCode::BAD_REQUEST); + } + + let request_host = request_host(&headers).ok_or(StatusCode::BAD_REQUEST)?; + + if !resource_host.eq_ignore_ascii_case(request_host) { + return Err(StatusCode::NOT_FOUND); + } + + let actor = server + .get_actor(identifier) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + let actor_id = actor.id.to_string(); + + Ok(( + [(header::CONTENT_TYPE, "application/jrd+json")], + Json(WebFingerResponse { + subject: resource, + aliases: vec![actor_id.clone()], + links: vec![WebFingerLink { + rel: "self", + media_type: "application/activity+json", + href: actor_id, + }], + }), + ) + .into_response()) +} + +fn request_host(headers: &HeaderMap) -> Option<&str> { + headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .filter(|value| !value.is_empty()) +} From 5e92f439420335dc800995fbb867ea8c8e01a20d Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sat, 1 Aug 2026 04:37:51 +0900 Subject: [PATCH 50/72] Add Follow handling to reference server runtime Introduce a stateless receive_follow transition in ref-feder-core. Validate that the Follow actor matches the resolved remote actor and that its object targets the selected local actor, then return the follower facts and Accept delivery through a transient FollowOutcome without retaining protocol state. Extend the portable key module with SHA-256 digest generation and draft-Cavage RSA-SHA256 signing and verification primitives. Add a personal inbox endpoint to ref-feder-runtime-server. Validate ActivityPub content types, body size, request host and date, body digests, signature headers, remote key ownership, and actor identity before invoking the core Follow transition. Persist the follower before sending the generated Accept and return 202 Accepted for successfully handled or irrelevant activities. Represent local actor access and runtime services through FederServer, while placing Arc only at the Axum router boundary for concurrent request sharing. Keep signed authentication as the default and temporarily retain an explicit insecure development policy until the reference example can issue signed Follow requests. Update the reference server example with bounded adapters for remote resolution, follower storage, and Accept sending. Document and exercise the actor, WebFinger, and personal inbox endpoints without accumulating an in-memory activity history. Shared inbox and Undo handling remain out of scope for this change. Assisted-by: Codex:gpt-5.6-sol --- Cargo.lock | 4 + crates/ref-feder-core/Cargo.toml | 1 + crates/ref-feder-core/src/actor.rs | 22 - crates/ref-feder-core/src/follow.rs | 81 +++ crates/ref-feder-core/src/key.rs | 125 ++++- crates/ref-feder-core/src/lib.rs | 9 +- crates/ref-feder-runtime-server/Cargo.toml | 3 + crates/ref-feder-runtime-server/src/actor.rs | 12 +- crates/ref-feder-runtime-server/src/inbox.rs | 461 ++++++++++++++++++ crates/ref-feder-runtime-server/src/lib.rs | 58 ++- .../ref-feder-runtime-server/src/webfinger.rs | 8 +- cspell.json | 3 +- examples/ref-actor-server/README.md | 46 +- examples/ref-actor-server/src/main.rs | 126 ++++- 14 files changed, 901 insertions(+), 58 deletions(-) delete mode 100644 crates/ref-feder-core/src/actor.rs create mode 100644 crates/ref-feder-core/src/follow.rs create mode 100644 crates/ref-feder-runtime-server/src/inbox.rs diff --git a/Cargo.lock b/Cargo.lock index 3d00c99..0051508 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1129,6 +1129,7 @@ dependencies = [ name = "ref-feder-core" version = "0.1.0" dependencies = [ + "base64", "feder-vocab", "rsa", "zeroize", @@ -1140,9 +1141,12 @@ version = "0.1.0" dependencies = [ "axum", "feder-vocab", + "httpdate", "mime", + "percent-encoding", "ref-feder-core", "serde", + "serde_json", "thiserror", ] diff --git a/crates/ref-feder-core/Cargo.toml b/crates/ref-feder-core/Cargo.toml index 413850c..23b4512 100644 --- a/crates/ref-feder-core/Cargo.toml +++ b/crates/ref-feder-core/Cargo.toml @@ -10,6 +10,7 @@ homepage.workspace = true repository.workspace = true [dependencies] +base64.workspace = true feder-vocab.workspace = true rsa.workspace = true zeroize.workspace = true diff --git a/crates/ref-feder-core/src/actor.rs b/crates/ref-feder-core/src/actor.rs deleted file mode 100644 index 41f3961..0000000 --- a/crates/ref-feder-core/src/actor.rs +++ /dev/null @@ -1,22 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use feder_vocab::Actor; - -pub trait ActorDispatcher { - type Error; - - fn get_actor(&self, identifier: &str) -> Result, Self::Error>; -} diff --git a/crates/ref-feder-core/src/follow.rs b/crates/ref-feder-core/src/follow.rs new file mode 100644 index 0000000..37e49ce --- /dev/null +++ b/crates/ref-feder-core/src/follow.rs @@ -0,0 +1,81 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use core::fmt; + +use feder_vocab::{Accept, Actor, Follow, Iri, Reference}; + +/// The transient result of accepting one valid Follow activity. +/// +/// Core does not retain this value or write it to storage. A runtime persists +/// `follower` and `following`, then delivers `accept` to `recipient_inbox`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FollowOutcome { + pub follower: Actor, + pub following: Iri, + pub accept: Accept, + pub recipient_inbox: Iri, +} + +pub fn receive_follow( + local_actor: &Actor, + remote_actor: &Actor, + mut follow: Follow, + accept_id: Iri, +) -> Result { + if reference_id(&follow.object) != &local_actor.id { + return Err(FollowError::WrongObject); + } + if reference_id(&follow.actor) != &remote_actor.id { + return Err(FollowError::WrongActor); + } + + follow.actor = Reference::object(remote_actor.clone()); + + Ok(FollowOutcome { + follower: remote_actor.clone(), + following: local_actor.id.clone(), + accept: Accept::new( + accept_id, + Reference::id(local_actor.id.clone()), + Reference::object(follow), + ), + recipient_inbox: remote_actor.inbox.clone(), + }) +} + +fn reference_id(reference: &Reference) -> &Iri { + match reference { + Reference::Id(id) => id, + Reference::Object(actor) => &actor.id, + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FollowError { + WrongActor, + WrongObject, +} + +impl fmt::Display for FollowError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::WrongActor => formatter.write_str("Follow actor does not match remote actor"), + Self::WrongObject => formatter.write_str("Follow does not target the local actor"), + } + } +} + +impl core::error::Error for FollowError {} diff --git a/crates/ref-feder-core/src/key.rs b/crates/ref-feder-core/src/key.rs index 1501208..d03f0ed 100644 --- a/crates/ref-feder-core/src/key.rs +++ b/crates/ref-feder-core/src/key.rs @@ -13,13 +13,17 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use alloc::string::String; +use alloc::{format, string::String, vec::Vec}; use core::fmt; +use base64::{Engine as _, engine::general_purpose::STANDARD}; use rsa::{ RsaPrivateKey, RsaPublicKey, + pkcs1v15::{Signature, SigningKey, VerifyingKey}, pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey, LineEnding}, rand_core::CryptoRngCore, + sha2::{Digest, Sha256}, + signature::{SignatureEncoding, Signer, Verifier}, }; use zeroize::Zeroizing; @@ -114,3 +118,122 @@ impl fmt::Display for KeyError { } impl core::error::Error for KeyError {} + +/// Creates an RFC 3230 SHA-256 digest header. +#[must_use] +pub fn create_sha256_digest_header(body: &[u8]) -> String { + let digest = Sha256::digest(body); + format!("SHA-256={}", STANDARD.encode(digest)) +} + +/// Signs a prepared HTTP request using the draft-Cavage header format. +/// +/// Header names must be supplied in the order in which they should appear in +/// the signature's `headers` parameter. +pub fn sign_draft_cavage( + key_pair: &ActorKeyPair, + key_id: &str, + method: &str, + request_target: &str, + headers: &[(&str, &str)], +) -> Result { + let signature_base = draft_cavage_signature_base(method, request_target, headers); + let private_key = RsaPrivateKey::from_pkcs8_pem(key_pair.private_key_pem()) + .map_err(HttpSignatureError::InvalidPrivateKey)?; + let signing_key = SigningKey::::new(private_key); + let signature = signing_key.sign(signature_base.as_bytes()).to_bytes(); + let signed_headers = headers + .iter() + .map(|(name, _)| name.to_ascii_lowercase()) + .collect::>() + .join(" "); + + Ok(format!( + "keyId=\"{key_id}\",algorithm=\"rsa-sha256\",headers=\"(request-target) {signed_headers}\",signature=\"{}\"", + STANDARD.encode(signature) + )) +} + +/// Verifies a draft-Cavage RSA-SHA256 signature over a prepared request. +/// +/// `headers` must contain the signed HTTP headers in their declared order, +/// excluding the `(request-target)` pseudo-header. +pub fn verify_draft_cavage( + public_key_pem: &str, + method: &str, + request_target: &str, + headers: &[(&str, &str)], + signature: &str, +) -> Result<(), HttpSignatureVerificationError> { + let signature_base = draft_cavage_signature_base(method, request_target, headers); + let public_key = RsaPublicKey::from_public_key_pem(public_key_pem) + .map_err(HttpSignatureVerificationError::InvalidPublicKey)?; + let signature = STANDARD + .decode(signature) + .map_err(HttpSignatureVerificationError::InvalidSignatureEncoding)?; + let signature = Signature::try_from(signature.as_slice()) + .map_err(HttpSignatureVerificationError::InvalidSignature)?; + let verifying_key = VerifyingKey::::new(public_key); + + verifying_key + .verify(signature_base.as_bytes(), &signature) + .map_err(HttpSignatureVerificationError::Verification) +} + +fn draft_cavage_signature_base( + method: &str, + request_target: &str, + headers: &[(&str, &str)], +) -> String { + let mut lines = Vec::with_capacity(headers.len() + 1); + lines.push(format!( + "(request-target): {} {request_target}", + method.to_ascii_lowercase() + )); + lines.extend( + headers + .iter() + .map(|(name, value)| format!("{}: {}", name.to_ascii_lowercase(), value.trim())), + ); + lines.join("\n") +} + +/// Errors produced while creating an HTTP signature. +#[derive(Debug)] +pub enum HttpSignatureError { + InvalidPrivateKey(rsa::pkcs8::Error), +} + +impl fmt::Display for HttpSignatureError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidPrivateKey(_) => formatter.write_str("invalid RSA private key PEM"), + } + } +} + +impl core::error::Error for HttpSignatureError {} + +/// Errors produced while verifying an HTTP signature. +#[derive(Debug)] +pub enum HttpSignatureVerificationError { + InvalidPublicKey(rsa::pkcs8::spki::Error), + InvalidSignatureEncoding(base64::DecodeError), + InvalidSignature(rsa::signature::Error), + Verification(rsa::signature::Error), +} + +impl fmt::Display for HttpSignatureVerificationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidPublicKey(_) => formatter.write_str("invalid RSA public key PEM"), + Self::InvalidSignatureEncoding(_) => { + formatter.write_str("invalid base64 signature encoding") + } + Self::InvalidSignature(_) => formatter.write_str("invalid RSA signature"), + Self::Verification(_) => formatter.write_str("HTTP signature verification failed"), + } + } +} + +impl core::error::Error for HttpSignatureVerificationError {} diff --git a/crates/ref-feder-core/src/lib.rs b/crates/ref-feder-core/src/lib.rs index e2e8ab1..6ab0667 100644 --- a/crates/ref-feder-core/src/lib.rs +++ b/crates/ref-feder-core/src/lib.rs @@ -23,9 +23,16 @@ extern crate alloc; pub use feder_vocab as vocab; +use feder_vocab::Actor; -pub mod actor; +pub mod follow; pub mod key; #[derive(Debug, Default)] pub struct FederCore; + +pub trait ActorDispatcher { + type Error; + + fn get_actor(&self, identifier: &str) -> Result, Self::Error>; +} diff --git a/crates/ref-feder-runtime-server/Cargo.toml b/crates/ref-feder-runtime-server/Cargo.toml index 6038f0a..181a3e7 100644 --- a/crates/ref-feder-runtime-server/Cargo.toml +++ b/crates/ref-feder-runtime-server/Cargo.toml @@ -12,9 +12,12 @@ repository.workspace = true [dependencies] axum = "=0.8" feder-vocab.workspace = true +httpdate = "1" mime = "0.3.17" +percent-encoding = "2.3.2" ref-feder-core.workspace = true serde.workspace = true +serde_json.workspace = true thiserror = "2.0.19" [lints] diff --git a/crates/ref-feder-runtime-server/src/actor.rs b/crates/ref-feder-runtime-server/src/actor.rs index de1898b..611a27e 100644 --- a/crates/ref-feder-runtime-server/src/actor.rs +++ b/crates/ref-feder-runtime-server/src/actor.rs @@ -13,6 +13,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +use std::sync::Arc; + use axum::{ Json, extract::{Path, State}, @@ -21,23 +23,23 @@ use axum::{ }; use feder_vocab::Actor; -use ref_feder_core::actor::ActorDispatcher; +use ref_feder_core::ActorDispatcher; use crate::{FederServer, negotiation::accepts_activitypub}; -impl ActorDispatcher for FederServer +impl ActorDispatcher for FederServer where A: ActorDispatcher, { type Error = A::Error; fn get_actor(&self, identifier: &str) -> Result, Self::Error> { - self.actors.get_actor(identifier) + self.actors().get_actor(identifier) } } -pub async fn actor( - State(server): State>, +pub async fn actor( + State(server): State>>, Path(identifier): Path, headers: HeaderMap, ) -> Result diff --git a/crates/ref-feder-runtime-server/src/inbox.rs b/crates/ref-feder-runtime-server/src/inbox.rs new file mode 100644 index 0000000..6845305 --- /dev/null +++ b/crates/ref-feder-runtime-server/src/inbox.rs @@ -0,0 +1,461 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::{ + collections::{BTreeMap, HashSet}, + future::Future, + sync::Arc, + time::{Duration, SystemTime}, +}; + +use axum::{ + body::Bytes, + extract::{Path, State}, + http::{ + HeaderMap, Method, StatusCode, Uri, + header::{CONTENT_TYPE, HOST}, + uri::Authority, + }, + response::{IntoResponse, Response}, +}; +use feder_vocab::{Accept, Actor, CryptographicKey, Follow, Iri, Reference}; +use mime::Mime; +use ref_feder_core::{ + ActorDispatcher, + follow::{FollowError, receive_follow}, + key::{create_sha256_digest_header, verify_draft_cavage}, +}; +use serde_json::{Value, from_slice, from_value}; + +use crate::FederServer; + +const MAX_SIGNATURE_AGE: Duration = Duration::from_secs(65 * 60); +const MAX_CLOCK_SKEW: Duration = Duration::from_secs(60 * 60); +const ACTIVITYPUB_CONTENT_TYPES: &[&str] = &["application/activity+json", "application/ld+json"]; + +// FIXME: Remove this policy once the reference example sends signed Follow +// requests. The built-in inbox should always verify requests; applications +// that need different authentication can build an inbox and call core directly. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum InboxAuthPolicy { + AllowUnsignedInsecureDev, + #[default] + RequireSigned, +} + +pub trait FollowStore { + type Error; + + fn store_follower(&self, follower: &Actor, following: &Iri) -> Result<(), Self::Error>; +} + +pub trait RemoteResolver { + type Error; + + fn resolve_actor<'a>( + &'a self, + actor_id: &'a Iri, + ) -> impl Future> + Send + 'a; + + fn resolve_key<'a>( + &'a self, + key_id: &'a Iri, + ) -> impl Future> + Send + 'a; +} + +pub trait ActivitySender { + type Error; + + fn send_accept<'a>( + &'a self, + local_actor: &'a Actor, + accept: &'a Accept, + inbox: &'a Iri, + ) -> impl Future> + Send + 'a; +} + +struct InboxRequest { + headers: HeaderMap, + method: Method, + uri: Uri, + body: Bytes, +} + +pub async fn inbox( + State(server): State>>, + Path(identifier): Path, + headers: HeaderMap, + method: Method, + uri: Uri, + body: Bytes, +) -> Result +where + A: ActorDispatcher, + S: ActivitySender + FollowStore + RemoteResolver, +{ + let local_actor = server + .actors() + .get_actor(&identifier) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + let content_type = headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()); + if !content_type + .is_some_and(|media_type| ACTIVITYPUB_CONTENT_TYPES.contains(&media_type.essence_str())) + { + return Err(StatusCode::UNSUPPORTED_MEDIA_TYPE); + } + + let request = InboxRequest { + headers, + method, + uri, + body, + }; + let value: Value = from_slice(&request.body).map_err(|_| StatusCode::BAD_REQUEST)?; + let activity_actor_id = activity_actor_id(&value); + let verified_actor = match server.inbox_auth_policy() { + InboxAuthPolicy::AllowUnsignedInsecureDev => None, + InboxAuthPolicy::RequireSigned => Some( + verify_signed_request( + server.services(), + &local_actor, + &request, + activity_actor_id.as_ref().ok_or(StatusCode::UNAUTHORIZED)?, + ) + .await?, + ), + }; + + if value.get("type").and_then(Value::as_str) != Some("Follow") { + return Ok(StatusCode::ACCEPTED.into_response()); + } + + let follow: Follow = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; + let remote_actor = match verified_actor { + Some(actor) => actor, + None => resolve_follow_actor(server.services(), &follow).await?, + }; + let accept_id = accept_id_for_follow(&local_actor.id, &follow.id)?; + let outcome = match receive_follow(&local_actor, &remote_actor, follow, accept_id) { + Ok(outcome) => outcome, + Err(FollowError::WrongObject) => return Ok(StatusCode::ACCEPTED.into_response()), + Err(FollowError::WrongActor) => return Err(StatusCode::UNAUTHORIZED), + }; + + server + .services() + .store_follower(&outcome.follower, &outcome.following) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + server + .services() + .send_accept(&local_actor, &outcome.accept, &outcome.recipient_inbox) + .await + .map_err(|_| StatusCode::BAD_GATEWAY)?; + + Ok(StatusCode::ACCEPTED.into_response()) +} + +async fn resolve_follow_actor(services: &S, follow: &Follow) -> Result +where + S: RemoteResolver, +{ + match &follow.actor { + Reference::Object(actor) => Ok((**actor).clone()), + Reference::Id(actor_id) => services + .resolve_actor(actor_id) + .await + .map_err(|_| StatusCode::BAD_GATEWAY), + } +} + +async fn verify_signed_request( + services: &S, + local_actor: &Actor, + request: &InboxRequest, + activity_actor_id: &Iri, +) -> Result +where + S: RemoteResolver, +{ + let signature_header = request + .headers + .get("signature") + .and_then(|value| value.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + let signature = parse_signature_header(signature_header).ok_or(StatusCode::UNAUTHORIZED)?; + if signature.algorithm != "rsa-sha256" + || signature.signed_headers.first().map(String::as_str) != Some("(request-target)") + { + return Err(StatusCode::UNAUTHORIZED); + } + + let mut seen_headers = HashSet::new(); + let mut signed_headers = Vec::new(); + for name in signature.signed_headers.iter().skip(1) { + if name.starts_with('(') || !seen_headers.insert(name.as_str()) { + return Err(StatusCode::UNAUTHORIZED); + } + let values = request.headers.get_all(name).iter().collect::>(); + let [value] = values.as_slice() else { + return Err(StatusCode::UNAUTHORIZED); + }; + let value = value.to_str().map_err(|_| StatusCode::UNAUTHORIZED)?; + signed_headers.push((name.as_str(), value)); + } + if !["host", "date", "digest"] + .iter() + .all(|required| seen_headers.contains(required)) + { + return Err(StatusCode::UNAUTHORIZED); + } + + verify_request_host(&request.headers, &local_actor.inbox)?; + verify_request_date(&request.headers)?; + verify_request_digest(&request.headers, &request.body)?; + + let key_id: Iri = signature + .key_id + .parse() + .map_err(|_| StatusCode::UNAUTHORIZED)?; + let public_key = services + .resolve_key(&key_id) + .await + .map_err(|_| StatusCode::BAD_GATEWAY)?; + if public_key.id != key_id || public_key.owner != *activity_actor_id { + return Err(StatusCode::UNAUTHORIZED); + } + + let request_target = request + .uri + .path_and_query() + .map_or(request.uri.path(), |value| value.as_str()); + verify_draft_cavage( + &public_key.public_key_pem, + request.method.as_str(), + request_target, + &signed_headers, + &signature.signature, + ) + .map_err(|_| StatusCode::UNAUTHORIZED)?; + + let actor = services + .resolve_actor(activity_actor_id) + .await + .map_err(|_| StatusCode::BAD_GATEWAY)?; + if actor.id != *activity_actor_id || !actor_owns_key(&actor, &public_key) { + return Err(StatusCode::UNAUTHORIZED); + } + + Ok(actor) +} + +fn actor_owns_key(actor: &Actor, key: &CryptographicKey) -> bool { + match actor.public_key.as_ref() { + Some(Reference::Id(advertised_key_id)) => advertised_key_id == &key.id, + Some(Reference::Object(advertised_key)) => { + advertised_key.id == key.id + && advertised_key.owner == actor.id + && advertised_key.public_key_pem == key.public_key_pem + } + None => false, + } +} + +fn accept_id_for_follow(local_actor_id: &Iri, follow_id: &Iri) -> Result { + let encoded_follow_id = percent_encoding::utf8_percent_encode( + follow_id.as_str(), + percent_encoding::NON_ALPHANUMERIC, + ); + format!("{local_actor_id}#accepts/{encoded_follow_id}") + .parse() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +fn activity_actor_id(value: &Value) -> Option { + let actor = value.get("actor")?; + actor + .as_str() + .or_else(|| actor.get("id").and_then(Value::as_str))? + .parse() + .ok() +} + +fn verify_request_host(headers: &HeaderMap, inbox: &Iri) -> Result<(), StatusCode> { + let signed_host = headers + .get(HOST) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .filter(|authority| !authority.as_str().contains('@')) + .ok_or(StatusCode::UNAUTHORIZED)?; + let inbox_uri = inbox + .as_str() + .parse::() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let expected_host = inbox_uri + .authority() + .filter(|authority| !authority.as_str().contains('@')) + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + let default_port = match inbox_uri.scheme_str() { + Some(scheme) if scheme.eq_ignore_ascii_case("http") => Some(80), + Some(scheme) if scheme.eq_ignore_ascii_case("https") => Some(443), + _ => None, + }; + let signed_port = effective_port(&signed_host, default_port).ok_or(StatusCode::UNAUTHORIZED)?; + let expected_port = + effective_port(expected_host, default_port).ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + + if signed_host + .host() + .eq_ignore_ascii_case(expected_host.host()) + && signed_port == expected_port + { + Ok(()) + } else { + Err(StatusCode::UNAUTHORIZED) + } +} + +fn effective_port(authority: &Authority, default_port: Option) -> Option> { + let suffix = authority.as_str().get(authority.host().len()..)?; + if suffix.is_empty() { + Some(default_port) + } else if suffix.starts_with(':') { + authority.port_u16().map(Some) + } else { + None + } +} + +fn verify_request_date(headers: &HeaderMap) -> Result<(), StatusCode> { + let date = headers + .get("date") + .and_then(|value| value.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED) + .and_then(|value| httpdate::parse_http_date(value).map_err(|_| StatusCode::UNAUTHORIZED))?; + let now = SystemTime::now(); + if now + .duration_since(date) + .is_ok_and(|age| age > MAX_SIGNATURE_AGE) + || date + .duration_since(now) + .is_ok_and(|skew| skew > MAX_CLOCK_SKEW) + { + return Err(StatusCode::UNAUTHORIZED); + } + Ok(()) +} + +fn verify_request_digest(headers: &HeaderMap, body: &[u8]) -> Result<(), StatusCode> { + let digest = headers + .get("digest") + .and_then(|value| value.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + let expected = create_sha256_digest_header(body); + let matches = digest.split(',').any(|entry| { + entry + .trim() + .split_once('=') + .is_some_and(|(algorithm, value)| { + algorithm.eq_ignore_ascii_case("sha-256") + && expected + .split_once('=') + .is_some_and(|(_, expected)| value == expected) + }) + }); + if matches { + Ok(()) + } else { + Err(StatusCode::UNAUTHORIZED) + } +} + +struct ParsedSignature { + key_id: String, + algorithm: String, + signed_headers: Vec, + signature: String, +} + +fn parse_signature_header(header: &str) -> Option { + let mut parameters = BTreeMap::new(); + let mut remaining = header; + while !remaining.trim_start().is_empty() { + remaining = remaining.trim_start(); + let equals = remaining.find('=')?; + let name = remaining[..equals].trim(); + if name.is_empty() + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return None; + } + remaining = &remaining[equals + 1..]; + let (value, rest) = parse_quoted_parameter(remaining.trim_start())?; + if parameters + .insert(name.to_ascii_lowercase(), value) + .is_some() + { + return None; + } + remaining = rest.trim_start(); + if remaining.is_empty() { + break; + } + remaining = remaining.strip_prefix(',')?; + } + + let key_id = parameters.remove("keyid")?; + let algorithm = parameters.remove("algorithm")?; + let signed_headers = parameters + .remove("headers")? + .split_ascii_whitespace() + .map(str::to_ascii_lowercase) + .collect::>(); + let signature = parameters.remove("signature")?; + if key_id.is_empty() || signed_headers.is_empty() || signature.is_empty() { + return None; + } + Some(ParsedSignature { + key_id, + algorithm: algorithm.to_ascii_lowercase(), + signed_headers, + signature, + }) +} + +fn parse_quoted_parameter(input: &str) -> Option<(String, &str)> { + let input = input.strip_prefix('"')?; + let mut value = String::new(); + let mut escaped = false; + for (index, character) in input.char_indices() { + if escaped { + value.push(character); + escaped = false; + } else if character == '\\' { + escaped = true; + } else if character == '"' { + return Some((value, &input[index + character.len_utf8()..])); + } else if character.is_control() { + return None; + } else { + value.push(character); + } + } + None +} diff --git a/crates/ref-feder-runtime-server/src/lib.rs b/crates/ref-feder-runtime-server/src/lib.rs index 1d64648..1c99b47 100644 --- a/crates/ref-feder-runtime-server/src/lib.rs +++ b/crates/ref-feder-runtime-server/src/lib.rs @@ -21,13 +21,20 @@ use std::sync::Arc; -use axum::{Router, routing::get}; -pub use ref_feder_core::actor::ActorDispatcher; +use axum::{ + Router, + extract::DefaultBodyLimit, + routing::{get, post}, +}; +pub use ref_feder_core::ActorDispatcher; pub mod actor; +pub mod inbox; pub mod negotiation; pub mod webfinger; +pub use inbox::{ActivitySender, FollowStore, InboxAuthPolicy, RemoteResolver}; + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("failed to bind server socket")] @@ -37,32 +44,51 @@ pub enum Error { Serve(#[source] std::io::Error), } -pub struct FederServer { - actors: Arc, +pub struct FederServer { + actors: A, + services: S, + inbox_auth_policy: InboxAuthPolicy, } -impl Clone for FederServer { - fn clone(&self) -> Self { +impl FederServer { + pub fn new(actors: A, services: S) -> Self { Self { - actors: Arc::clone(&self.actors), + actors, + services, + inbox_auth_policy: InboxAuthPolicy::RequireSigned, } } -} -impl FederServer { - pub fn new(actors: A) -> Self { - Self { - actors: Arc::new(actors), - } + #[must_use] + pub fn with_inbox_auth_policy(mut self, inbox_auth_policy: InboxAuthPolicy) -> Self { + self.inbox_auth_policy = inbox_auth_policy; + self + } + + pub(crate) fn actors(&self) -> &A { + &self.actors + } + + pub(crate) fn services(&self) -> &S { + &self.services + } + + pub(crate) fn inbox_auth_policy(&self) -> InboxAuthPolicy { + self.inbox_auth_policy } } -pub fn build_router(server: FederServer) -> Router +pub fn build_router(server: FederServer) -> Router where A: ActorDispatcher + Send + Sync + 'static, + S: ActivitySender + FollowStore + RemoteResolver + Send + Sync + 'static, { + let server = Arc::new(server); + Router::new() - .route("/users/{identifier}", get(actor::actor::)) - .route("/.well-known/webfinger", get(webfinger::webfinger::)) + .route("/users/{identifier}", get(actor::actor::)) + .route("/.well-known/webfinger", get(webfinger::webfinger::)) + .route("/users/{identifier}/inbox", post(inbox::inbox::)) + .layer(DefaultBodyLimit::max(1_048_576)) .with_state(server) } diff --git a/crates/ref-feder-runtime-server/src/webfinger.rs b/crates/ref-feder-runtime-server/src/webfinger.rs index c4e737f..f161bca 100644 --- a/crates/ref-feder-runtime-server/src/webfinger.rs +++ b/crates/ref-feder-runtime-server/src/webfinger.rs @@ -13,6 +13,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +use std::sync::Arc; + use crate::FederServer; use axum::{ Json, @@ -20,7 +22,7 @@ use axum::{ http::{HeaderMap, StatusCode, header}, response::{IntoResponse, Response}, }; -use ref_feder_core::actor::ActorDispatcher; +use ref_feder_core::ActorDispatcher; use serde::{Deserialize, Serialize}; #[derive(Deserialize)] @@ -43,8 +45,8 @@ pub struct WebFingerResponse { links: Vec, } -pub async fn webfinger( - State(server): State>, +pub async fn webfinger( + State(server): State>>, headers: HeaderMap, Query(query): Query, ) -> Result diff --git a/cspell.json b/cspell.json index 5141c51..2f26f15 100644 --- a/cspell.json +++ b/cspell.json @@ -1,6 +1,7 @@ { "words": [ "Feder", - "activitypub" + "activitypub", + "webfinger" ] } \ No newline at end of file diff --git a/examples/ref-actor-server/README.md b/examples/ref-actor-server/README.md index 7d95d97..03f9ac4 100644 --- a/examples/ref-actor-server/README.md +++ b/examples/ref-actor-server/README.md @@ -1,8 +1,8 @@ -Reference Actor Server Example -============================== +Reference ActivityPub Server +============================ -Minimal actor endpoint using `ref-feder-core` capabilities through -`ref-feder-runtime-server`. +Minimal actor, WebFinger, and personal inbox endpoints using `ref-feder-core` +capabilities through `ref-feder-runtime-server`. Run @@ -23,3 +23,41 @@ curl -i \ The endpoint returns `200 OK` with an ActivityPub actor document. Requests for another identifier return `404 Not Found`, and requests that do not prefer an ActivityPub representation return `406 Not Acceptable`. + +Discover the actor through WebFinger: + +~~~~ sh +curl -i \ + -H 'Host: 127.0.0.1:3000' \ + 'http://127.0.0.1:3000/.well-known/webfinger?resource=acct:alice@127.0.0.1:3000' +~~~~ + +The endpoint returns `application/jrd+json` with a `self` link to +`http://127.0.0.1:3000/users/alice`. The domain in the `acct:` resource must +match the request's `Host` header. + +Send an unsigned development Follow to Alice's personal inbox: + +~~~~ sh +curl -i \ + -H 'Content-Type: application/activity+json' \ + --data-binary '{ + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://remote.example/activities/follow/1", + "type": "Follow", + "actor": { + "id": "https://remote.example/users/bob", + "type": "Person", + "inbox": "https://remote.example/users/bob/inbox", + "outbox": "https://remote.example/users/bob/outbox" + }, + "object": "http://127.0.0.1:3000/users/alice" + }' \ + http://127.0.0.1:3000/users/alice/inbox +~~~~ + +The endpoint stores the latest follower, records the generated `Accept`, and +returns `202 Accepted`. These example adapters deliberately retain only their +latest value, so repeated requests do not grow an in-memory protocol history. +Unsigned inbox requests are enabled only for this local development example; +`FederServer` requires signed requests by default. diff --git a/examples/ref-actor-server/src/main.rs b/examples/ref-actor-server/src/main.rs index 07f1db2..4c4debf 100644 --- a/examples/ref-actor-server/src/main.rs +++ b/examples/ref-actor-server/src/main.rs @@ -13,10 +13,19 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use std::{convert::Infallible, net::SocketAddr}; +use std::{ + convert::Infallible, + fmt, + future::{Future, ready}, + net::SocketAddr, + sync::Mutex, +}; -use feder_vocab::{Actor, Endpoints}; -use ref_feder_runtime_server::{ActorDispatcher, Error, FederServer, build_router}; +use feder_vocab::{Accept, Actor, CryptographicKey, Endpoints, Iri, Reference}; +use ref_feder_runtime_server::{ + ActivitySender, ActorDispatcher, Error, FederServer, FollowStore, InboxAuthPolicy, + RemoteResolver, build_router, +}; const IDENTIFIER: &str = "alice"; const ORIGIN: &str = "http://127.0.0.1:3000"; @@ -25,6 +34,23 @@ struct SingleActorDispatcher { actor: Actor, } +struct ExampleServices { + remote_actor: Actor, + latest_follower: Mutex>, + latest_accept: Mutex>, +} + +#[derive(Debug)] +struct ExampleServiceError(&'static str); + +impl fmt::Display for ExampleServiceError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.0) + } +} + +impl std::error::Error for ExampleServiceError {} + impl ActorDispatcher for SingleActorDispatcher { type Error = Infallible; @@ -33,6 +59,66 @@ impl ActorDispatcher for SingleActorDispatcher { } } +impl FollowStore for ExampleServices { + type Error = ExampleServiceError; + + fn store_follower(&self, follower: &Actor, following: &Iri) -> Result<(), Self::Error> { + *self + .latest_follower + .lock() + .map_err(|_| ExampleServiceError("follower state lock poisoned"))? = + Some((follower.clone(), following.clone())); + tracing::info!(follower = %follower.id, following = %following, "stored follower"); + Ok(()) + } +} + +impl RemoteResolver for ExampleServices { + type Error = ExampleServiceError; + + fn resolve_actor<'a>( + &'a self, + actor_id: &'a Iri, + ) -> impl Future> + Send + 'a { + ready(if actor_id == &self.remote_actor.id { + Ok(self.remote_actor.clone()) + } else { + Err(ExampleServiceError("remote actor not found")) + }) + } + + fn resolve_key<'a>( + &'a self, + key_id: &'a Iri, + ) -> impl Future> + Send + 'a { + ready(match self.remote_actor.public_key.as_ref() { + Some(Reference::Object(key)) if key.id == *key_id => Ok((**key).clone()), + _ => Err(ExampleServiceError("remote key not found")), + }) + } +} + +impl ActivitySender for ExampleServices { + type Error = ExampleServiceError; + + fn send_accept<'a>( + &'a self, + _local_actor: &'a Actor, + accept: &'a Accept, + inbox: &'a Iri, + ) -> impl Future> + Send + 'a { + let result = self + .latest_accept + .lock() + .map_err(|_| ExampleServiceError("delivery state lock poisoned")) + .map(|mut latest_accept| { + *latest_accept = Some((accept.clone(), inbox.clone())); + tracing::info!(activity = %accept.id, recipient = %inbox, "delivered Accept"); + }); + ready(result) + } +} + fn local_actor() -> Actor { let actor_id = format!("{ORIGIN}/users/{IDENTIFIER}"); let mut actor = Actor::person( @@ -56,6 +142,27 @@ fn local_actor() -> Actor { actor } +fn remote_actor() -> Actor { + let actor_id = "https://remote.example/users/bob"; + let mut actor = Actor::person( + actor_id.parse().expect("valid remote actor IRI"), + format!("{actor_id}/inbox") + .parse() + .expect("valid remote inbox IRI"), + format!("{actor_id}/outbox") + .parse() + .expect("valid remote outbox IRI"), + ); + actor.set_public_key(Reference::object(CryptographicKey::new( + format!("{actor_id}#main-key") + .parse() + .expect("valid remote key IRI"), + actor.id.clone(), + "unused by the unsigned development example".to_string(), + ))); + actor +} + #[tokio::main] async fn main() -> Result<(), Error> { tracing_subscriber::fmt() @@ -68,12 +175,21 @@ async fn main() -> Result<(), Error> { let dispatcher = SingleActorDispatcher { actor: local_actor(), }; - let app = build_router(FederServer::new(dispatcher)); + let services = ExampleServices { + remote_actor: remote_actor(), + latest_follower: Mutex::new(None), + latest_accept: Mutex::new(None), + }; + let server = FederServer::new(dispatcher, services) + .with_inbox_auth_policy(InboxAuthPolicy::AllowUnsignedInsecureDev); + let app = build_router(server); tracing::info!( bind = %bind, actor = %format!("{ORIGIN}/users/{IDENTIFIER}"), - "starting reference actor endpoint example" + webfinger = %format!("{ORIGIN}/.well-known/webfinger"), + inbox = %format!("{ORIGIN}/users/{IDENTIFIER}/inbox"), + "starting reference ActivityPub server example" ); let listener = tokio::net::TcpListener::bind(bind) From 1a461d30c4f931e4599aa3dcf4642da8e2894bf8 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 2 Aug 2026 00:55:34 +0900 Subject: [PATCH 51/72] Add server storage capability to reference core Define ServerStorage as the application-owned persistence boundary for follower relationships and per-actor signing keys. Allow runtimes to persist Follow outcomes and retrieve the appropriate ActorKeyPair without retaining protocol state inside the core. Assisted-by: Codex:gpt-5.6-sol --- Cargo.lock | 4 ++++ crates/ref-feder-core/src/storage.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 crates/ref-feder-core/src/storage.rs diff --git a/Cargo.lock b/Cargo.lock index 0051508..364b16c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1142,12 +1142,16 @@ dependencies = [ "axum", "feder-vocab", "httpdate", + "ipnet", "mime", "percent-encoding", "ref-feder-core", + "reqwest", "serde", "serde_json", "thiserror", + "tokio", + "url", ] [[package]] diff --git a/crates/ref-feder-core/src/storage.rs b/crates/ref-feder-core/src/storage.rs new file mode 100644 index 0000000..c054153 --- /dev/null +++ b/crates/ref-feder-core/src/storage.rs @@ -0,0 +1,26 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use feder_vocab::{Actor, Iri}; + +use crate::key::ActorKeyPair; + +pub trait ServerStorage { + type Error; + + fn store_follower(&self, follower: &Actor, following: &Iri) -> Result<(), Self::Error>; + + fn load_actor_key_pair(&self, actor_id: &Iri) -> Result, Self::Error>; +} From 91195ee467d4d2fe5de278ed3de4eee1d7444595 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 2 Aug 2026 01:30:28 +0900 Subject: [PATCH 52/72] Add signed networking to reference server runtime Port protected remote actor and key resolution to the reference server runtime. Reject private and special-use destinations by default, disable redirects and proxies, and enforce request timeouts and response size limits. Give FederServer concrete resolver and activity sender components. Verify signed inbox requests, persist Follow relationships through ServerStorage, load per-actor signing keys, and deliver signed Accept activities. Migrate the reference actor-server example to ServerStorage and the fallible server constructor. Use the bundled test key pair and a local recipient inbox to demonstrate signed Accept delivery without generating keys at startup. Assisted-by: Codex:gpt-5.6-sol --- Cargo.lock | 1 + crates/ref-feder-core/src/lib.rs | 1 + crates/ref-feder-runtime-server/Cargo.toml | 8 + crates/ref-feder-runtime-server/src/actor.rs | 218 +++++++++++++++++- crates/ref-feder-runtime-server/src/config.rs | 24 ++ crates/ref-feder-runtime-server/src/inbox.rs | 88 +++---- crates/ref-feder-runtime-server/src/lib.rs | 72 ++++-- crates/ref-feder-runtime-server/src/send.rs | 162 +++++++++++++ crates/ref-feder-runtime-server/src/url.rs | 148 ++++++++++++ examples/ref-actor-server/Cargo.toml | 1 + examples/ref-actor-server/README.md | 24 +- examples/ref-actor-server/src/main.rs | 142 +++++------- 12 files changed, 719 insertions(+), 170 deletions(-) create mode 100644 crates/ref-feder-runtime-server/src/config.rs create mode 100644 crates/ref-feder-runtime-server/src/send.rs create mode 100644 crates/ref-feder-runtime-server/src/url.rs diff --git a/Cargo.lock b/Cargo.lock index 364b16c..20fe1af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1119,6 +1119,7 @@ version = "0.1.0" dependencies = [ "axum", "feder-vocab", + "ref-feder-core", "ref-feder-runtime-server", "tokio", "tracing", diff --git a/crates/ref-feder-core/src/lib.rs b/crates/ref-feder-core/src/lib.rs index 6ab0667..4a81516 100644 --- a/crates/ref-feder-core/src/lib.rs +++ b/crates/ref-feder-core/src/lib.rs @@ -27,6 +27,7 @@ use feder_vocab::Actor; pub mod follow; pub mod key; +pub mod storage; #[derive(Debug, Default)] pub struct FederCore; diff --git a/crates/ref-feder-runtime-server/Cargo.toml b/crates/ref-feder-runtime-server/Cargo.toml index 181a3e7..4e7bae5 100644 --- a/crates/ref-feder-runtime-server/Cargo.toml +++ b/crates/ref-feder-runtime-server/Cargo.toml @@ -16,9 +16,17 @@ httpdate = "1" mime = "0.3.17" percent-encoding = "2.3.2" ref-feder-core.workspace = true +reqwest = { + version = "0.13.1", + default-features = false, + features = ["rustls"], +} serde.workspace = true serde_json.workspace = true thiserror = "2.0.19" +url = "2" +ipnet = "2.11.0" +tokio = { version = "1", features = ["net"] } [lints] workspace = true diff --git a/crates/ref-feder-runtime-server/src/actor.rs b/crates/ref-feder-runtime-server/src/actor.rs index 611a27e..9c0843d 100644 --- a/crates/ref-feder-runtime-server/src/actor.rs +++ b/crates/ref-feder-runtime-server/src/actor.rs @@ -21,11 +21,18 @@ use axum::{ http::{HeaderMap, StatusCode, header}, response::{IntoResponse, Response}, }; -use feder_vocab::Actor; - +use feder_vocab::{Actor, ActorType, CryptographicKey, Endpoints, Iri, Reference}; use ref_feder_core::ActorDispatcher; +use reqwest::{ + Client, StatusCode as HttpStatusCode, Url, + header::{ACCEPT, CONTENT_TYPE}, +}; +use serde::Deserialize; + +use crate::{FederServer, config::OutboundAddressPolicy, negotiation::accepts_activitypub, url}; -use crate::{FederServer, negotiation::accepts_activitypub}; +const MAX_ACTOR_BODY_SIZE: usize = 1_048_576; +const ACTIVITYPUB_ACCEPT: &str = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""; impl ActorDispatcher for FederServer where @@ -64,3 +71,208 @@ where ) .into_response()) } + +#[derive(Clone, Debug)] +pub struct ActorResolver { + client: Client, + address_policy: OutboundAddressPolicy, +} + +impl ActorResolver { + pub fn new(address_policy: OutboundAddressPolicy) -> Result { + let client = url::build_client(address_policy).map_err(ActorResolveError::BuildClient)?; + Ok(Self { + client, + address_policy, + }) + } + + pub async fn resolve_reference( + &self, + reference: &mut Reference, + ) -> Result<(), ActorResolveError> { + let Reference::Id(actor_id) = reference else { + return Ok(()); + }; + let actor_id = actor_id.clone(); + let actor = self.resolve(&actor_id).await?; + *reference = Reference::object(actor); + Ok(()) + } + + pub async fn resolve(&self, actor_id: &Iri) -> Result { + let body = self.fetch_document(actor_id).await?; + let document: ActorDocument = + serde_json::from_slice(&body).map_err(ActorResolveError::Deserialize)?; + let actor = document.into_actor(); + if actor.id != *actor_id { + return Err(ActorResolveError::ActorIdMismatch { + requested: actor_id.to_string(), + returned: actor.id.to_string(), + }); + } + + Ok(actor) + } + + pub(crate) async fn resolve_key( + &self, + key_id: &Iri, + ) -> Result { + let body = self.fetch_document(key_id).await?; + if let Ok(key) = serde_json::from_slice::(&body) + && key.id == *key_id + { + return Ok(key); + } + if let Ok(document) = serde_json::from_slice::(&body) + && let Some(Reference::Object(key)) = document.public_key + && key.id == *key_id + { + return Ok(*key); + } + + Err(ActorResolveError::KeyNotFound(key_id.to_string())) + } + + async fn fetch_document(&self, resource_id: &Iri) -> Result, ActorResolveError> { + let url = Url::parse(resource_id.as_str()) + .map_err(|_| ActorResolveError::InvalidResourceId(resource_id.to_string()))?; + if !matches!(url.scheme(), "http" | "https") + || !url.username().is_empty() + || url.password().is_some() + || url.host().is_none() + { + return Err(ActorResolveError::InvalidResourceId( + resource_id.to_string(), + )); + } + url::validate_literal_host(&url, self.address_policy).map_err(|address| { + ActorResolveError::PrivateResourceAddress { + resource: resource_id.to_string(), + address, + } + })?; + + let mut response = self + .client + .get(url) + .header(ACCEPT, ACTIVITYPUB_ACCEPT) + .send() + .await + .map_err(ActorResolveError::Request)?; + if !response.status().is_success() { + return Err(ActorResolveError::UnsuccessfulStatus { + resource: resource_id.to_string(), + status: response.status(), + }); + } + let content_type = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + if !is_activitypub_content_type(content_type) { + return Err(ActorResolveError::UnsupportedContentType( + content_type.to_string(), + )); + } + if response + .content_length() + .is_some_and(|length| length > MAX_ACTOR_BODY_SIZE as u64) + { + return Err(ActorResolveError::ResponseTooLarge); + } + + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(ActorResolveError::Request)? { + if body.len().saturating_add(chunk.len()) > MAX_ACTOR_BODY_SIZE { + return Err(ActorResolveError::ResponseTooLarge); + } + body.extend_from_slice(&chunk); + } + + Ok(body) + } +} + +fn is_activitypub_content_type(content_type: &str) -> bool { + let media_type = content_type + .split_once(';') + .map_or(content_type, |(media_type, _)| media_type) + .trim(); + media_type.eq_ignore_ascii_case("application/activity+json") + || media_type.eq_ignore_ascii_case("application/ld+json") +} + +#[derive(Deserialize)] +struct ActorDocument { + #[serde(rename = "type")] + kind: ActorType, + id: Iri, + inbox: Iri, + outbox: Iri, + followers: Option, + #[serde(rename = "preferredUsername")] + preferred_username: Option, + name: Option, + endpoints: Option, + #[serde(rename = "publicKey")] + public_key: Option>, +} + +impl ActorDocument { + fn into_actor(self) -> Actor { + Actor { + context: None, + kind: self.kind, + id: self.id, + inbox: self.inbox, + outbox: self.outbox, + followers: self.followers, + preferred_username: self.preferred_username, + name: self.name, + endpoints: self.endpoints, + public_key: self.public_key, + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ActorResolveError { + #[error("failed to build actor resolution HTTP client")] + BuildClient(#[source] reqwest::Error), + + #[error("invalid remote ActivityPub resource ID: {0}")] + InvalidResourceId(String), + + #[error("remote ActivityPub resource {resource} uses non-public address {address}")] + PrivateResourceAddress { + resource: String, + address: std::net::IpAddr, + }, + + #[error("failed to fetch remote ActivityPub resource")] + Request(#[source] reqwest::Error), + + #[error("fetching remote ActivityPub resource {resource} returned {status}")] + UnsuccessfulStatus { + resource: String, + status: HttpStatusCode, + }, + + #[error("remote actor response has unsupported content type: {0}")] + UnsupportedContentType(String), + + #[error("remote actor response exceeds size limit")] + ResponseTooLarge, + + #[error("failed to deserialize remote actor")] + Deserialize(#[source] serde_json::Error), + + #[error("remote actor ID mismatch: requested {requested}, returned {returned}")] + ActorIdMismatch { requested: String, returned: String }, + + #[error("remote ActivityPub document does not contain key {0}")] + KeyNotFound(String), +} diff --git a/crates/ref-feder-runtime-server/src/config.rs b/crates/ref-feder-runtime-server/src/config.rs new file mode 100644 index 0000000..d645672 --- /dev/null +++ b/crates/ref-feder-runtime-server/src/config.rs @@ -0,0 +1,24 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum OutboundAddressPolicy { + /// Allows only publicly routable destination addresses. + #[default] + PublicOnly, + + /// Allows private and special-use destinations. This disables SSRF protection. + AllowPrivateAddress, +} diff --git a/crates/ref-feder-runtime-server/src/inbox.rs b/crates/ref-feder-runtime-server/src/inbox.rs index 6845305..8e4c35b 100644 --- a/crates/ref-feder-runtime-server/src/inbox.rs +++ b/crates/ref-feder-runtime-server/src/inbox.rs @@ -15,7 +15,6 @@ use std::{ collections::{BTreeMap, HashSet}, - future::Future, sync::Arc, time::{Duration, SystemTime}, }; @@ -30,16 +29,17 @@ use axum::{ }, response::{IntoResponse, Response}, }; -use feder_vocab::{Accept, Actor, CryptographicKey, Follow, Iri, Reference}; +use feder_vocab::{Actor, CryptographicKey, Follow, Iri, Reference}; use mime::Mime; use ref_feder_core::{ ActorDispatcher, follow::{FollowError, receive_follow}, key::{create_sha256_digest_header, verify_draft_cavage}, + storage::ServerStorage, }; use serde_json::{Value, from_slice, from_value}; -use crate::FederServer; +use crate::{ActorResolver, FederServer}; const MAX_SIGNATURE_AGE: Duration = Duration::from_secs(65 * 60); const MAX_CLOCK_SKEW: Duration = Duration::from_secs(60 * 60); @@ -55,37 +55,6 @@ pub enum InboxAuthPolicy { RequireSigned, } -pub trait FollowStore { - type Error; - - fn store_follower(&self, follower: &Actor, following: &Iri) -> Result<(), Self::Error>; -} - -pub trait RemoteResolver { - type Error; - - fn resolve_actor<'a>( - &'a self, - actor_id: &'a Iri, - ) -> impl Future> + Send + 'a; - - fn resolve_key<'a>( - &'a self, - key_id: &'a Iri, - ) -> impl Future> + Send + 'a; -} - -pub trait ActivitySender { - type Error; - - fn send_accept<'a>( - &'a self, - local_actor: &'a Actor, - accept: &'a Accept, - inbox: &'a Iri, - ) -> impl Future> + Send + 'a; -} - struct InboxRequest { headers: HeaderMap, method: Method, @@ -103,7 +72,7 @@ pub async fn inbox( ) -> Result where A: ActorDispatcher, - S: ActivitySender + FollowStore + RemoteResolver, + S: ServerStorage, { let local_actor = server .actors() @@ -132,7 +101,7 @@ where InboxAuthPolicy::AllowUnsignedInsecureDev => None, InboxAuthPolicy::RequireSigned => Some( verify_signed_request( - server.services(), + server.resolver(), &local_actor, &request, activity_actor_id.as_ref().ok_or(StatusCode::UNAUTHORIZED)?, @@ -148,7 +117,7 @@ where let follow: Follow = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; let remote_actor = match verified_actor { Some(actor) => actor, - None => resolve_follow_actor(server.services(), &follow).await?, + None => resolve_follow_actor(server.resolver(), &follow).await?, }; let accept_id = accept_id_for_follow(&local_actor.id, &follow.id)?; let outcome = match receive_follow(&local_actor, &remote_actor, follow, accept_id) { @@ -158,40 +127,49 @@ where }; server - .services() + .storage() .store_follower(&outcome.follower, &outcome.following) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let key_pair = server + .storage() + .load_actor_key_pair(&local_actor.id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + server - .services() - .send_accept(&local_actor, &outcome.accept, &outcome.recipient_inbox) + .sender() + .send_activity( + &local_actor, + &key_pair, + &outcome.accept, + &outcome.recipient_inbox, + ) .await .map_err(|_| StatusCode::BAD_GATEWAY)?; Ok(StatusCode::ACCEPTED.into_response()) } -async fn resolve_follow_actor(services: &S, follow: &Follow) -> Result -where - S: RemoteResolver, -{ +async fn resolve_follow_actor( + resolver: &ActorResolver, + follow: &Follow, +) -> Result { match &follow.actor { Reference::Object(actor) => Ok((**actor).clone()), - Reference::Id(actor_id) => services - .resolve_actor(actor_id) + Reference::Id(actor_id) => resolver + .resolve(actor_id) .await .map_err(|_| StatusCode::BAD_GATEWAY), } } -async fn verify_signed_request( - services: &S, +async fn verify_signed_request( + resolver: &ActorResolver, local_actor: &Actor, request: &InboxRequest, activity_actor_id: &Iri, -) -> Result -where - S: RemoteResolver, -{ +) -> Result { let signature_header = request .headers .get("signature") @@ -232,7 +210,7 @@ where .key_id .parse() .map_err(|_| StatusCode::UNAUTHORIZED)?; - let public_key = services + let public_key = resolver .resolve_key(&key_id) .await .map_err(|_| StatusCode::BAD_GATEWAY)?; @@ -253,8 +231,8 @@ where ) .map_err(|_| StatusCode::UNAUTHORIZED)?; - let actor = services - .resolve_actor(activity_actor_id) + let actor = resolver + .resolve(activity_actor_id) .await .map_err(|_| StatusCode::BAD_GATEWAY)?; if actor.id != *activity_actor_id || !actor_owns_key(&actor, &public_key) { diff --git a/crates/ref-feder-runtime-server/src/lib.rs b/crates/ref-feder-runtime-server/src/lib.rs index 1c99b47..5692403 100644 --- a/crates/ref-feder-runtime-server/src/lib.rs +++ b/crates/ref-feder-runtime-server/src/lib.rs @@ -18,22 +18,28 @@ //! This crate develops runtime orchestration against `ref-feder-core` while //! the production `feder-runtime-server` remains operational. Its API is //! intentionally unstable during the architecture refactoring. +pub mod actor; +pub mod config; +pub mod inbox; +pub mod negotiation; +pub mod send; +pub mod url; +pub mod webfinger; use std::sync::Arc; +pub use actor::{ActorResolveError, ActorResolver}; use axum::{ Router, extract::DefaultBodyLimit, routing::{get, post}, }; +pub use config::OutboundAddressPolicy; +pub use inbox::InboxAuthPolicy; pub use ref_feder_core::ActorDispatcher; +use ref_feder_core::storage::ServerStorage; -pub mod actor; -pub mod inbox; -pub mod negotiation; -pub mod webfinger; - -pub use inbox::{ActivitySender, FollowStore, InboxAuthPolicy, RemoteResolver}; +use crate::send::{ActivitySender, SendError}; #[derive(Debug, thiserror::Error)] pub enum Error { @@ -42,21 +48,51 @@ pub enum Error { #[error("server failed")] Serve(#[source] std::io::Error), + + #[error("failed to construct activity sender")] + ActivitySender(#[from] SendError), + + #[error("failed to construct actor resolver")] + ActorResolver(#[from] ActorResolveError), } pub struct FederServer { actors: A, - services: S, + storage: S, + resolver: ActorResolver, + sender: ActivitySender, inbox_auth_policy: InboxAuthPolicy, } impl FederServer { - pub fn new(actors: A, services: S) -> Self { - Self { + pub fn new(actors: A, storage: S) -> Result { + let policy = OutboundAddressPolicy::PublicOnly; + let resolver = ActorResolver::new(policy)?; + let sender = ActivitySender::new(policy)?; + Ok(Self { + actors, + storage, + resolver, + sender, + inbox_auth_policy: InboxAuthPolicy::RequireSigned, + }) + } + + // for development + pub fn with_outbound_address_policy( + actors: A, + storage: S, + policy: OutboundAddressPolicy, + ) -> Result { + let resolver = ActorResolver::new(policy)?; + let sender = ActivitySender::new(policy)?; + Ok(Self { actors, - services, + storage, + resolver, + sender, inbox_auth_policy: InboxAuthPolicy::RequireSigned, - } + }) } #[must_use] @@ -69,8 +105,16 @@ impl FederServer { &self.actors } - pub(crate) fn services(&self) -> &S { - &self.services + pub(crate) fn storage(&self) -> &S { + &self.storage + } + + pub(crate) fn resolver(&self) -> &ActorResolver { + &self.resolver + } + + pub(crate) fn sender(&self) -> &ActivitySender { + &self.sender } pub(crate) fn inbox_auth_policy(&self) -> InboxAuthPolicy { @@ -81,7 +125,7 @@ impl FederServer { pub fn build_router(server: FederServer) -> Router where A: ActorDispatcher + Send + Sync + 'static, - S: ActivitySender + FollowStore + RemoteResolver + Send + Sync + 'static, + S: ServerStorage + Send + Sync + 'static, { let server = Arc::new(server); diff --git a/crates/ref-feder-runtime-server/src/send.rs b/crates/ref-feder-runtime-server/src/send.rs new file mode 100644 index 0000000..8763467 --- /dev/null +++ b/crates/ref-feder-runtime-server/src/send.rs @@ -0,0 +1,162 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::time::SystemTime; + +use feder_vocab::{Actor, Iri, Reference}; +use ref_feder_core::key::{ + ActorKeyPair, HttpSignatureError, create_sha256_digest_header, sign_draft_cavage, +}; +use reqwest::{ + Client, StatusCode, Url, + header::{CONTENT_TYPE, DATE, HOST}, +}; +use serde::Serialize; + +use crate::{config::OutboundAddressPolicy, url}; + +#[derive(Clone, Debug)] +pub struct ActivitySender { + client: Client, + address_policy: OutboundAddressPolicy, +} + +impl ActivitySender { + /// Creates a signed ActivityPub HTTP sender. + pub fn new(address_policy: OutboundAddressPolicy) -> Result { + let client = url::build_client(address_policy).map_err(SendError::BuildClient)?; + + Ok(Self { + client, + address_policy, + }) + } + + pub async fn send_activity( + &self, + local_actor: &Actor, + key_pair: &ActorKeyPair, + activity: &T, + inbox: &Iri, + ) -> Result<(), SendError> + where + T: Serialize + ?Sized, + { + let key_id = match local_actor.public_key.as_ref() { + Some(Reference::Id(key_id)) => key_id, + Some(Reference::Object(key)) => { + if key.owner != local_actor.id || key.public_key_pem != key_pair.public_key_pem() { + return Err(SendError::ActorKeyMismatch(local_actor.id.to_string())); + } + &key.id + } + None => return Err(SendError::MissingActorKey(local_actor.id.to_string())), + }; + let body = serde_json::to_vec(activity).map_err(SendError::Serialize)?; + let url = + Url::parse(inbox.as_str()).map_err(|_| SendError::InvalidInbox(inbox.to_string()))?; + if !matches!(url.scheme(), "http" | "https") + || !url.username().is_empty() + || url.password().is_some() + || url.host().is_none() + { + return Err(SendError::InvalidInbox(inbox.to_string())); + } + crate::url::validate_literal_host(&url, self.address_policy).map_err(|address| { + SendError::PrivateInboxAddress { + inbox: inbox.to_string(), + address, + } + })?; + let mut host = url + .host() + .ok_or_else(|| SendError::InvalidInbox(inbox.to_string()))? + .to_string(); + if let Some(port) = url.port() { + host = format!("{host}:{port}"); + } + let date = httpdate::fmt_http_date(SystemTime::now()); + let digest = create_sha256_digest_header(&body); + let headers = [ + ("content-type", "application/activity+json"), + ("date", date.as_str()), + ("digest", digest.as_str()), + ("host", host.as_str()), + ]; + let mut request_target = url.path().to_string(); + if let Some(query) = url.query() { + request_target.push('?'); + request_target.push_str(query); + } + let signature = + sign_draft_cavage(key_pair, key_id.as_str(), "POST", &request_target, &headers) + .map_err(SendError::Sign)?; + + let response = self + .client + .post(url) + .header(CONTENT_TYPE, "application/activity+json") + .header(DATE, date) + .header("Digest", digest) + .header(HOST, host) + .header("Signature", signature) + .body(body) + .send() + .await + .map_err(SendError::Request)?; + + if !response.status().is_success() { + return Err(SendError::UnsuccessfulStatus { + inbox: inbox.to_string(), + status: response.status(), + }); + } + + Ok(()) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum SendError { + #[error("failed to build HTTP client")] + BuildClient(#[source] reqwest::Error), + + #[error("failed to serialize activity")] + Serialize(#[source] serde_json::Error), + + #[error("local actor {0} does not advertise a signing key")] + MissingActorKey(String), + + #[error("stored signing key does not match local actor {0}")] + ActorKeyMismatch(String), + + #[error("invalid recipient inbox: {0}")] + InvalidInbox(String), + + #[error("recipient inbox {inbox} resolves to non-public address {address}")] + PrivateInboxAddress { + inbox: String, + address: std::net::IpAddr, + }, + + #[error("failed to sign activity request")] + Sign(#[source] HttpSignatureError), + + #[error("failed to send activity")] + Request(#[source] reqwest::Error), + + #[error("sending activity to {inbox} returned {status}")] + UnsuccessfulStatus { inbox: String, status: StatusCode }, +} diff --git a/crates/ref-feder-runtime-server/src/url.rs b/crates/ref-feder-runtime-server/src/url.rs new file mode 100644 index 0000000..f16c90a --- /dev/null +++ b/crates/ref-feder-runtime-server/src/url.rs @@ -0,0 +1,148 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::{ + io, + net::{IpAddr, SocketAddr}, + sync::LazyLock, + time::Duration, +}; + +use ipnet::IpNet; +use reqwest::{ + Client, Url, + dns::{Addrs, Name, Resolve, Resolving}, + redirect::Policy, +}; +use url::Host; + +use crate::config::OutboundAddressPolicy; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +const NON_PUBLIC_NETWORK_CIDRS: &[&str] = &[ + "0.0.0.0/8", + "10.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.0.0.0/24", + "192.0.2.0/24", + "192.88.99.0/24", + "192.168.0.0/16", + "198.18.0.0/15", + "198.51.100.0/24", + "203.0.113.0/24", + "224.0.0.0/4", + "240.0.0.0/4", + "::/128", + "::1/128", + "64:ff9b::/96", + "64:ff9b:1::/48", + "100::/64", + "100:0:0:1::/64", + "2001::/23", + "2001:db8::/32", + "2002::/16", + "3fff::/20", + "5f00::/16", + "fc00::/7", + "fe80::/10", + "ff00::/8", +]; + +static NON_PUBLIC_NETWORKS: LazyLock> = LazyLock::new(|| { + NON_PUBLIC_NETWORK_CIDRS + .iter() + .map(|cidr| cidr.parse().expect("hardcoded network CIDR is valid")) + .collect() +}); + +pub(crate) fn build_client(policy: OutboundAddressPolicy) -> Result { + Client::builder() + .dns_resolver(PublicDnsResolver { policy }) + .redirect(Policy::none()) + .no_proxy() + .connect_timeout(CONNECT_TIMEOUT) + .timeout(REQUEST_TIMEOUT) + .build() +} + +pub(crate) fn validate_literal_host( + url: &Url, + policy: OutboundAddressPolicy, +) -> Result<(), IpAddr> { + if policy == OutboundAddressPolicy::AllowPrivateAddress { + return Ok(()); + } + + match url.host() { + Some(Host::Ipv4(address)) => validate_public_address(address.into()), + Some(Host::Ipv6(address)) => validate_public_address(address.into()), + Some(Host::Domain(_)) | None => Ok(()), + } +} + +#[derive(Clone, Copy, Debug)] +struct PublicDnsResolver { + policy: OutboundAddressPolicy, +} + +impl Resolve for PublicDnsResolver { + fn resolve(&self, name: Name) -> Resolving { + let host = name.as_str().to_string(); + let policy = self.policy; + + Box::pin(async move { + let addresses = tokio::net::lookup_host((host.as_str(), 0)) + .await? + .collect::>(); + if addresses.is_empty() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("{host} resolved to no addresses"), + ) + .into()); + } + if policy == OutboundAddressPolicy::PublicOnly { + for address in &addresses { + validate_public_address(address.ip()).map_err(|blocked| { + io::Error::new( + io::ErrorKind::PermissionDenied, + format!("{host} resolved to non-public address {blocked}"), + ) + })?; + } + } + + Ok(Box::new(addresses.into_iter()) as Addrs) + }) + } +} + +fn validate_public_address(address: IpAddr) -> Result<(), IpAddr> { + if let IpAddr::V6(address) = address + && let Some(mapped) = address.to_ipv4_mapped() + { + return validate_public_address(mapped.into()); + } + let is_public = !NON_PUBLIC_NETWORKS + .iter() + .any(|network| network.contains(&address)); + + if is_public { Ok(()) } else { Err(address) } +} diff --git a/examples/ref-actor-server/Cargo.toml b/examples/ref-actor-server/Cargo.toml index 27272a7..95c610f 100644 --- a/examples/ref-actor-server/Cargo.toml +++ b/examples/ref-actor-server/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] axum = "0.8" feder-vocab.workspace = true +ref-feder-core.workspace = true ref-feder-runtime-server = { path = "../../crates/ref-feder-runtime-server" } tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] } tracing = "0.1" diff --git a/examples/ref-actor-server/README.md b/examples/ref-actor-server/README.md index 03f9ac4..34a719f 100644 --- a/examples/ref-actor-server/README.md +++ b/examples/ref-actor-server/README.md @@ -43,21 +43,27 @@ curl -i \ -H 'Content-Type: application/activity+json' \ --data-binary '{ "@context": "https://www.w3.org/ns/activitystreams", - "id": "https://remote.example/activities/follow/1", + "id": "http://127.0.0.1:3000/remote/activities/follow/1", "type": "Follow", "actor": { - "id": "https://remote.example/users/bob", + "id": "http://127.0.0.1:3000/remote/users/bob", "type": "Person", - "inbox": "https://remote.example/users/bob/inbox", - "outbox": "https://remote.example/users/bob/outbox" + "inbox": "http://127.0.0.1:3000/remote-inbox", + "outbox": "http://127.0.0.1:3000/remote/users/bob/outbox" }, "object": "http://127.0.0.1:3000/users/alice" }' \ http://127.0.0.1:3000/users/alice/inbox ~~~~ -The endpoint stores the latest follower, records the generated `Accept`, and -returns `202 Accepted`. These example adapters deliberately retain only their -latest value, so repeated requests do not grow an in-memory protocol history. -Unsigned inbox requests are enabled only for this local development example; -`FederServer` requires signed requests by default. +The endpoint stores the latest follower, loads Alice's key pair, and sends a +signed `Accept` to the example's `/remote-inbox` recipient before returning +`202 Accepted`. The recipient checks that the request has a `Signature` header +and logs receipt of the activity. + +The example loads its actor key pair from the repository's test fixture and +retains only that pair and the latest follower, so repeated requests do not +grow an in-memory protocol history. The fixture key is public test data and +must never be used for a real actor. Unsigned incoming requests and private +outbound addresses are enabled only for this local development example; +`FederServer` requires signed requests and public destinations by default. diff --git a/examples/ref-actor-server/src/main.rs b/examples/ref-actor-server/src/main.rs index 4c4debf..6412349 100644 --- a/examples/ref-actor-server/src/main.rs +++ b/examples/ref-actor-server/src/main.rs @@ -13,43 +13,46 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use std::{ - convert::Infallible, - fmt, - future::{Future, ready}, - net::SocketAddr, - sync::Mutex, -}; +use std::{convert::Infallible, fmt, net::SocketAddr, sync::Mutex}; -use feder_vocab::{Accept, Actor, CryptographicKey, Endpoints, Iri, Reference}; +use axum::{ + body::Bytes, + http::{HeaderMap, StatusCode}, + routing::post, +}; +use feder_vocab::{Actor, CryptographicKey, Endpoints, Iri, Reference}; +use ref_feder_core::{key::ActorKeyPair, storage::ServerStorage}; use ref_feder_runtime_server::{ - ActivitySender, ActorDispatcher, Error, FederServer, FollowStore, InboxAuthPolicy, - RemoteResolver, build_router, + ActorDispatcher, Error, FederServer, InboxAuthPolicy, OutboundAddressPolicy, build_router, }; const IDENTIFIER: &str = "alice"; const ORIGIN: &str = "http://127.0.0.1:3000"; +const ACTOR_PRIVATE_KEY_PEM: &str = + include_str!("../../../crates/feder-core/tests/fixtures/rsa-private-key.pem"); +const ACTOR_PUBLIC_KEY_PEM: &str = + include_str!("../../../crates/feder-core/tests/fixtures/rsa-public-key.pem"); struct SingleActorDispatcher { actor: Actor, } -struct ExampleServices { - remote_actor: Actor, +struct ExampleStorage { + local_actor_id: Iri, + actor_key_pair: ActorKeyPair, latest_follower: Mutex>, - latest_accept: Mutex>, } #[derive(Debug)] -struct ExampleServiceError(&'static str); +struct ExampleStorageError(&'static str); -impl fmt::Display for ExampleServiceError { +impl fmt::Display for ExampleStorageError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(self.0) } } -impl std::error::Error for ExampleServiceError {} +impl std::error::Error for ExampleStorageError {} impl ActorDispatcher for SingleActorDispatcher { type Error = Infallible; @@ -59,67 +62,25 @@ impl ActorDispatcher for SingleActorDispatcher { } } -impl FollowStore for ExampleServices { - type Error = ExampleServiceError; +impl ServerStorage for ExampleStorage { + type Error = ExampleStorageError; fn store_follower(&self, follower: &Actor, following: &Iri) -> Result<(), Self::Error> { *self .latest_follower .lock() - .map_err(|_| ExampleServiceError("follower state lock poisoned"))? = + .map_err(|_| ExampleStorageError("follower state lock poisoned"))? = Some((follower.clone(), following.clone())); tracing::info!(follower = %follower.id, following = %following, "stored follower"); Ok(()) } -} - -impl RemoteResolver for ExampleServices { - type Error = ExampleServiceError; - - fn resolve_actor<'a>( - &'a self, - actor_id: &'a Iri, - ) -> impl Future> + Send + 'a { - ready(if actor_id == &self.remote_actor.id { - Ok(self.remote_actor.clone()) - } else { - Err(ExampleServiceError("remote actor not found")) - }) - } - - fn resolve_key<'a>( - &'a self, - key_id: &'a Iri, - ) -> impl Future> + Send + 'a { - ready(match self.remote_actor.public_key.as_ref() { - Some(Reference::Object(key)) if key.id == *key_id => Ok((**key).clone()), - _ => Err(ExampleServiceError("remote key not found")), - }) - } -} -impl ActivitySender for ExampleServices { - type Error = ExampleServiceError; - - fn send_accept<'a>( - &'a self, - _local_actor: &'a Actor, - accept: &'a Accept, - inbox: &'a Iri, - ) -> impl Future> + Send + 'a { - let result = self - .latest_accept - .lock() - .map_err(|_| ExampleServiceError("delivery state lock poisoned")) - .map(|mut latest_accept| { - *latest_accept = Some((accept.clone(), inbox.clone())); - tracing::info!(activity = %accept.id, recipient = %inbox, "delivered Accept"); - }); - ready(result) + fn load_actor_key_pair(&self, actor_id: &Iri) -> Result, Self::Error> { + Ok((actor_id == &self.local_actor_id).then(|| self.actor_key_pair.clone())) } } -fn local_actor() -> Actor { +fn local_actor(key_pair: &ActorKeyPair) -> Actor { let actor_id = format!("{ORIGIN}/users/{IDENTIFIER}"); let mut actor = Actor::person( actor_id.parse().expect("valid actor IRI"), @@ -139,30 +100,25 @@ fn local_actor() -> Actor { .expect("valid shared inbox IRI"), ), }); - actor -} - -fn remote_actor() -> Actor { - let actor_id = "https://remote.example/users/bob"; - let mut actor = Actor::person( - actor_id.parse().expect("valid remote actor IRI"), - format!("{actor_id}/inbox") - .parse() - .expect("valid remote inbox IRI"), - format!("{actor_id}/outbox") - .parse() - .expect("valid remote outbox IRI"), - ); actor.set_public_key(Reference::object(CryptographicKey::new( format!("{actor_id}#main-key") .parse() - .expect("valid remote key IRI"), + .expect("valid actor key IRI"), actor.id.clone(), - "unused by the unsigned development example".to_string(), + key_pair.public_key_pem().to_string(), ))); actor } +async fn remote_inbox(headers: HeaderMap, body: Bytes) -> StatusCode { + if !headers.contains_key("signature") { + return StatusCode::UNAUTHORIZED; + } + + tracing::info!(body_size = body.len(), "received signed Accept activity"); + StatusCode::ACCEPTED +} + #[tokio::main] async fn main() -> Result<(), Error> { tracing_subscriber::fmt() @@ -172,17 +128,25 @@ async fn main() -> Result<(), Error> { let bind: SocketAddr = "127.0.0.1:3000" .parse() .expect("valid default bind address"); - let dispatcher = SingleActorDispatcher { - actor: local_actor(), - }; - let services = ExampleServices { - remote_actor: remote_actor(), + let actor_key_pair = ActorKeyPair::from_pem( + ACTOR_PRIVATE_KEY_PEM.to_string(), + ACTOR_PUBLIC_KEY_PEM.to_string(), + ) + .expect("bundled example actor key pair is valid"); + let actor = local_actor(&actor_key_pair); + let storage = ExampleStorage { + local_actor_id: actor.id.clone(), + actor_key_pair, latest_follower: Mutex::new(None), - latest_accept: Mutex::new(None), }; - let server = FederServer::new(dispatcher, services) - .with_inbox_auth_policy(InboxAuthPolicy::AllowUnsignedInsecureDev); - let app = build_router(server); + let dispatcher = SingleActorDispatcher { actor }; + let server = FederServer::with_outbound_address_policy( + dispatcher, + storage, + OutboundAddressPolicy::AllowPrivateAddress, + )? + .with_inbox_auth_policy(InboxAuthPolicy::AllowUnsignedInsecureDev); + let app = build_router(server).route("/remote-inbox", post(remote_inbox)); tracing::info!( bind = %bind, From c23e2838444772c9b5c296e3c1019d0f5fcb4ee8 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 2 Aug 2026 01:54:09 +0900 Subject: [PATCH 53/72] Block deprecated IPv6 site-local destinations Reject the deprecated fec0::/10 site-local range when outbound networking uses the PublicOnly policy, closing an SSRF path through literal URLs and DNS resolution. Assisted-by: Codex:gpt-5.6-sol --- crates/ref-feder-runtime-server/src/url.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ref-feder-runtime-server/src/url.rs b/crates/ref-feder-runtime-server/src/url.rs index f16c90a..012d7be 100644 --- a/crates/ref-feder-runtime-server/src/url.rs +++ b/crates/ref-feder-runtime-server/src/url.rs @@ -61,6 +61,7 @@ const NON_PUBLIC_NETWORK_CIDRS: &[&str] = &[ "3fff::/20", "5f00::/16", "fc00::/7", + "fec0::/10", "fe80::/10", "ff00::/8", ]; From d216b1fe30ab5f8c0e54b1d4e2d5643595726a9b Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 2 Aug 2026 04:36:49 +0900 Subject: [PATCH 54/72] Add follower removal storage capability Extend ServerStorage with an idempotent follower-removal operation for the upcoming Undo Follow transition. Update the reference actor-server storage adapter to remove its retained follower only when both sides of the relationship match. Assisted-by: Codex:gpt-5.6-sol --- crates/ref-feder-core/src/storage.rs | 2 ++ examples/ref-actor-server/src/main.rs | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/crates/ref-feder-core/src/storage.rs b/crates/ref-feder-core/src/storage.rs index c054153..a922ac6 100644 --- a/crates/ref-feder-core/src/storage.rs +++ b/crates/ref-feder-core/src/storage.rs @@ -23,4 +23,6 @@ pub trait ServerStorage { fn store_follower(&self, follower: &Actor, following: &Iri) -> Result<(), Self::Error>; fn load_actor_key_pair(&self, actor_id: &Iri) -> Result, Self::Error>; + + fn remove_follower(&self, follower: &Iri, following: &Iri) -> Result<(), Self::Error>; } diff --git a/examples/ref-actor-server/src/main.rs b/examples/ref-actor-server/src/main.rs index 6412349..c1e79c4 100644 --- a/examples/ref-actor-server/src/main.rs +++ b/examples/ref-actor-server/src/main.rs @@ -78,6 +78,22 @@ impl ServerStorage for ExampleStorage { fn load_actor_key_pair(&self, actor_id: &Iri) -> Result, Self::Error> { Ok((actor_id == &self.local_actor_id).then(|| self.actor_key_pair.clone())) } + + fn remove_follower(&self, follower: &Iri, following: &Iri) -> Result<(), Self::Error> { + let mut latest_follower = self + .latest_follower + .lock() + .map_err(|_| ExampleStorageError("follower state lock poisoned"))?; + if latest_follower + .as_ref() + .is_some_and(|(stored_follower, stored_following)| { + stored_follower.id == *follower && stored_following == following + }) + { + *latest_follower = None; + } + Ok(()) + } } fn local_actor(key_pair: &ActorKeyPair) -> Actor { From 15571e53e027dacd7d986900b1c29efcd0d0865e Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 2 Aug 2026 04:44:12 +0900 Subject: [PATCH 55/72] Add pure Undo Follow transition Validate that an Undo actor owns its embedded Follow and that the Follow targets the local actor. Return a transient follower-removal outcome without retaining protocol state or performing storage operations inside core. Assisted-by: Codex:gpt-5.6-sol --- crates/ref-feder-core/src/lib.rs | 1 + crates/ref-feder-core/src/undo.rs | 79 +++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 crates/ref-feder-core/src/undo.rs diff --git a/crates/ref-feder-core/src/lib.rs b/crates/ref-feder-core/src/lib.rs index 4a81516..e246203 100644 --- a/crates/ref-feder-core/src/lib.rs +++ b/crates/ref-feder-core/src/lib.rs @@ -28,6 +28,7 @@ use feder_vocab::Actor; pub mod follow; pub mod key; pub mod storage; +pub mod undo; #[derive(Debug, Default)] pub struct FederCore; diff --git a/crates/ref-feder-core/src/undo.rs b/crates/ref-feder-core/src/undo.rs new file mode 100644 index 0000000..07d017b --- /dev/null +++ b/crates/ref-feder-core/src/undo.rs @@ -0,0 +1,79 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use core::fmt; + +use feder_vocab::{Actor, Iri, Reference, Undo}; + +/// The transient result of undoing one valid Follow activity. +/// +/// Core does not retain this value or remove anything from storage. A runtime +/// passes `follower` and `following` to its follower-removal capability. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UndoFollowOutcome { + pub follower: Iri, + pub following: Iri, +} + +pub fn receive_undo_follow( + local_actor: &Actor, + remote_actor: &Actor, + undo: Undo, +) -> Result { + if reference_id(&undo.actor) != &remote_actor.id { + return Err(UndoFollowError::WrongActor); + } + + let Reference::Object(follow) = undo.object else { + return Err(UndoFollowError::LinkedFollow); + }; + if reference_id(&follow.actor) != &remote_actor.id { + return Err(UndoFollowError::WrongActor); + } + if reference_id(&follow.object) != &local_actor.id { + return Err(UndoFollowError::WrongObject); + } + + Ok(UndoFollowOutcome { + follower: remote_actor.id.clone(), + following: local_actor.id.clone(), + }) +} + +fn reference_id(reference: &Reference) -> &Iri { + match reference { + Reference::Id(id) => id, + Reference::Object(actor) => &actor.id, + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum UndoFollowError { + LinkedFollow, + WrongActor, + WrongObject, +} + +impl fmt::Display for UndoFollowError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::LinkedFollow => formatter.write_str("Undo does not embed its Follow activity"), + Self::WrongActor => formatter.write_str("Undo actor does not own the embedded Follow"), + Self::WrongObject => formatter.write_str("undone Follow does not target local actor"), + } + } +} + +impl core::error::Error for UndoFollowError {} From d8a6a3d66ba69f76038a03a52e6a9c98d4b457ae Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 2 Aug 2026 12:10:15 +0900 Subject: [PATCH 56/72] Handle Undo Follow activities in reference inbox Dispatch Undo activities through the pure core transition and remove validated follower relationships through ServerStorage. Update the reference actor server example with idempotent follower removal and a documented Follow-to-Undo flow. Assisted-by: Codex:gpt-5.6-sol --- crates/ref-feder-runtime-server/src/inbox.rs | 37 ++++++++++++++++---- examples/ref-actor-server/README.md | 28 +++++++++++++++ examples/ref-actor-server/src/main.rs | 1 + 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/crates/ref-feder-runtime-server/src/inbox.rs b/crates/ref-feder-runtime-server/src/inbox.rs index 8e4c35b..f93c2ac 100644 --- a/crates/ref-feder-runtime-server/src/inbox.rs +++ b/crates/ref-feder-runtime-server/src/inbox.rs @@ -29,13 +29,14 @@ use axum::{ }, response::{IntoResponse, Response}, }; -use feder_vocab::{Actor, CryptographicKey, Follow, Iri, Reference}; +use feder_vocab::{Actor, CryptographicKey, Follow, Iri, Reference, Undo}; use mime::Mime; use ref_feder_core::{ ActorDispatcher, follow::{FollowError, receive_follow}, key::{create_sha256_digest_header, verify_draft_cavage}, storage::ServerStorage, + undo::{UndoFollowError, receive_undo_follow}, }; use serde_json::{Value, from_slice, from_value}; @@ -110,14 +111,36 @@ where ), }; - if value.get("type").and_then(Value::as_str) != Some("Follow") { - return Ok(StatusCode::ACCEPTED.into_response()); + match value.get("type").and_then(Value::as_str) { + Some("Follow") => {} + Some("Undo") => { + let undo: Undo = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; + let remote_actor = match verified_actor { + Some(actor) => actor, + None => resolve_actor_reference(server.resolver(), &undo.actor).await?, + }; + let outcome = match receive_undo_follow(&local_actor, &remote_actor, undo) { + Ok(outcome) => outcome, + Err(UndoFollowError::LinkedFollow | UndoFollowError::WrongObject) => { + return Ok(StatusCode::ACCEPTED.into_response()); + } + Err(UndoFollowError::WrongActor) => return Err(StatusCode::UNAUTHORIZED), + }; + + server + .storage() + .remove_follower(&outcome.follower, &outcome.following) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + return Ok(StatusCode::ACCEPTED.into_response()); + } + _ => return Ok(StatusCode::ACCEPTED.into_response()), } let follow: Follow = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; let remote_actor = match verified_actor { Some(actor) => actor, - None => resolve_follow_actor(server.resolver(), &follow).await?, + None => resolve_actor_reference(server.resolver(), &follow.actor).await?, }; let accept_id = accept_id_for_follow(&local_actor.id, &follow.id)?; let outcome = match receive_follow(&local_actor, &remote_actor, follow, accept_id) { @@ -151,11 +174,11 @@ where Ok(StatusCode::ACCEPTED.into_response()) } -async fn resolve_follow_actor( +async fn resolve_actor_reference( resolver: &ActorResolver, - follow: &Follow, + actor: &Reference, ) -> Result { - match &follow.actor { + match actor { Reference::Object(actor) => Ok((**actor).clone()), Reference::Id(actor_id) => resolver .resolve(actor_id) diff --git a/examples/ref-actor-server/README.md b/examples/ref-actor-server/README.md index 34a719f..7e6e6dc 100644 --- a/examples/ref-actor-server/README.md +++ b/examples/ref-actor-server/README.md @@ -61,6 +61,34 @@ signed `Accept` to the example's `/remote-inbox` recipient before returning `202 Accepted`. The recipient checks that the request has a `Signature` header and logs receipt of the activity. +Undo that Follow: + +~~~~ sh +curl -i \ + -H 'Content-Type: application/activity+json' \ + --data-binary '{ + "@context": "https://www.w3.org/ns/activitystreams", + "id": "http://127.0.0.1:3000/remote/activities/undo/1", + "type": "Undo", + "actor": { + "id": "http://127.0.0.1:3000/remote/users/bob", + "type": "Person", + "inbox": "http://127.0.0.1:3000/remote-inbox", + "outbox": "http://127.0.0.1:3000/remote/users/bob/outbox" + }, + "object": { + "id": "http://127.0.0.1:3000/remote/activities/follow/1", + "type": "Follow", + "actor": "http://127.0.0.1:3000/remote/users/bob", + "object": "http://127.0.0.1:3000/users/alice" + } + }' \ + http://127.0.0.1:3000/users/alice/inbox +~~~~ + +The endpoint validates that Bob owns the embedded Follow, removes the matching +follower relationship, and returns `202 Accepted`. Repeating the Undo is safe. + The example loads its actor key pair from the repository's test fixture and retains only that pair and the latest follower, so repeated requests do not grow an in-memory protocol history. The fixture key is public test data and diff --git a/examples/ref-actor-server/src/main.rs b/examples/ref-actor-server/src/main.rs index c1e79c4..fc51887 100644 --- a/examples/ref-actor-server/src/main.rs +++ b/examples/ref-actor-server/src/main.rs @@ -91,6 +91,7 @@ impl ServerStorage for ExampleStorage { }) { *latest_follower = None; + tracing::info!(%follower, %following, "removed follower"); } Ok(()) } From 69ec63827471a5e8d8703dfb4e98a86b01c03c99 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 2 Aug 2026 13:40:37 +0900 Subject: [PATCH 57/72] Add shared inbox dispatch to reference runtime Add canonical actor ID lookup to ActorDispatcher so shared inbox activities can be routed without assuming an application URL structure. Route Follow and Undo Follow activities from the shared inbox through the existing authentication, core transition, storage, and delivery flow. Update the reference actor server to advertise and demonstrate the shared inbox endpoint. Assisted-by: Codex:gpt-5.6-sol --- crates/ref-feder-core/src/lib.rs | 4 +- crates/ref-feder-runtime-server/src/actor.rs | 4 ++ crates/ref-feder-runtime-server/src/inbox.rs | 65 ++++++++++++++++++++ crates/ref-feder-runtime-server/src/lib.rs | 1 + examples/ref-actor-server/README.md | 11 ++-- examples/ref-actor-server/src/main.rs | 5 ++ 6 files changed, 84 insertions(+), 6 deletions(-) diff --git a/crates/ref-feder-core/src/lib.rs b/crates/ref-feder-core/src/lib.rs index e246203..1593d74 100644 --- a/crates/ref-feder-core/src/lib.rs +++ b/crates/ref-feder-core/src/lib.rs @@ -23,7 +23,7 @@ extern crate alloc; pub use feder_vocab as vocab; -use feder_vocab::Actor; +use feder_vocab::{Actor, Iri}; pub mod follow; pub mod key; @@ -37,4 +37,6 @@ pub trait ActorDispatcher { type Error; fn get_actor(&self, identifier: &str) -> Result, Self::Error>; + + fn get_actor_by_id(&self, actor_id: &Iri) -> Result, Self::Error>; } diff --git a/crates/ref-feder-runtime-server/src/actor.rs b/crates/ref-feder-runtime-server/src/actor.rs index 9c0843d..ae6784d 100644 --- a/crates/ref-feder-runtime-server/src/actor.rs +++ b/crates/ref-feder-runtime-server/src/actor.rs @@ -43,6 +43,10 @@ where fn get_actor(&self, identifier: &str) -> Result, Self::Error> { self.actors().get_actor(identifier) } + + fn get_actor_by_id(&self, actor_id: &Iri) -> Result, Self::Error> { + self.actors().get_actor_by_id(actor_id) + } } pub async fn actor( diff --git a/crates/ref-feder-runtime-server/src/inbox.rs b/crates/ref-feder-runtime-server/src/inbox.rs index f93c2ac..d32a51a 100644 --- a/crates/ref-feder-runtime-server/src/inbox.rs +++ b/crates/ref-feder-runtime-server/src/inbox.rs @@ -80,6 +80,43 @@ where .get_actor(&identifier) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; + let (request, value) = parse_inbox_request(headers, method, uri, body)?; + + receive_activity(&server, local_actor, request, value).await +} + +pub async fn shared_inbox( + State(server): State>>, + headers: HeaderMap, + method: Method, + uri: Uri, + body: Bytes, +) -> Result +where + A: ActorDispatcher, + S: ServerStorage, +{ + let (request, value) = parse_inbox_request(headers, method, uri, body)?; + let Some(target_id) = activity_target_id(&value) else { + return Ok(StatusCode::ACCEPTED.into_response()); + }; + let Some(local_actor) = server + .actors() + .get_actor_by_id(&target_id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + else { + return Ok(StatusCode::ACCEPTED.into_response()); + }; + + receive_activity(&server, local_actor, request, value).await +} + +fn parse_inbox_request( + headers: HeaderMap, + method: Method, + uri: Uri, + body: Bytes, +) -> Result<(InboxRequest, Value), StatusCode> { let content_type = headers .get(CONTENT_TYPE) .and_then(|value| value.to_str().ok()) @@ -97,6 +134,20 @@ where body, }; let value: Value = from_slice(&request.body).map_err(|_| StatusCode::BAD_REQUEST)?; + + Ok((request, value)) +} + +async fn receive_activity( + server: &FederServer, + local_actor: Actor, + request: InboxRequest, + value: Value, +) -> Result +where + A: ActorDispatcher, + S: ServerStorage, +{ let activity_actor_id = activity_actor_id(&value); let verified_actor = match server.inbox_auth_policy() { InboxAuthPolicy::AllowUnsignedInsecureDev => None, @@ -296,6 +347,20 @@ fn activity_actor_id(value: &Value) -> Option { .ok() } +fn activity_target_id(value: &Value) -> Option { + let target = match value.get("type").and_then(Value::as_str) { + Some("Follow") => value.get("object")?, + Some("Undo") => value.get("object")?.get("object")?, + _ => return None, + }; + + target + .as_str() + .or_else(|| target.get("id").and_then(Value::as_str))? + .parse() + .ok() +} + fn verify_request_host(headers: &HeaderMap, inbox: &Iri) -> Result<(), StatusCode> { let signed_host = headers .get(HOST) diff --git a/crates/ref-feder-runtime-server/src/lib.rs b/crates/ref-feder-runtime-server/src/lib.rs index 5692403..b2e6129 100644 --- a/crates/ref-feder-runtime-server/src/lib.rs +++ b/crates/ref-feder-runtime-server/src/lib.rs @@ -133,6 +133,7 @@ where .route("/users/{identifier}", get(actor::actor::)) .route("/.well-known/webfinger", get(webfinger::webfinger::)) .route("/users/{identifier}/inbox", post(inbox::inbox::)) + .route("/inbox", post(inbox::shared_inbox::)) .layer(DefaultBodyLimit::max(1_048_576)) .with_state(server) } diff --git a/examples/ref-actor-server/README.md b/examples/ref-actor-server/README.md index 7e6e6dc..d3caac2 100644 --- a/examples/ref-actor-server/README.md +++ b/examples/ref-actor-server/README.md @@ -1,8 +1,8 @@ Reference ActivityPub Server ============================ -Minimal actor, WebFinger, and personal inbox endpoints using `ref-feder-core` -capabilities through `ref-feder-runtime-server`. +Minimal actor, WebFinger, personal inbox, and shared inbox endpoints using +`ref-feder-core` capabilities through `ref-feder-runtime-server`. Run @@ -36,7 +36,8 @@ The endpoint returns `application/jrd+json` with a `self` link to `http://127.0.0.1:3000/users/alice`. The domain in the `acct:` resource must match the request's `Host` header. -Send an unsigned development Follow to Alice's personal inbox: +Send an unsigned development Follow to the shared inbox. The runtime selects +Alice from the Follow's `object` IRI: ~~~~ sh curl -i \ @@ -53,7 +54,7 @@ curl -i \ }, "object": "http://127.0.0.1:3000/users/alice" }' \ - http://127.0.0.1:3000/users/alice/inbox + http://127.0.0.1:3000/inbox ~~~~ The endpoint stores the latest follower, loads Alice's key pair, and sends a @@ -83,7 +84,7 @@ curl -i \ "object": "http://127.0.0.1:3000/users/alice" } }' \ - http://127.0.0.1:3000/users/alice/inbox + http://127.0.0.1:3000/inbox ~~~~ The endpoint validates that Bob owns the embedded Follow, removes the matching diff --git a/examples/ref-actor-server/src/main.rs b/examples/ref-actor-server/src/main.rs index fc51887..c055a41 100644 --- a/examples/ref-actor-server/src/main.rs +++ b/examples/ref-actor-server/src/main.rs @@ -60,6 +60,10 @@ impl ActorDispatcher for SingleActorDispatcher { fn get_actor(&self, identifier: &str) -> Result, Self::Error> { Ok((identifier == IDENTIFIER).then(|| self.actor.clone())) } + + fn get_actor_by_id(&self, actor_id: &Iri) -> Result, Self::Error> { + Ok((actor_id == &self.actor.id).then(|| self.actor.clone())) + } } impl ServerStorage for ExampleStorage { @@ -170,6 +174,7 @@ async fn main() -> Result<(), Error> { actor = %format!("{ORIGIN}/users/{IDENTIFIER}"), webfinger = %format!("{ORIGIN}/.well-known/webfinger"), inbox = %format!("{ORIGIN}/users/{IDENTIFIER}/inbox"), + shared_inbox = %format!("{ORIGIN}/inbox"), "starting reference ActivityPub server example" ); From a91b0bf7992a6b3ee1cec914864cca7a4dbde9ae Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 2 Aug 2026 13:50:10 +0900 Subject: [PATCH 58/72] Expose followers collection in reference runtime Add a follower-listing capability to ServerStorage and expose each local actor's followers as an ActivityStreams OrderedCollection. Advertise the collection from the example actor and demonstrate how Follow and Undo requests update its contents. Assisted-by: Codex:gpt-5.6-sol --- crates/ref-feder-core/src/storage.rs | 4 ++ .../ref-feder-runtime-server/src/followers.rs | 67 +++++++++++++++++++ crates/ref-feder-runtime-server/src/lib.rs | 5 ++ examples/ref-actor-server/README.md | 12 ++++ examples/ref-actor-server/src/main.rs | 17 +++++ 5 files changed, 105 insertions(+) create mode 100644 crates/ref-feder-runtime-server/src/followers.rs diff --git a/crates/ref-feder-core/src/storage.rs b/crates/ref-feder-core/src/storage.rs index a922ac6..71c2799 100644 --- a/crates/ref-feder-core/src/storage.rs +++ b/crates/ref-feder-core/src/storage.rs @@ -13,6 +13,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +use alloc::vec::Vec; + use feder_vocab::{Actor, Iri}; use crate::key::ActorKeyPair; @@ -25,4 +27,6 @@ pub trait ServerStorage { fn load_actor_key_pair(&self, actor_id: &Iri) -> Result, Self::Error>; fn remove_follower(&self, follower: &Iri, following: &Iri) -> Result<(), Self::Error>; + + fn list_followers(&self, following: &Iri) -> Result, Self::Error>; } diff --git a/crates/ref-feder-runtime-server/src/followers.rs b/crates/ref-feder-runtime-server/src/followers.rs new file mode 100644 index 0000000..aa45eb4 --- /dev/null +++ b/crates/ref-feder-runtime-server/src/followers.rs @@ -0,0 +1,67 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, State}, + http::{HeaderMap, StatusCode, header}, + response::{IntoResponse, Response}, +}; +use feder_vocab::OrderedCollection; +use ref_feder_core::{ActorDispatcher, storage::ServerStorage}; + +use crate::{FederServer, negotiation::accepts_activitypub}; + +pub async fn followers( + State(server): State>>, + Path(identifier): Path, + headers: HeaderMap, +) -> Result +where + A: ActorDispatcher, + S: ServerStorage, +{ + let actor = server + .actors() + .get_actor(&identifier) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + if !accepts_activitypub(&headers) { + return Ok(([(header::VARY, "Accept")], StatusCode::NOT_ACCEPTABLE).into_response()); + } + + let followers = server + .storage() + .list_followers(&actor.id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let total_items = + u64::try_from(followers.len()).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let collection_id = actor + .followers + .clone() + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + let collection = OrderedCollection::new(collection_id, total_items, followers); + + Ok(( + [ + (header::CONTENT_TYPE, "application/activity+json"), + (header::VARY, "Accept"), + ], + Json(collection), + ) + .into_response()) +} diff --git a/crates/ref-feder-runtime-server/src/lib.rs b/crates/ref-feder-runtime-server/src/lib.rs index b2e6129..2afe704 100644 --- a/crates/ref-feder-runtime-server/src/lib.rs +++ b/crates/ref-feder-runtime-server/src/lib.rs @@ -20,6 +20,7 @@ //! intentionally unstable during the architecture refactoring. pub mod actor; pub mod config; +pub mod followers; pub mod inbox; pub mod negotiation; pub mod send; @@ -131,6 +132,10 @@ where Router::new() .route("/users/{identifier}", get(actor::actor::)) + .route( + "/users/{identifier}/followers", + get(followers::followers::), + ) .route("/.well-known/webfinger", get(webfinger::webfinger::)) .route("/users/{identifier}/inbox", post(inbox::inbox::)) .route("/inbox", post(inbox::shared_inbox::)) diff --git a/examples/ref-actor-server/README.md b/examples/ref-actor-server/README.md index d3caac2..80fb819 100644 --- a/examples/ref-actor-server/README.md +++ b/examples/ref-actor-server/README.md @@ -90,6 +90,18 @@ curl -i \ The endpoint validates that Bob owns the embedded Follow, removes the matching follower relationship, and returns `202 Accepted`. Repeating the Undo is safe. +Request Alice's followers collection: + +~~~~ sh +curl -i \ + -H 'Accept: application/activity+json' \ + http://127.0.0.1:3000/users/alice/followers +~~~~ + +The endpoint returns an ActivityStreams `OrderedCollection`. Its +`orderedItems` contains Bob after the Follow request and is empty after the +Undo request. + The example loads its actor key pair from the repository's test fixture and retains only that pair and the latest follower, so repeated requests do not grow an in-memory protocol history. The fixture key is public test data and diff --git a/examples/ref-actor-server/src/main.rs b/examples/ref-actor-server/src/main.rs index c055a41..3107301 100644 --- a/examples/ref-actor-server/src/main.rs +++ b/examples/ref-actor-server/src/main.rs @@ -99,6 +99,18 @@ impl ServerStorage for ExampleStorage { } Ok(()) } + + fn list_followers(&self, following: &Iri) -> Result, Self::Error> { + let latest_follower = self + .latest_follower + .lock() + .map_err(|_| ExampleStorageError("follower state lock poisoned"))?; + Ok(latest_follower + .as_ref() + .filter(|(_, stored_following)| stored_following == following) + .map(|(follower, _)| vec![follower.id.clone()]) + .unwrap_or_default()) + } } fn local_actor(key_pair: &ActorKeyPair) -> Actor { @@ -114,6 +126,11 @@ fn local_actor(key_pair: &ActorKeyPair) -> Actor { ); actor.preferred_username = Some(IDENTIFIER.to_string()); actor.name = Some("Alice".to_string()); + actor.followers = Some( + format!("{actor_id}/followers") + .parse() + .expect("valid followers collection IRI"), + ); actor.endpoints = Some(Endpoints { shared_inbox: Some( format!("{ORIGIN}/inbox") From ea671ae6e5ac82a59ee99eb7131d2d1d49914f97 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 2 Aug 2026 14:14:55 +0900 Subject: [PATCH 59/72] Add outbound Follow orchestration Add a pure core operation that constructs an outbound Follow activity and its transient pending relationship. Extend ServerStorage with pending Follow persistence and add FederServer::follow_actor to resolve the remote actor, persist intent, load the local signing key, and deliver the signed activity. Allow applications to retain shared FederServer state alongside the router, and update the reference example with bounded pending storage and an end-to-end outbound Follow demonstration. Assisted-by: Codex:gpt-5.6-sol --- crates/ref-feder-core/src/follow.rs | 38 ++++++ crates/ref-feder-core/src/storage.rs | 4 +- crates/ref-feder-runtime-server/src/follow.rs | 90 +++++++++++++++ crates/ref-feder-runtime-server/src/lib.rs | 9 +- examples/ref-actor-server/README.md | 23 +++- examples/ref-actor-server/src/main.rs | 108 +++++++++++++++--- 6 files changed, 252 insertions(+), 20 deletions(-) create mode 100644 crates/ref-feder-runtime-server/src/follow.rs diff --git a/crates/ref-feder-core/src/follow.rs b/crates/ref-feder-core/src/follow.rs index 37e49ce..f3309fa 100644 --- a/crates/ref-feder-core/src/follow.rs +++ b/crates/ref-feder-core/src/follow.rs @@ -29,6 +29,44 @@ pub struct FollowOutcome { pub recipient_inbox: Iri, } +/// A pending outbound Follow relationship for application-owned storage. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PendingFollow { + pub local_actor: Iri, + pub remote_actor: Actor, + pub follow_activity: Iri, +} + +/// The transient result of creating one outbound Follow activity. +/// +/// Core retains neither the activity nor its pending relationship. A runtime +/// persists `relationship` before delivering `activity` to the remote actor. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CreateFollowOutcome { + pub relationship: PendingFollow, + pub activity: Follow, +} + +#[must_use] +pub fn create_follow( + local_actor: &Actor, + remote_actor: &Actor, + follow_id: Iri, +) -> CreateFollowOutcome { + CreateFollowOutcome { + relationship: PendingFollow { + local_actor: local_actor.id.clone(), + remote_actor: remote_actor.clone(), + follow_activity: follow_id.clone(), + }, + activity: Follow::new( + follow_id, + Reference::id(local_actor.id.clone()), + Reference::id(remote_actor.id.clone()), + ), + } +} + pub fn receive_follow( local_actor: &Actor, remote_actor: &Actor, diff --git a/crates/ref-feder-core/src/storage.rs b/crates/ref-feder-core/src/storage.rs index 71c2799..56e6fb0 100644 --- a/crates/ref-feder-core/src/storage.rs +++ b/crates/ref-feder-core/src/storage.rs @@ -17,7 +17,7 @@ use alloc::vec::Vec; use feder_vocab::{Actor, Iri}; -use crate::key::ActorKeyPair; +use crate::{follow::PendingFollow, key::ActorKeyPair}; pub trait ServerStorage { type Error; @@ -29,4 +29,6 @@ pub trait ServerStorage { fn remove_follower(&self, follower: &Iri, following: &Iri) -> Result<(), Self::Error>; fn list_followers(&self, following: &Iri) -> Result, Self::Error>; + + fn store_pending_follow(&self, follow: &PendingFollow) -> Result<(), Self::Error>; } diff --git a/crates/ref-feder-runtime-server/src/follow.rs b/crates/ref-feder-runtime-server/src/follow.rs new file mode 100644 index 0000000..77973f2 --- /dev/null +++ b/crates/ref-feder-runtime-server/src/follow.rs @@ -0,0 +1,90 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use feder_vocab::{Follow, Iri}; +use ref_feder_core::{ActorDispatcher, follow::create_follow, storage::ServerStorage}; + +use crate::{ActorResolveError, FederServer, send::SendError}; + +impl FederServer +where + A: ActorDispatcher, + S: ServerStorage, +{ + /// Creates, persists, and delivers a Follow initiated by a local actor. + /// + /// The pending relationship is persisted before delivery so applications + /// can retain the intent when delivery fails and implement retries. + pub async fn follow_actor( + &self, + local_actor_id: &Iri, + remote_actor_id: &Iri, + follow_id: Iri, + ) -> Result> { + let local_actor = self + .actors() + .get_actor_by_id(local_actor_id) + .map_err(FollowActorError::ActorDispatcher)? + .ok_or_else(|| FollowActorError::LocalActorNotFound(local_actor_id.clone()))?; + let remote_actor = self + .resolver() + .resolve(remote_actor_id) + .await + .map_err(FollowActorError::ActorResolver)?; + let outcome = create_follow(&local_actor, &remote_actor, follow_id); + + self.storage() + .store_pending_follow(&outcome.relationship) + .map_err(FollowActorError::Storage)?; + let key_pair = self + .storage() + .load_actor_key_pair(&local_actor.id) + .map_err(FollowActorError::Storage)? + .ok_or_else(|| FollowActorError::MissingActorKey(local_actor.id.clone()))?; + let inbox = remote_actor + .endpoints + .as_ref() + .and_then(|endpoints| endpoints.shared_inbox.as_ref()) + .unwrap_or(&remote_actor.inbox); + + self.sender() + .send_activity(&local_actor, &key_pair, &outcome.activity, inbox) + .await + .map_err(FollowActorError::ActivitySender)?; + + Ok(outcome.activity) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum FollowActorError { + #[error("actor dispatcher failed")] + ActorDispatcher(A), + + #[error("local actor not found: {0}")] + LocalActorNotFound(Iri), + + #[error("failed to resolve remote actor")] + ActorResolver(#[source] ActorResolveError), + + #[error("server storage failed")] + Storage(S), + + #[error("local actor has no stored signing key: {0}")] + MissingActorKey(Iri), + + #[error("failed to send Follow activity")] + ActivitySender(#[source] SendError), +} diff --git a/crates/ref-feder-runtime-server/src/lib.rs b/crates/ref-feder-runtime-server/src/lib.rs index 2afe704..a712227 100644 --- a/crates/ref-feder-runtime-server/src/lib.rs +++ b/crates/ref-feder-runtime-server/src/lib.rs @@ -20,6 +20,7 @@ //! intentionally unstable during the architecture refactoring. pub mod actor; pub mod config; +pub mod follow; pub mod followers; pub mod inbox; pub mod negotiation; @@ -128,8 +129,14 @@ where A: ActorDispatcher + Send + Sync + 'static, S: ServerStorage + Send + Sync + 'static, { - let server = Arc::new(server); + build_router_with_state(Arc::new(server)) +} +pub fn build_router_with_state(server: Arc>) -> Router +where + A: ActorDispatcher + Send + Sync + 'static, + S: ServerStorage + Send + Sync + 'static, +{ Router::new() .route("/users/{identifier}", get(actor::actor::)) .route( diff --git a/examples/ref-actor-server/README.md b/examples/ref-actor-server/README.md index 80fb819..e11d0c8 100644 --- a/examples/ref-actor-server/README.md +++ b/examples/ref-actor-server/README.md @@ -102,9 +102,22 @@ The endpoint returns an ActivityStreams `OrderedCollection`. Its `orderedItems` contains Bob after the Follow request and is empty after the Undo request. +Send an outbound Follow from Alice to the example's remote Bob actor: + +~~~~ sh +curl -i -X POST http://127.0.0.1:3000/send-follow +~~~~ + +The application supplies the local actor, remote actor, and activity IRIs to +`FederServer::follow_actor()`. The runtime resolves Bob, asks core to construct +the activity and pending relationship, persists that relationship, loads +Alice's key, and sends a signed Follow to Bob's inbox. The pending relationship +is bounded to one entry in this example. + The example loads its actor key pair from the repository's test fixture and -retains only that pair and the latest follower, so repeated requests do not -grow an in-memory protocol history. The fixture key is public test data and -must never be used for a real actor. Unsigned incoming requests and private -outbound addresses are enabled only for this local development example; -`FederServer` requires signed requests and public destinations by default. +retains only that pair, the latest follower, and the latest pending Follow, so +repeated requests do not grow an in-memory protocol history. The fixture key +is public test data and must never be used for a real actor. Unsigned incoming +requests and private outbound addresses are enabled only for this local +development example; `FederServer` requires signed requests and public +destinations by default. diff --git a/examples/ref-actor-server/src/main.rs b/examples/ref-actor-server/src/main.rs index 3107301..05ac988 100644 --- a/examples/ref-actor-server/src/main.rs +++ b/examples/ref-actor-server/src/main.rs @@ -13,21 +13,30 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use std::{convert::Infallible, fmt, net::SocketAddr, sync::Mutex}; +use std::{ + convert::Infallible, + fmt, + net::SocketAddr, + sync::{Arc, Mutex}, +}; use axum::{ + Json, body::Bytes, - http::{HeaderMap, StatusCode}, - routing::post, + http::{HeaderMap, StatusCode, header}, + response::IntoResponse, + routing::{get, post}, }; use feder_vocab::{Actor, CryptographicKey, Endpoints, Iri, Reference}; -use ref_feder_core::{key::ActorKeyPair, storage::ServerStorage}; +use ref_feder_core::{follow::PendingFollow, key::ActorKeyPair, storage::ServerStorage}; use ref_feder_runtime_server::{ - ActorDispatcher, Error, FederServer, InboxAuthPolicy, OutboundAddressPolicy, build_router, + ActorDispatcher, Error, FederServer, InboxAuthPolicy, OutboundAddressPolicy, + build_router_with_state, }; const IDENTIFIER: &str = "alice"; const ORIGIN: &str = "http://127.0.0.1:3000"; +const REMOTE_ACTOR_ID: &str = "http://127.0.0.1:3000/remote/users/bob"; const ACTOR_PRIVATE_KEY_PEM: &str = include_str!("../../../crates/feder-core/tests/fixtures/rsa-private-key.pem"); const ACTOR_PUBLIC_KEY_PEM: &str = @@ -41,6 +50,7 @@ struct ExampleStorage { local_actor_id: Iri, actor_key_pair: ActorKeyPair, latest_follower: Mutex>, + latest_pending_follow: Mutex>, } #[derive(Debug)] @@ -111,6 +121,21 @@ impl ServerStorage for ExampleStorage { .map(|(follower, _)| vec![follower.id.clone()]) .unwrap_or_default()) } + + fn store_pending_follow(&self, follow: &PendingFollow) -> Result<(), Self::Error> { + *self + .latest_pending_follow + .lock() + .map_err(|_| ExampleStorageError("pending Follow state lock poisoned"))? = + Some(follow.clone()); + tracing::info!( + local_actor = %follow.local_actor, + remote_actor = %follow.remote_actor.id, + follow_activity = %follow.follow_activity, + "stored pending Follow" + ); + Ok(()) + } } fn local_actor(key_pair: &ActorKeyPair) -> Actor { @@ -148,15 +173,62 @@ fn local_actor(key_pair: &ActorKeyPair) -> Actor { actor } +fn remote_actor() -> Actor { + let mut actor = Actor::person( + REMOTE_ACTOR_ID.parse().expect("valid remote actor IRI"), + format!("{ORIGIN}/remote-inbox") + .parse() + .expect("valid remote inbox IRI"), + format!("{REMOTE_ACTOR_ID}/outbox") + .parse() + .expect("valid remote outbox IRI"), + ); + actor.preferred_username = Some("bob".to_string()); + actor +} + +async fn remote_actor_document() -> impl IntoResponse { + ( + [(header::CONTENT_TYPE, "application/activity+json")], + Json(remote_actor()), + ) +} + async fn remote_inbox(headers: HeaderMap, body: Bytes) -> StatusCode { if !headers.contains_key("signature") { return StatusCode::UNAUTHORIZED; } - tracing::info!(body_size = body.len(), "received signed Accept activity"); + tracing::info!(body_size = body.len(), "received signed activity"); StatusCode::ACCEPTED } +type ExampleServer = FederServer; + +async fn send_example_follow(server: Arc) -> StatusCode { + let local_actor_id = format!("{ORIGIN}/users/{IDENTIFIER}") + .parse() + .expect("valid local actor IRI"); + let remote_actor_id = REMOTE_ACTOR_ID.parse().expect("valid remote actor IRI"); + let follow_id = format!("{ORIGIN}/users/{IDENTIFIER}/activities/follow/example") + .parse() + .expect("valid Follow activity IRI"); + + match server + .follow_actor(&local_actor_id, &remote_actor_id, follow_id) + .await + { + Ok(follow) => { + tracing::info!(follow = %follow.id, "sent Follow"); + StatusCode::ACCEPTED + } + Err(error) => { + tracing::error!(%error, "failed to send Follow"); + StatusCode::BAD_GATEWAY + } + } +} + #[tokio::main] async fn main() -> Result<(), Error> { tracing_subscriber::fmt() @@ -176,15 +248,25 @@ async fn main() -> Result<(), Error> { local_actor_id: actor.id.clone(), actor_key_pair, latest_follower: Mutex::new(None), + latest_pending_follow: Mutex::new(None), }; let dispatcher = SingleActorDispatcher { actor }; - let server = FederServer::with_outbound_address_policy( - dispatcher, - storage, - OutboundAddressPolicy::AllowPrivateAddress, - )? - .with_inbox_auth_policy(InboxAuthPolicy::AllowUnsignedInsecureDev); - let app = build_router(server).route("/remote-inbox", post(remote_inbox)); + let server = Arc::new( + FederServer::with_outbound_address_policy( + dispatcher, + storage, + OutboundAddressPolicy::AllowPrivateAddress, + )? + .with_inbox_auth_policy(InboxAuthPolicy::AllowUnsignedInsecureDev), + ); + let follow_server = Arc::clone(&server); + let app = build_router_with_state(server) + .route("/remote/users/bob", get(remote_actor_document)) + .route("/remote-inbox", post(remote_inbox)) + .route( + "/send-follow", + post(move || send_example_follow(Arc::clone(&follow_server))), + ); tracing::info!( bind = %bind, From 7a9fc88a9a0ca39b225fcf25b75ced135234bc0a Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 2 Aug 2026 14:47:27 +0900 Subject: [PATCH 60/72] Confirm accepted outbound Follow relationships Add a pure core transition that validates inbound Accept activities against the authenticated remote actor and the stored pending Follow relationship. Extend ServerStorage with pending Follow lookup and compare-and-set confirmation capabilities. Route linked and embedded Accept Follow activities through personal and shared inboxes, and update the reference example with a bounded pending-to-accepted state transition. Assisted-by: Codex:gpt-5.6-sol --- crates/ref-feder-core/src/follow.rs | 64 ++++++++++++++ crates/ref-feder-core/src/storage.rs | 8 ++ crates/ref-feder-runtime-server/src/inbox.rs | 88 ++++++++++++++++++-- examples/ref-actor-server/README.md | 26 +++++- examples/ref-actor-server/src/main.rs | 59 ++++++++++++- 5 files changed, 235 insertions(+), 10 deletions(-) diff --git a/crates/ref-feder-core/src/follow.rs b/crates/ref-feder-core/src/follow.rs index f3309fa..d0169a6 100644 --- a/crates/ref-feder-core/src/follow.rs +++ b/crates/ref-feder-core/src/follow.rs @@ -67,6 +67,34 @@ pub fn create_follow( } } +pub fn receive_accept_follow( + local_actor: &Actor, + remote_actor: &Actor, + pending: &PendingFollow, + accept: Accept, +) -> Result<(), AcceptFollowError> { + if pending.local_actor != local_actor.id { + return Err(AcceptFollowError::WrongLocalActor); + } + if pending.remote_actor.id != remote_actor.id || reference_id(&accept.actor) != &remote_actor.id + { + return Err(AcceptFollowError::WrongActor); + } + if follow_reference_id(&accept.object) != &pending.follow_activity { + return Err(AcceptFollowError::WrongFollow); + } + if let Reference::Object(follow) = &accept.object { + if reference_id(&follow.actor) != &local_actor.id { + return Err(AcceptFollowError::WrongFollowActor); + } + if reference_id(&follow.object) != &remote_actor.id { + return Err(AcceptFollowError::WrongFollowObject); + } + } + + Ok(()) +} + pub fn receive_follow( local_actor: &Actor, remote_actor: &Actor, @@ -101,6 +129,42 @@ fn reference_id(reference: &Reference) -> &Iri { } } +fn follow_reference_id(reference: &Reference) -> &Iri { + match reference { + Reference::Id(id) => id, + Reference::Object(follow) => &follow.id, + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AcceptFollowError { + WrongActor, + WrongFollow, + WrongFollowActor, + WrongFollowObject, + WrongLocalActor, +} + +impl fmt::Display for AcceptFollowError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::WrongActor => formatter.write_str("Accept actor does not match remote actor"), + Self::WrongFollow => formatter.write_str("Accept does not reference pending Follow"), + Self::WrongFollowActor => { + formatter.write_str("accepted Follow actor does not match local actor") + } + Self::WrongFollowObject => { + formatter.write_str("accepted Follow does not target remote actor") + } + Self::WrongLocalActor => { + formatter.write_str("pending Follow does not belong to local actor") + } + } + } +} + +impl core::error::Error for AcceptFollowError {} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum FollowError { WrongActor, diff --git a/crates/ref-feder-core/src/storage.rs b/crates/ref-feder-core/src/storage.rs index 56e6fb0..91ab483 100644 --- a/crates/ref-feder-core/src/storage.rs +++ b/crates/ref-feder-core/src/storage.rs @@ -31,4 +31,12 @@ pub trait ServerStorage { fn list_followers(&self, following: &Iri) -> Result, Self::Error>; fn store_pending_follow(&self, follow: &PendingFollow) -> Result<(), Self::Error>; + + fn load_pending_follow( + &self, + follow_activity: &Iri, + ) -> Result, Self::Error>; + + /// Confirm `expected` only if that exact relationship is still pending. + fn confirm_pending_follow(&self, expected: &PendingFollow) -> Result; } diff --git a/crates/ref-feder-runtime-server/src/inbox.rs b/crates/ref-feder-runtime-server/src/inbox.rs index d32a51a..e527445 100644 --- a/crates/ref-feder-runtime-server/src/inbox.rs +++ b/crates/ref-feder-runtime-server/src/inbox.rs @@ -29,11 +29,13 @@ use axum::{ }, response::{IntoResponse, Response}, }; -use feder_vocab::{Actor, CryptographicKey, Follow, Iri, Reference, Undo}; +use feder_vocab::{Accept, Actor, CryptographicKey, Follow, Iri, Reference, Undo}; use mime::Mime; use ref_feder_core::{ ActorDispatcher, - follow::{FollowError, receive_follow}, + follow::{ + AcceptFollowError, FollowError, PendingFollow, receive_accept_follow, receive_follow, + }, key::{create_sha256_digest_header, verify_draft_cavage}, storage::ServerStorage, undo::{UndoFollowError, receive_undo_follow}, @@ -82,7 +84,7 @@ where .ok_or(StatusCode::NOT_FOUND)?; let (request, value) = parse_inbox_request(headers, method, uri, body)?; - receive_activity(&server, local_actor, request, value).await + receive_activity(&server, local_actor, request, value, None).await } pub async fn shared_inbox( @@ -97,7 +99,7 @@ where S: ServerStorage, { let (request, value) = parse_inbox_request(headers, method, uri, body)?; - let Some(target_id) = activity_target_id(&value) else { + let Some((target_id, pending_follow)) = shared_inbox_target(server.storage(), &value)? else { return Ok(StatusCode::ACCEPTED.into_response()); }; let Some(local_actor) = server @@ -108,7 +110,7 @@ where return Ok(StatusCode::ACCEPTED.into_response()); }; - receive_activity(&server, local_actor, request, value).await + receive_activity(&server, local_actor, request, value, pending_follow).await } fn parse_inbox_request( @@ -143,6 +145,7 @@ async fn receive_activity( local_actor: Actor, request: InboxRequest, value: Value, + pending_follow: Option, ) -> Result where A: ActorDispatcher, @@ -164,6 +167,43 @@ where match value.get("type").and_then(Value::as_str) { Some("Follow") => {} + Some("Accept") => { + let accept: Accept = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; + let remote_actor = match verified_actor { + Some(actor) => actor, + None => resolve_actor_reference(server.resolver(), &accept.actor).await?, + }; + let follow_activity = follow_reference_id(&accept.object); + let pending = match pending_follow { + Some(pending) if pending.follow_activity == *follow_activity => pending, + Some(_) => return Ok(StatusCode::ACCEPTED.into_response()), + None => match server + .storage() + .load_pending_follow(follow_activity) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + { + Some(pending) => pending, + None => return Ok(StatusCode::ACCEPTED.into_response()), + }, + }; + match receive_accept_follow(&local_actor, &remote_actor, &pending, accept) { + Ok(_) => {} + Err(AcceptFollowError::WrongActor) => return Err(StatusCode::UNAUTHORIZED), + Err( + AcceptFollowError::WrongFollow + | AcceptFollowError::WrongFollowActor + | AcceptFollowError::WrongFollowObject + | AcceptFollowError::WrongLocalActor, + ) => return Ok(StatusCode::ACCEPTED.into_response()), + } + + server + .storage() + .confirm_pending_follow(&pending) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + return Ok(StatusCode::ACCEPTED.into_response()); + } Some("Undo") => { let undo: Undo = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; let remote_actor = match verified_actor { @@ -361,6 +401,44 @@ fn activity_target_id(value: &Value) -> Option { .ok() } +fn shared_inbox_target( + storage: &S, + value: &Value, +) -> Result)>, StatusCode> +where + S: ServerStorage, +{ + if value.get("type").and_then(Value::as_str) == Some("Accept") { + let Some(follow_activity) = value.get("object").and_then(value_reference_id) else { + return Ok(None); + }; + let pending = storage + .load_pending_follow(&follow_activity) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + return Ok(pending.map(|pending| { + let local_actor = pending.local_actor.clone(); + (local_actor, Some(pending)) + })); + } + + Ok(activity_target_id(value).map(|target| (target, None))) +} + +fn value_reference_id(value: &Value) -> Option { + value + .as_str() + .or_else(|| value.get("id").and_then(Value::as_str))? + .parse() + .ok() +} + +fn follow_reference_id(reference: &Reference) -> &Iri { + match reference { + Reference::Id(id) => id, + Reference::Object(follow) => &follow.id, + } +} + fn verify_request_host(headers: &HeaderMap, inbox: &Iri) -> Result<(), StatusCode> { let signed_host = headers .get(HOST) diff --git a/examples/ref-actor-server/README.md b/examples/ref-actor-server/README.md index e11d0c8..6e53253 100644 --- a/examples/ref-actor-server/README.md +++ b/examples/ref-actor-server/README.md @@ -114,8 +114,32 @@ the activity and pending relationship, persists that relationship, loads Alice's key, and sends a signed Follow to Bob's inbox. The pending relationship is bounded to one entry in this example. +Confirm that pending Follow with a linked `Accept` through the shared inbox: + +~~~~ sh +curl -i \ + -H 'Content-Type: application/activity+json' \ + --data-binary '{ + "@context": "https://www.w3.org/ns/activitystreams", + "id": "http://127.0.0.1:3000/remote/activities/accept/example", + "type": "Accept", + "actor": { + "id": "http://127.0.0.1:3000/remote/users/bob", + "type": "Person", + "inbox": "http://127.0.0.1:3000/remote-inbox", + "outbox": "http://127.0.0.1:3000/remote/users/bob/outbox" + }, + "object": "http://127.0.0.1:3000/users/alice/activities/follow/example" + }' \ + http://127.0.0.1:3000/inbox +~~~~ + +The shared inbox uses the linked Follow IRI to find Alice, core validates Bob +against the pending relationship, and storage atomically changes that exact +relationship from pending to accepted. Repeating the Accept is a safe no-op. + The example loads its actor key pair from the repository's test fixture and -retains only that pair, the latest follower, and the latest pending Follow, so +retains only that pair, the latest follower, and the latest outbound Follow, so repeated requests do not grow an in-memory protocol history. The fixture key is public test data and must never be used for a real actor. Unsigned incoming requests and private outbound addresses are enabled only for this local diff --git a/examples/ref-actor-server/src/main.rs b/examples/ref-actor-server/src/main.rs index 05ac988..78c63f6 100644 --- a/examples/ref-actor-server/src/main.rs +++ b/examples/ref-actor-server/src/main.rs @@ -50,7 +50,12 @@ struct ExampleStorage { local_actor_id: Iri, actor_key_pair: ActorKeyPair, latest_follower: Mutex>, - latest_pending_follow: Mutex>, + latest_outbound_follow: Mutex>, +} + +enum OutboundFollowState { + Pending(PendingFollow), + Accepted(PendingFollow), } #[derive(Debug)] @@ -124,10 +129,10 @@ impl ServerStorage for ExampleStorage { fn store_pending_follow(&self, follow: &PendingFollow) -> Result<(), Self::Error> { *self - .latest_pending_follow + .latest_outbound_follow .lock() .map_err(|_| ExampleStorageError("pending Follow state lock poisoned"))? = - Some(follow.clone()); + Some(OutboundFollowState::Pending(follow.clone())); tracing::info!( local_actor = %follow.local_actor, remote_actor = %follow.remote_actor.id, @@ -136,6 +141,52 @@ impl ServerStorage for ExampleStorage { ); Ok(()) } + + fn load_pending_follow( + &self, + follow_activity: &Iri, + ) -> Result, Self::Error> { + let outbound = self + .latest_outbound_follow + .lock() + .map_err(|_| ExampleStorageError("outbound Follow state lock poisoned"))?; + Ok(match outbound.as_ref() { + Some(OutboundFollowState::Pending(follow)) + if follow.follow_activity == *follow_activity => + { + Some(follow.clone()) + } + Some(OutboundFollowState::Accepted(follow)) => { + tracing::debug!( + follow_activity = %follow.follow_activity, + "Follow is already accepted" + ); + None + } + Some(OutboundFollowState::Pending(_)) | None => None, + }) + } + + fn confirm_pending_follow(&self, expected: &PendingFollow) -> Result { + let mut outbound = self + .latest_outbound_follow + .lock() + .map_err(|_| ExampleStorageError("outbound Follow state lock poisoned"))?; + let matches = matches!( + outbound.as_ref(), + Some(OutboundFollowState::Pending(follow)) if follow == expected + ); + if matches { + *outbound = Some(OutboundFollowState::Accepted(expected.clone())); + tracing::info!( + local_actor = %expected.local_actor, + remote_actor = %expected.remote_actor.id, + follow_activity = %expected.follow_activity, + "confirmed pending Follow" + ); + } + Ok(matches) + } } fn local_actor(key_pair: &ActorKeyPair) -> Actor { @@ -248,7 +299,7 @@ async fn main() -> Result<(), Error> { local_actor_id: actor.id.clone(), actor_key_pair, latest_follower: Mutex::new(None), - latest_pending_follow: Mutex::new(None), + latest_outbound_follow: Mutex::new(None), }; let dispatcher = SingleActorDispatcher { actor }; let server = Arc::new( From 3ea2b883a0ef02fb97da657d1739d584a3427ad4 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 2 Aug 2026 15:04:47 +0900 Subject: [PATCH 61/72] Add local Note creation and typed persistence Add a pure core operation that constructs a local Note and its corresponding Create activity from runtime-provided IDs, addressing, and content. Introduce a dedicated NoteStore capability and add FederServer::create_note to load the local actor and persist only the durable Note without retaining protocol history. Update the reference example with bounded Note storage and a local creation flow while leaving Create delivery for the next migration step. Assisted-by: Codex:gpt-5.6-sol --- crates/ref-feder-core/src/lib.rs | 1 + crates/ref-feder-core/src/note.rs | 63 +++++++++++++++++ crates/ref-feder-core/src/storage.rs | 8 ++- crates/ref-feder-runtime-server/src/lib.rs | 1 + crates/ref-feder-runtime-server/src/note.rs | 61 ++++++++++++++++ examples/ref-actor-server/README.md | 23 ++++-- examples/ref-actor-server/src/main.rs | 77 ++++++++++++++++++++- 7 files changed, 225 insertions(+), 9 deletions(-) create mode 100644 crates/ref-feder-core/src/note.rs create mode 100644 crates/ref-feder-runtime-server/src/note.rs diff --git a/crates/ref-feder-core/src/lib.rs b/crates/ref-feder-core/src/lib.rs index 1593d74..6c8c529 100644 --- a/crates/ref-feder-core/src/lib.rs +++ b/crates/ref-feder-core/src/lib.rs @@ -27,6 +27,7 @@ use feder_vocab::{Actor, Iri}; pub mod follow; pub mod key; +pub mod note; pub mod storage; pub mod undo; diff --git a/crates/ref-feder-core/src/note.rs b/crates/ref-feder-core/src/note.rs new file mode 100644 index 0000000..d657965 --- /dev/null +++ b/crates/ref-feder-core/src/note.rs @@ -0,0 +1,63 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use alloc::string::String; + +use feder_vocab::{Actor, Create, Iri, Note, Reference, References}; + +/// Runtime-provided facts for constructing one local Note and Create activity. +/// +/// IDs and timestamps are inputs so core does not depend on clocks, randomness, +/// or an operating-system-specific identifier source. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CreateNoteInput { + pub note_id: Iri, + pub create_id: Iri, + pub to: References, + pub cc: References, + pub content: String, + pub media_type: Option, + pub published: Option, + pub url: Option, +} + +/// The transient result of constructing one local Note. +/// +/// Core retains neither value. A runtime persists `note`; `activity` remains +/// available for subsequent delivery orchestration. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CreateNoteOutcome { + pub note: Note, + pub activity: Create, +} + +#[must_use] +pub fn create_note(local_actor: &Actor, input: CreateNoteInput) -> CreateNoteOutcome { + let actor = Reference::id(local_actor.id.clone()); + let mut note = Note::new(input.note_id); + note.attributed_to = Some(actor.clone()); + note.to = input.to; + note.cc = input.cc; + note.content = Some(input.content); + note.media_type = input.media_type; + note.published = input.published; + note.url = input.url; + + let mut activity = Create::new(input.create_id, actor, Reference::object(note.clone())); + activity.to = note.to.clone(); + activity.cc = note.cc.clone(); + + CreateNoteOutcome { note, activity } +} diff --git a/crates/ref-feder-core/src/storage.rs b/crates/ref-feder-core/src/storage.rs index 91ab483..d524292 100644 --- a/crates/ref-feder-core/src/storage.rs +++ b/crates/ref-feder-core/src/storage.rs @@ -15,7 +15,7 @@ use alloc::vec::Vec; -use feder_vocab::{Actor, Iri}; +use feder_vocab::{Actor, Iri, Note}; use crate::{follow::PendingFollow, key::ActorKeyPair}; @@ -40,3 +40,9 @@ pub trait ServerStorage { /// Confirm `expected` only if that exact relationship is still pending. fn confirm_pending_follow(&self, expected: &PendingFollow) -> Result; } + +pub trait NoteStore { + type Error; + + fn store_note(&self, note: &Note) -> Result<(), Self::Error>; +} diff --git a/crates/ref-feder-runtime-server/src/lib.rs b/crates/ref-feder-runtime-server/src/lib.rs index a712227..abaabd4 100644 --- a/crates/ref-feder-runtime-server/src/lib.rs +++ b/crates/ref-feder-runtime-server/src/lib.rs @@ -24,6 +24,7 @@ pub mod follow; pub mod followers; pub mod inbox; pub mod negotiation; +pub mod note; pub mod send; pub mod url; pub mod webfinger; diff --git a/crates/ref-feder-runtime-server/src/note.rs b/crates/ref-feder-runtime-server/src/note.rs new file mode 100644 index 0000000..ac0d066 --- /dev/null +++ b/crates/ref-feder-runtime-server/src/note.rs @@ -0,0 +1,61 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use feder_vocab::Iri; +use ref_feder_core::{ + ActorDispatcher, + note::{CreateNoteInput, CreateNoteOutcome, create_note}, + storage::NoteStore, +}; + +use crate::FederServer; + +impl FederServer +where + A: ActorDispatcher, + S: NoteStore, +{ + /// Constructs and persists a local Note without retaining protocol state. + pub async fn create_note( + &self, + local_actor_id: &Iri, + input: CreateNoteInput, + ) -> Result> { + let local_actor = self + .actors() + .get_actor_by_id(local_actor_id) + .map_err(CreateNoteError::ActorDispatcher)? + .ok_or_else(|| CreateNoteError::LocalActorNotFound(local_actor_id.clone()))?; + let outcome = create_note(&local_actor, input); + + self.storage() + .store_note(&outcome.note) + .map_err(CreateNoteError::Storage)?; + + Ok(outcome) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum CreateNoteError { + #[error("actor dispatcher failed")] + ActorDispatcher(A), + + #[error("local actor not found: {0}")] + LocalActorNotFound(Iri), + + #[error("note storage failed")] + Storage(S), +} diff --git a/examples/ref-actor-server/README.md b/examples/ref-actor-server/README.md index 6e53253..9fc955a 100644 --- a/examples/ref-actor-server/README.md +++ b/examples/ref-actor-server/README.md @@ -138,10 +138,21 @@ The shared inbox uses the linked Follow IRI to find Alice, core validates Bob against the pending relationship, and storage atomically changes that exact relationship from pending to accepted. Repeating the Accept is a safe no-op. +Create and persist a local Note: + +~~~~ sh +curl -i -X POST http://127.0.0.1:3000/create-note +~~~~ + +The application supplies stable Note and Create activity IRIs plus the Note's +content and addressing. Core constructs both values, and the runtime persists +only the durable Note through `NoteStore`. Delivery of the transient Create +activity is intentionally left for the next migration step. + The example loads its actor key pair from the repository's test fixture and -retains only that pair, the latest follower, and the latest outbound Follow, so -repeated requests do not grow an in-memory protocol history. The fixture key -is public test data and must never be used for a real actor. Unsigned incoming -requests and private outbound addresses are enabled only for this local -development example; `FederServer` requires signed requests and public -destinations by default. +retains only that pair, the latest follower, the latest outbound Follow, and +the latest Note, so repeated requests do not grow an in-memory protocol +history. The fixture key is public test data and must never be used for a real +actor. Unsigned incoming requests and private outbound addresses are enabled +only for this local development example; `FederServer` requires signed +requests and public destinations by default. diff --git a/examples/ref-actor-server/src/main.rs b/examples/ref-actor-server/src/main.rs index 78c63f6..52a03f4 100644 --- a/examples/ref-actor-server/src/main.rs +++ b/examples/ref-actor-server/src/main.rs @@ -27,8 +27,13 @@ use axum::{ response::IntoResponse, routing::{get, post}, }; -use feder_vocab::{Actor, CryptographicKey, Endpoints, Iri, Reference}; -use ref_feder_core::{follow::PendingFollow, key::ActorKeyPair, storage::ServerStorage}; +use feder_vocab::{Actor, CryptographicKey, Endpoints, Iri, Note, Reference, References}; +use ref_feder_core::{ + follow::PendingFollow, + key::ActorKeyPair, + note::CreateNoteInput, + storage::{NoteStore, ServerStorage}, +}; use ref_feder_runtime_server::{ ActorDispatcher, Error, FederServer, InboxAuthPolicy, OutboundAddressPolicy, build_router_with_state, @@ -51,6 +56,7 @@ struct ExampleStorage { actor_key_pair: ActorKeyPair, latest_follower: Mutex>, latest_outbound_follow: Mutex>, + latest_note: Mutex>, } enum OutboundFollowState { @@ -189,6 +195,19 @@ impl ServerStorage for ExampleStorage { } } +impl NoteStore for ExampleStorage { + type Error = ExampleStorageError; + + fn store_note(&self, note: &Note) -> Result<(), Self::Error> { + *self + .latest_note + .lock() + .map_err(|_| ExampleStorageError("Note state lock poisoned"))? = Some(note.clone()); + tracing::info!(note = %note.id, "stored Note"); + Ok(()) + } +} + fn local_actor(key_pair: &ActorKeyPair) -> Actor { let actor_id = format!("{ORIGIN}/users/{IDENTIFIER}"); let mut actor = Actor::person( @@ -280,6 +299,54 @@ async fn send_example_follow(server: Arc) -> StatusCode { } } +async fn create_example_note(server: Arc) -> StatusCode { + let local_actor_id = format!("{ORIGIN}/users/{IDENTIFIER}") + .parse() + .expect("valid local actor IRI"); + let note_id = format!("{ORIGIN}/users/{IDENTIFIER}/posts/example") + .parse() + .expect("valid Note IRI"); + let create_id = format!("{ORIGIN}/users/{IDENTIFIER}/activities/create/example") + .parse() + .expect("valid Create activity IRI"); + let public = "https://www.w3.org/ns/activitystreams#Public" + .parse() + .expect("valid ActivityStreams Public collection IRI"); + let followers = format!("{ORIGIN}/users/{IDENTIFIER}/followers") + .parse() + .expect("valid followers collection IRI"); + + match server + .create_note( + &local_actor_id, + CreateNoteInput { + note_id, + create_id, + to: References::one(public), + cc: References::one(followers), + content: "Hello from the reference Feder runtime.".to_string(), + media_type: Some("text/plain".to_string()), + published: None, + url: None, + }, + ) + .await + { + Ok(outcome) => { + tracing::info!( + note = %outcome.note.id, + activity = %outcome.activity.id, + "created Note" + ); + StatusCode::CREATED + } + Err(error) => { + tracing::error!(%error, "failed to create Note"); + StatusCode::INTERNAL_SERVER_ERROR + } + } +} + #[tokio::main] async fn main() -> Result<(), Error> { tracing_subscriber::fmt() @@ -300,6 +367,7 @@ async fn main() -> Result<(), Error> { actor_key_pair, latest_follower: Mutex::new(None), latest_outbound_follow: Mutex::new(None), + latest_note: Mutex::new(None), }; let dispatcher = SingleActorDispatcher { actor }; let server = Arc::new( @@ -311,12 +379,17 @@ async fn main() -> Result<(), Error> { .with_inbox_auth_policy(InboxAuthPolicy::AllowUnsignedInsecureDev), ); let follow_server = Arc::clone(&server); + let note_server = Arc::clone(&server); let app = build_router_with_state(server) .route("/remote/users/bob", get(remote_actor_document)) .route("/remote-inbox", post(remote_inbox)) .route( "/send-follow", post(move || send_example_follow(Arc::clone(&follow_server))), + ) + .route( + "/create-note", + post(move || create_example_note(Arc::clone(¬e_server))), ); tracing::info!( From c888e6966103aadc5945c49d15bbcecfdee436ab Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 2 Aug 2026 15:15:20 +0900 Subject: [PATCH 62/72] Deliver Create activities to Note recipients Derive transient delivery intents from Note addressing in core without retaining recipient or activity history. Add follower delivery and shared storage error capabilities, then expand followers, resolve direct actors, deduplicate inboxes, and deliver signed Create activities after Note persistence. Update the reference example to demonstrate signed Create delivery to a stored follower. Assisted-by: Codex:gpt-5.6-sol --- crates/ref-feder-core/src/note.rs | 40 ++++++- crates/ref-feder-core/src/storage.rs | 12 +- crates/ref-feder-runtime-server/src/note.rs | 118 ++++++++++++++++++-- examples/ref-actor-server/README.md | 6 +- examples/ref-actor-server/src/main.rs | 22 +++- 5 files changed, 178 insertions(+), 20 deletions(-) diff --git a/crates/ref-feder-core/src/note.rs b/crates/ref-feder-core/src/note.rs index d657965..c1a23a3 100644 --- a/crates/ref-feder-core/src/note.rs +++ b/crates/ref-feder-core/src/note.rs @@ -13,10 +13,19 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use alloc::string::String; +use alloc::{string::String, vec::Vec}; use feder_vocab::{Actor, Create, Iri, Note, Reference, References}; +pub const PUBLIC_COLLECTION: &str = "https://www.w3.org/ns/activitystreams#Public"; + +/// A transient delivery intent derived from a Note's addressing fields. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum NoteRecipient { + Followers(Iri), + Actor(Iri), +} + /// Runtime-provided facts for constructing one local Note and Create activity. /// /// IDs and timestamps are inputs so core does not depend on clocks, randomness, @@ -41,6 +50,7 @@ pub struct CreateNoteInput { pub struct CreateNoteOutcome { pub note: Note, pub activity: Create, + pub recipients: Vec, } #[must_use] @@ -59,5 +69,31 @@ pub fn create_note(local_actor: &Actor, input: CreateNoteInput) -> CreateNoteOut activity.to = note.to.clone(); activity.cc = note.cc.clone(); - CreateNoteOutcome { note, activity } + let recipients = note_recipients(local_actor, ¬e); + + CreateNoteOutcome { + note, + activity, + recipients, + } +} + +fn note_recipients(local_actor: &Actor, note: &Note) -> Vec { + let mut recipients = Vec::new(); + + for address in note.to.iter().chain(note.cc.iter()) { + let recipient = if address.as_str() == PUBLIC_COLLECTION || address == &local_actor.id { + continue; + } else if local_actor.followers.as_ref() == Some(address) { + NoteRecipient::Followers(local_actor.id.clone()) + } else { + NoteRecipient::Actor(address.clone()) + }; + + if !recipients.contains(&recipient) { + recipients.push(recipient); + } + } + + recipients } diff --git a/crates/ref-feder-core/src/storage.rs b/crates/ref-feder-core/src/storage.rs index d524292..3c44cb0 100644 --- a/crates/ref-feder-core/src/storage.rs +++ b/crates/ref-feder-core/src/storage.rs @@ -19,9 +19,11 @@ use feder_vocab::{Actor, Iri, Note}; use crate::{follow::PendingFollow, key::ActorKeyPair}; -pub trait ServerStorage { +pub trait Storage { type Error; +} +pub trait ServerStorage: Storage { fn store_follower(&self, follower: &Actor, following: &Iri) -> Result<(), Self::Error>; fn load_actor_key_pair(&self, actor_id: &Iri) -> Result, Self::Error>; @@ -41,8 +43,10 @@ pub trait ServerStorage { fn confirm_pending_follow(&self, expected: &PendingFollow) -> Result; } -pub trait NoteStore { - type Error; - +pub trait NoteStore: Storage { fn store_note(&self, note: &Note) -> Result<(), Self::Error>; } + +pub trait FollowerDeliveryStore: ServerStorage { + fn list_follower_actors(&self, local_actor: &Iri) -> Result, Self::Error>; +} diff --git a/crates/ref-feder-runtime-server/src/note.rs b/crates/ref-feder-runtime-server/src/note.rs index ac0d066..231dc01 100644 --- a/crates/ref-feder-runtime-server/src/note.rs +++ b/crates/ref-feder-runtime-server/src/note.rs @@ -13,21 +13,26 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use feder_vocab::Iri; +use std::collections::HashSet; + +use feder_vocab::{Actor, Iri}; use ref_feder_core::{ ActorDispatcher, - note::{CreateNoteInput, CreateNoteOutcome, create_note}, - storage::NoteStore, + note::{CreateNoteInput, CreateNoteOutcome, NoteRecipient, create_note}, + storage::{FollowerDeliveryStore, NoteStore}, }; -use crate::FederServer; +use crate::{ActorResolveError, FederServer, send::SendError}; impl FederServer where A: ActorDispatcher, - S: NoteStore, + S: FollowerDeliveryStore + NoteStore, { - /// Constructs and persists a local Note without retaining protocol state. + /// Constructs, persists, and delivers a local Note without retaining state. + /// + /// Persistence occurs before delivery. Every independent recipient is + /// attempted even if another resolution or delivery fails. pub async fn create_note( &self, local_actor_id: &Iri, @@ -44,8 +49,96 @@ where .store_note(&outcome.note) .map_err(CreateNoteError::Storage)?; - Ok(outcome) + let (inboxes, actor_resolve_error) = self + .resolve_note_recipients(&outcome.recipients) + .await + .map_err(CreateNoteError::Storage)?; + if inboxes.is_empty() { + if let Some(error) = actor_resolve_error { + return Err(CreateNoteError::ActorResolver(error)); + } + return Ok(outcome); + } + + let key_pair = self + .storage() + .load_actor_key_pair(&local_actor.id) + .map_err(CreateNoteError::Storage)? + .ok_or_else(|| CreateNoteError::MissingActorKey(local_actor.id.clone()))?; + let mut first_send_error = None; + for inbox in inboxes { + if let Err(error) = self + .sender() + .send_activity(&local_actor, &key_pair, &outcome.activity, &inbox) + .await + && first_send_error.is_none() + { + first_send_error = Some(error); + } + } + + if let Some(error) = first_send_error { + Err(CreateNoteError::ActivitySender(error)) + } else if let Some(error) = actor_resolve_error { + Err(CreateNoteError::ActorResolver(error)) + } else { + Ok(outcome) + } } + + async fn resolve_note_recipients( + &self, + recipients: &[NoteRecipient], + ) -> Result<(Vec, Option), S::Error> { + let mut inboxes = Vec::new(); + let mut covered_actor_ids = HashSet::new(); + let mut seen_inboxes = HashSet::new(); + let mut first_actor_resolve_error = None; + + for recipient in recipients { + let NoteRecipient::Followers(local_actor_id) = recipient else { + continue; + }; + for actor in self.storage().list_follower_actors(local_actor_id)? { + covered_actor_ids.insert(actor.id.clone()); + let inbox = preferred_shared_inbox(&actor).clone(); + if seen_inboxes.insert(inbox.clone()) { + inboxes.push(inbox); + } + } + } + + for recipient in recipients { + let NoteRecipient::Actor(actor_id) = recipient else { + continue; + }; + if covered_actor_ids.contains(actor_id) { + continue; + } + match self.resolver().resolve(actor_id).await { + Ok(actor) => { + covered_actor_ids.insert(actor.id.clone()); + if seen_inboxes.insert(actor.inbox.clone()) { + inboxes.push(actor.inbox); + } + } + Err(error) if first_actor_resolve_error.is_none() => { + first_actor_resolve_error = Some(error); + } + Err(_) => {} + } + } + + Ok((inboxes, first_actor_resolve_error)) + } +} + +fn preferred_shared_inbox(actor: &Actor) -> &Iri { + actor + .endpoints + .as_ref() + .and_then(|endpoints| endpoints.shared_inbox.as_ref()) + .unwrap_or(&actor.inbox) } #[derive(Debug, thiserror::Error)] @@ -56,6 +149,15 @@ pub enum CreateNoteError { #[error("local actor not found: {0}")] LocalActorNotFound(Iri), - #[error("note storage failed")] + #[error("note or follower storage failed")] Storage(S), + + #[error("failed to resolve a Note recipient")] + ActorResolver(#[source] ActorResolveError), + + #[error("local actor has no stored signing key: {0}")] + MissingActorKey(Iri), + + #[error("failed to send Create activity")] + ActivitySender(#[source] SendError), } diff --git a/examples/ref-actor-server/README.md b/examples/ref-actor-server/README.md index 9fc955a..f4f0d30 100644 --- a/examples/ref-actor-server/README.md +++ b/examples/ref-actor-server/README.md @@ -146,8 +146,10 @@ curl -i -X POST http://127.0.0.1:3000/create-note The application supplies stable Note and Create activity IRIs plus the Note's content and addressing. Core constructs both values, and the runtime persists -only the durable Note through `NoteStore`. Delivery of the transient Create -activity is intentionally left for the next migration step. +only the durable Note through `NoteStore`. It then expands the followers +address through `FollowerDeliveryStore`, prefers shared inboxes, deduplicates +destinations, and sends the transient Create activity with Alice's key. Send +the earlier inbound Follow first to observe delivery to Bob's example inbox. The example loads its actor key pair from the repository's test fixture and retains only that pair, the latest follower, the latest outbound Follow, and diff --git a/examples/ref-actor-server/src/main.rs b/examples/ref-actor-server/src/main.rs index 52a03f4..3058c21 100644 --- a/examples/ref-actor-server/src/main.rs +++ b/examples/ref-actor-server/src/main.rs @@ -32,7 +32,7 @@ use ref_feder_core::{ follow::PendingFollow, key::ActorKeyPair, note::CreateNoteInput, - storage::{NoteStore, ServerStorage}, + storage::{FollowerDeliveryStore, NoteStore, ServerStorage, Storage}, }; use ref_feder_runtime_server::{ ActorDispatcher, Error, FederServer, InboxAuthPolicy, OutboundAddressPolicy, @@ -87,9 +87,11 @@ impl ActorDispatcher for SingleActorDispatcher { } } -impl ServerStorage for ExampleStorage { +impl Storage for ExampleStorage { type Error = ExampleStorageError; +} +impl ServerStorage for ExampleStorage { fn store_follower(&self, follower: &Actor, following: &Iri) -> Result<(), Self::Error> { *self .latest_follower @@ -196,8 +198,6 @@ impl ServerStorage for ExampleStorage { } impl NoteStore for ExampleStorage { - type Error = ExampleStorageError; - fn store_note(&self, note: &Note) -> Result<(), Self::Error> { *self .latest_note @@ -208,6 +208,20 @@ impl NoteStore for ExampleStorage { } } +impl FollowerDeliveryStore for ExampleStorage { + fn list_follower_actors(&self, local_actor: &Iri) -> Result, Self::Error> { + let latest_follower = self + .latest_follower + .lock() + .map_err(|_| ExampleStorageError("follower state lock poisoned"))?; + Ok(latest_follower + .as_ref() + .filter(|(_, following)| following == local_actor) + .map(|(follower, _)| vec![follower.clone()]) + .unwrap_or_default()) + } +} + fn local_actor(key_pair: &ActorKeyPair) -> Actor { let actor_id = format!("{ORIGIN}/users/{IDENTIFIER}"); let mut actor = Actor::person( From 6789e07bae3f0e05d811cfcbb6564546f536a7b7 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 2 Aug 2026 15:31:11 +0900 Subject: [PATCH 63/72] Expose persisted public Notes Extend NoteStore with typed Note loading and add a pure core check for public ActivityStreams addressing. Expose canonical local post paths through the reference server runtime with ActivityPub content negotiation while hiding missing and non-public Notes. Update the reference example to load and serve its bounded persisted Note. Assisted-by: Codex:gpt-5.6-sol --- crates/ref-feder-core/src/note.rs | 8 ++ crates/ref-feder-core/src/storage.rs | 2 + crates/ref-feder-runtime-server/src/lib.rs | 11 ++- crates/ref-feder-runtime-server/src/object.rs | 79 +++++++++++++++++++ examples/ref-actor-server/README.md | 12 +++ examples/ref-actor-server/src/main.rs | 11 +++ 6 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 crates/ref-feder-runtime-server/src/object.rs diff --git a/crates/ref-feder-core/src/note.rs b/crates/ref-feder-core/src/note.rs index c1a23a3..76e41ff 100644 --- a/crates/ref-feder-core/src/note.rs +++ b/crates/ref-feder-core/src/note.rs @@ -78,6 +78,14 @@ pub fn create_note(local_actor: &Actor, input: CreateNoteInput) -> CreateNoteOut } } +#[must_use] +pub fn is_public_note(note: &Note) -> bool { + note.to + .iter() + .chain(note.cc.iter()) + .any(|recipient| recipient.as_str() == PUBLIC_COLLECTION) +} + fn note_recipients(local_actor: &Actor, note: &Note) -> Vec { let mut recipients = Vec::new(); diff --git a/crates/ref-feder-core/src/storage.rs b/crates/ref-feder-core/src/storage.rs index 3c44cb0..818a91d 100644 --- a/crates/ref-feder-core/src/storage.rs +++ b/crates/ref-feder-core/src/storage.rs @@ -45,6 +45,8 @@ pub trait ServerStorage: Storage { pub trait NoteStore: Storage { fn store_note(&self, note: &Note) -> Result<(), Self::Error>; + + fn load_note(&self, note_id: &Iri) -> Result, Self::Error>; } pub trait FollowerDeliveryStore: ServerStorage { diff --git a/crates/ref-feder-runtime-server/src/lib.rs b/crates/ref-feder-runtime-server/src/lib.rs index abaabd4..7d56b5f 100644 --- a/crates/ref-feder-runtime-server/src/lib.rs +++ b/crates/ref-feder-runtime-server/src/lib.rs @@ -25,6 +25,7 @@ pub mod followers; pub mod inbox; pub mod negotiation; pub mod note; +pub mod object; pub mod send; pub mod url; pub mod webfinger; @@ -40,7 +41,7 @@ use axum::{ pub use config::OutboundAddressPolicy; pub use inbox::InboxAuthPolicy; pub use ref_feder_core::ActorDispatcher; -use ref_feder_core::storage::ServerStorage; +use ref_feder_core::storage::{NoteStore, ServerStorage}; use crate::send::{ActivitySender, SendError}; @@ -128,7 +129,7 @@ impl FederServer { pub fn build_router(server: FederServer) -> Router where A: ActorDispatcher + Send + Sync + 'static, - S: ServerStorage + Send + Sync + 'static, + S: NoteStore + ServerStorage + Send + Sync + 'static, { build_router_with_state(Arc::new(server)) } @@ -136,7 +137,7 @@ where pub fn build_router_with_state(server: Arc>) -> Router where A: ActorDispatcher + Send + Sync + 'static, - S: ServerStorage + Send + Sync + 'static, + S: NoteStore + ServerStorage + Send + Sync + 'static, { Router::new() .route("/users/{identifier}", get(actor::actor::)) @@ -144,6 +145,10 @@ where "/users/{identifier}/followers", get(followers::followers::), ) + .route( + "/users/{identifier}/posts/{post_id}", + get(object::note::), + ) .route("/.well-known/webfinger", get(webfinger::webfinger::)) .route("/users/{identifier}/inbox", post(inbox::inbox::)) .route("/inbox", post(inbox::shared_inbox::)) diff --git a/crates/ref-feder-runtime-server/src/object.rs b/crates/ref-feder-runtime-server/src/object.rs new file mode 100644 index 0000000..50391e7 --- /dev/null +++ b/crates/ref-feder-runtime-server/src/object.rs @@ -0,0 +1,79 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, State}, + http::{HeaderMap, StatusCode, header}, + response::{IntoResponse, Response}, +}; +use feder_vocab::Iri; +use ref_feder_core::{ActorDispatcher, note::is_public_note, storage::NoteStore}; + +use crate::{FederServer, negotiation::accepts_activitypub}; + +pub async fn note( + State(server): State>>, + Path((identifier, post_id)): Path<(String, String)>, + headers: HeaderMap, +) -> Result +where + A: ActorDispatcher, + S: NoteStore, +{ + let actor = server + .actors() + .get_actor(&identifier) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + let note_id = note_id(&actor.id, &post_id)?; + let note = server + .storage() + .load_note(¬e_id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + if !is_public_note(¬e) { + return Err(StatusCode::NOT_FOUND); + } + if !accepts_activitypub(&headers) { + return Ok(([(header::VARY, "Accept")], StatusCode::NOT_ACCEPTABLE).into_response()); + } + + Ok(( + [ + (header::CONTENT_TYPE, "application/activity+json"), + (header::VARY, "Accept"), + ], + Json(note), + ) + .into_response()) +} + +fn note_id(actor_id: &Iri, post_id: &str) -> Result { + let mut url = + url::Url::parse(actor_id.as_str()).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + url.set_query(None); + url.set_fragment(None); + url.path_segments_mut() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .pop_if_empty() + .push("posts") + .push(post_id); + url.as_str() + .parse() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} diff --git a/examples/ref-actor-server/README.md b/examples/ref-actor-server/README.md index f4f0d30..30b21a0 100644 --- a/examples/ref-actor-server/README.md +++ b/examples/ref-actor-server/README.md @@ -151,6 +151,18 @@ address through `FollowerDeliveryStore`, prefers shared inboxes, deduplicates destinations, and sends the transient Create activity with Alice's key. Send the earlier inbound Follow first to observe delivery to Bob's example inbox. +Fetch the persisted public Note: + +~~~~ sh +curl -i \ + -H 'Accept: application/activity+json' \ + http://127.0.0.1:3000/users/alice/posts/example +~~~~ + +The endpoint derives the canonical Note IRI from Alice and `example`, loads it +through `NoteStore`, verifies its public addressing in core, and returns the +ActivityPub Note with `Vary: Accept`. + The example loads its actor key pair from the repository's test fixture and retains only that pair, the latest follower, the latest outbound Follow, and the latest Note, so repeated requests do not grow an in-memory protocol diff --git a/examples/ref-actor-server/src/main.rs b/examples/ref-actor-server/src/main.rs index 3058c21..9bcbaca 100644 --- a/examples/ref-actor-server/src/main.rs +++ b/examples/ref-actor-server/src/main.rs @@ -206,6 +206,17 @@ impl NoteStore for ExampleStorage { tracing::info!(note = %note.id, "stored Note"); Ok(()) } + + fn load_note(&self, note_id: &Iri) -> Result, Self::Error> { + let latest_note = self + .latest_note + .lock() + .map_err(|_| ExampleStorageError("Note state lock poisoned"))?; + Ok(latest_note + .as_ref() + .filter(|note| note.id == *note_id) + .cloned()) + } } impl FollowerDeliveryStore for ExampleStorage { From ad26128d766ed0fb32578d9741e431491c4c691c Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 2 Aug 2026 15:54:18 +0900 Subject: [PATCH 64/72] Validate WebFinger against the configured handle host Require FederServer users to provide the authoritative WebFinger handle host and validate acct resources against it. Stop trusting the client-controlled Host header when deciding whether an account belongs to the local deployment, and update the reference actor server with its configured handle host. Assisted-by: Codex:gpt-5.6-sol --- crates/ref-feder-runtime-server/src/lib.rs | 10 +++++++++- crates/ref-feder-runtime-server/src/webfinger.rs | 14 ++------------ examples/ref-actor-server/src/main.rs | 2 ++ 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/ref-feder-runtime-server/src/lib.rs b/crates/ref-feder-runtime-server/src/lib.rs index 7d56b5f..476edf0 100644 --- a/crates/ref-feder-runtime-server/src/lib.rs +++ b/crates/ref-feder-runtime-server/src/lib.rs @@ -63,19 +63,21 @@ pub enum Error { pub struct FederServer { actors: A, storage: S, + handle_host: String, resolver: ActorResolver, sender: ActivitySender, inbox_auth_policy: InboxAuthPolicy, } impl FederServer { - pub fn new(actors: A, storage: S) -> Result { + pub fn new(actors: A, storage: S, handle_host: impl Into) -> Result { let policy = OutboundAddressPolicy::PublicOnly; let resolver = ActorResolver::new(policy)?; let sender = ActivitySender::new(policy)?; Ok(Self { actors, storage, + handle_host: handle_host.into(), resolver, sender, inbox_auth_policy: InboxAuthPolicy::RequireSigned, @@ -86,6 +88,7 @@ impl FederServer { pub fn with_outbound_address_policy( actors: A, storage: S, + handle_host: impl Into, policy: OutboundAddressPolicy, ) -> Result { let resolver = ActorResolver::new(policy)?; @@ -93,6 +96,7 @@ impl FederServer { Ok(Self { actors, storage, + handle_host: handle_host.into(), resolver, sender, inbox_auth_policy: InboxAuthPolicy::RequireSigned, @@ -113,6 +117,10 @@ impl FederServer { &self.storage } + pub(crate) fn handle_host(&self) -> &str { + &self.handle_host + } + pub(crate) fn resolver(&self) -> &ActorResolver { &self.resolver } diff --git a/crates/ref-feder-runtime-server/src/webfinger.rs b/crates/ref-feder-runtime-server/src/webfinger.rs index f161bca..d0e2ee3 100644 --- a/crates/ref-feder-runtime-server/src/webfinger.rs +++ b/crates/ref-feder-runtime-server/src/webfinger.rs @@ -19,7 +19,7 @@ use crate::FederServer; use axum::{ Json, extract::{Query, State}, - http::{HeaderMap, StatusCode, header}, + http::{StatusCode, header}, response::{IntoResponse, Response}, }; use ref_feder_core::ActorDispatcher; @@ -47,7 +47,6 @@ pub struct WebFingerResponse { pub async fn webfinger( State(server): State>>, - headers: HeaderMap, Query(query): Query, ) -> Result where @@ -65,9 +64,7 @@ where return Err(StatusCode::BAD_REQUEST); } - let request_host = request_host(&headers).ok_or(StatusCode::BAD_REQUEST)?; - - if !resource_host.eq_ignore_ascii_case(request_host) { + if !resource_host.eq_ignore_ascii_case(server.handle_host()) { return Err(StatusCode::NOT_FOUND); } @@ -92,10 +89,3 @@ where ) .into_response()) } - -fn request_host(headers: &HeaderMap) -> Option<&str> { - headers - .get(header::HOST) - .and_then(|value| value.to_str().ok()) - .filter(|value| !value.is_empty()) -} diff --git a/examples/ref-actor-server/src/main.rs b/examples/ref-actor-server/src/main.rs index 9bcbaca..36d6c3a 100644 --- a/examples/ref-actor-server/src/main.rs +++ b/examples/ref-actor-server/src/main.rs @@ -41,6 +41,7 @@ use ref_feder_runtime_server::{ const IDENTIFIER: &str = "alice"; const ORIGIN: &str = "http://127.0.0.1:3000"; +const HANDLE_HOST: &str = "127.0.0.1:3000"; const REMOTE_ACTOR_ID: &str = "http://127.0.0.1:3000/remote/users/bob"; const ACTOR_PRIVATE_KEY_PEM: &str = include_str!("../../../crates/feder-core/tests/fixtures/rsa-private-key.pem"); @@ -399,6 +400,7 @@ async fn main() -> Result<(), Error> { FederServer::with_outbound_address_policy( dispatcher, storage, + HANDLE_HOST, OutboundAddressPolicy::AllowPrivateAddress, )? .with_inbox_auth_policy(InboxAuthPolicy::AllowUnsignedInsecureDev), From c4aaed30b46e42a71bcd1869655ad0a32bd79605 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 2 Aug 2026 15:58:47 +0900 Subject: [PATCH 65/72] Verify signatures against the selected inbox endpoint Preserve the inbox endpoint selected during request routing and use its authority when verifying signed requests. Personal inbox requests continue to use the actor inbox, while shared inbox requests use the actor's advertised sharedInbox endpoint. Treat requests for actors without a shared inbox as accepted no-ops. Assisted-by: Codex:gpt-5.6-sol --- crates/ref-feder-runtime-server/src/inbox.rs | 27 ++++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/ref-feder-runtime-server/src/inbox.rs b/crates/ref-feder-runtime-server/src/inbox.rs index e527445..4b56788 100644 --- a/crates/ref-feder-runtime-server/src/inbox.rs +++ b/crates/ref-feder-runtime-server/src/inbox.rs @@ -82,9 +82,10 @@ where .get_actor(&identifier) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; + let expected_inbox = local_actor.inbox.clone(); let (request, value) = parse_inbox_request(headers, method, uri, body)?; - receive_activity(&server, local_actor, request, value, None).await + receive_activity(&server, local_actor, expected_inbox, request, value, None).await } pub async fn shared_inbox( @@ -109,8 +110,23 @@ where else { return Ok(StatusCode::ACCEPTED.into_response()); }; + let Some(expected_inbox) = local_actor + .endpoints + .as_ref() + .and_then(|endpoints| endpoints.shared_inbox.clone()) + else { + return Ok(StatusCode::ACCEPTED.into_response()); + }; - receive_activity(&server, local_actor, request, value, pending_follow).await + receive_activity( + &server, + local_actor, + expected_inbox, + request, + value, + pending_follow, + ) + .await } fn parse_inbox_request( @@ -143,6 +159,7 @@ fn parse_inbox_request( async fn receive_activity( server: &FederServer, local_actor: Actor, + expected_inbox: Iri, request: InboxRequest, value: Value, pending_follow: Option, @@ -157,9 +174,9 @@ where InboxAuthPolicy::RequireSigned => Some( verify_signed_request( server.resolver(), - &local_actor, &request, activity_actor_id.as_ref().ok_or(StatusCode::UNAUTHORIZED)?, + &expected_inbox, ) .await?, ), @@ -280,9 +297,9 @@ async fn resolve_actor_reference( async fn verify_signed_request( resolver: &ActorResolver, - local_actor: &Actor, request: &InboxRequest, activity_actor_id: &Iri, + expected_inbox: &Iri, ) -> Result { let signature_header = request .headers @@ -316,7 +333,7 @@ async fn verify_signed_request( return Err(StatusCode::UNAUTHORIZED); } - verify_request_host(&request.headers, &local_actor.inbox)?; + verify_request_host(&request.headers, expected_inbox)?; verify_request_date(&request.headers)?; verify_request_digest(&request.headers, &request.body)?; From 879aeb03ae45ec22057a477fd93b1ddf1737365d Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 2 Aug 2026 16:22:49 +0900 Subject: [PATCH 66/72] Make HTTP signatures optional in the reference core Restore the http-signatures feature boundary around actor keys, digest generation, and draft-Cavage signing and verification. Keep RSA, Base64, and zeroization out of the portable core dependency tree unless explicitly requested. Enable the feature from the server runtime and reference actor example. Assisted-by: Codex:gpt-5.6-sol --- crates/ref-feder-core/Cargo.toml | 9 ++++++--- crates/ref-feder-core/src/lib.rs | 1 + crates/ref-feder-core/src/storage.rs | 5 ++++- crates/ref-feder-runtime-server/Cargo.toml | 2 +- examples/ref-actor-server/Cargo.toml | 2 +- 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/ref-feder-core/Cargo.toml b/crates/ref-feder-core/Cargo.toml index 23b4512..a95a60e 100644 --- a/crates/ref-feder-core/Cargo.toml +++ b/crates/ref-feder-core/Cargo.toml @@ -9,11 +9,14 @@ license.workspace = true homepage.workspace = true repository.workspace = true +[features] +http-signatures = ["dep:base64", "dep:rsa", "dep:zeroize"] + [dependencies] -base64.workspace = true +base64 = { workspace = true, optional = true } feder-vocab.workspace = true -rsa.workspace = true -zeroize.workspace = true +rsa = { workspace = true, optional = true } +zeroize = { workspace = true, optional = true } [lints] workspace = true diff --git a/crates/ref-feder-core/src/lib.rs b/crates/ref-feder-core/src/lib.rs index 6c8c529..9001296 100644 --- a/crates/ref-feder-core/src/lib.rs +++ b/crates/ref-feder-core/src/lib.rs @@ -26,6 +26,7 @@ pub use feder_vocab as vocab; use feder_vocab::{Actor, Iri}; pub mod follow; +#[cfg(feature = "http-signatures")] pub mod key; pub mod note; pub mod storage; diff --git a/crates/ref-feder-core/src/storage.rs b/crates/ref-feder-core/src/storage.rs index 818a91d..7b5ea0a 100644 --- a/crates/ref-feder-core/src/storage.rs +++ b/crates/ref-feder-core/src/storage.rs @@ -17,7 +17,9 @@ use alloc::vec::Vec; use feder_vocab::{Actor, Iri, Note}; -use crate::{follow::PendingFollow, key::ActorKeyPair}; +use crate::follow::PendingFollow; +#[cfg(feature = "http-signatures")] +use crate::key::ActorKeyPair; pub trait Storage { type Error; @@ -26,6 +28,7 @@ pub trait Storage { pub trait ServerStorage: Storage { fn store_follower(&self, follower: &Actor, following: &Iri) -> Result<(), Self::Error>; + #[cfg(feature = "http-signatures")] fn load_actor_key_pair(&self, actor_id: &Iri) -> Result, Self::Error>; fn remove_follower(&self, follower: &Iri, following: &Iri) -> Result<(), Self::Error>; diff --git a/crates/ref-feder-runtime-server/Cargo.toml b/crates/ref-feder-runtime-server/Cargo.toml index 4e7bae5..8f5c166 100644 --- a/crates/ref-feder-runtime-server/Cargo.toml +++ b/crates/ref-feder-runtime-server/Cargo.toml @@ -15,7 +15,7 @@ feder-vocab.workspace = true httpdate = "1" mime = "0.3.17" percent-encoding = "2.3.2" -ref-feder-core.workspace = true +ref-feder-core = { workspace = true, features = ["http-signatures"] } reqwest = { version = "0.13.1", default-features = false, diff --git a/examples/ref-actor-server/Cargo.toml b/examples/ref-actor-server/Cargo.toml index 95c610f..fa66b0d 100644 --- a/examples/ref-actor-server/Cargo.toml +++ b/examples/ref-actor-server/Cargo.toml @@ -7,7 +7,7 @@ license.workspace = true [dependencies] axum = "0.8" feder-vocab.workspace = true -ref-feder-core.workspace = true +ref-feder-core = { workspace = true, features = ["http-signatures"] } ref-feder-runtime-server = { path = "../../crates/ref-feder-runtime-server" } tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] } tracing = "0.1" From 17aa654d3ceaf35fce94f725fa10aecb2c81958a Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 2 Aug 2026 16:30:34 +0900 Subject: [PATCH 67/72] Use compact follower delivery targets Replace complete stored Actor values in follower fan-out with the minimal addressing facts required for delivery: actor ID, inbox, and optional shared inbox. Update Note delivery and the reference example to use the compact target, keeping the storage contract compatible with the existing SQLite follower schema and avoiding unnecessary actor retention. Assisted-by: Codex:gpt-5.6-sol --- crates/ref-feder-core/src/storage.rs | 13 ++++++++++++- crates/ref-feder-runtime-server/src/note.rs | 19 +++++++------------ examples/ref-actor-server/src/main.rs | 18 +++++++++++++++--- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/crates/ref-feder-core/src/storage.rs b/crates/ref-feder-core/src/storage.rs index 7b5ea0a..0f2e25d 100644 --- a/crates/ref-feder-core/src/storage.rs +++ b/crates/ref-feder-core/src/storage.rs @@ -52,6 +52,17 @@ pub trait NoteStore: Storage { fn load_note(&self, note_id: &Iri) -> Result, Self::Error>; } +/// The stored addressing facts needed to deliver an activity to a follower. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FollowerDeliveryTarget { + pub actor_id: Iri, + pub inbox: Iri, + pub shared_inbox: Option, +} + pub trait FollowerDeliveryStore: ServerStorage { - fn list_follower_actors(&self, local_actor: &Iri) -> Result, Self::Error>; + fn list_follower_delivery_targets( + &self, + local_actor: &Iri, + ) -> Result, Self::Error>; } diff --git a/crates/ref-feder-runtime-server/src/note.rs b/crates/ref-feder-runtime-server/src/note.rs index 231dc01..6b396ce 100644 --- a/crates/ref-feder-runtime-server/src/note.rs +++ b/crates/ref-feder-runtime-server/src/note.rs @@ -15,7 +15,7 @@ use std::collections::HashSet; -use feder_vocab::{Actor, Iri}; +use feder_vocab::Iri; use ref_feder_core::{ ActorDispatcher, note::{CreateNoteInput, CreateNoteOutcome, NoteRecipient, create_note}, @@ -99,9 +99,12 @@ where let NoteRecipient::Followers(local_actor_id) = recipient else { continue; }; - for actor in self.storage().list_follower_actors(local_actor_id)? { - covered_actor_ids.insert(actor.id.clone()); - let inbox = preferred_shared_inbox(&actor).clone(); + for target in self + .storage() + .list_follower_delivery_targets(local_actor_id)? + { + covered_actor_ids.insert(target.actor_id); + let inbox = target.shared_inbox.unwrap_or(target.inbox); if seen_inboxes.insert(inbox.clone()) { inboxes.push(inbox); } @@ -133,14 +136,6 @@ where } } -fn preferred_shared_inbox(actor: &Actor) -> &Iri { - actor - .endpoints - .as_ref() - .and_then(|endpoints| endpoints.shared_inbox.as_ref()) - .unwrap_or(&actor.inbox) -} - #[derive(Debug, thiserror::Error)] pub enum CreateNoteError { #[error("actor dispatcher failed")] diff --git a/examples/ref-actor-server/src/main.rs b/examples/ref-actor-server/src/main.rs index 36d6c3a..f496966 100644 --- a/examples/ref-actor-server/src/main.rs +++ b/examples/ref-actor-server/src/main.rs @@ -32,7 +32,7 @@ use ref_feder_core::{ follow::PendingFollow, key::ActorKeyPair, note::CreateNoteInput, - storage::{FollowerDeliveryStore, NoteStore, ServerStorage, Storage}, + storage::{FollowerDeliveryStore, FollowerDeliveryTarget, NoteStore, ServerStorage, Storage}, }; use ref_feder_runtime_server::{ ActorDispatcher, Error, FederServer, InboxAuthPolicy, OutboundAddressPolicy, @@ -221,7 +221,10 @@ impl NoteStore for ExampleStorage { } impl FollowerDeliveryStore for ExampleStorage { - fn list_follower_actors(&self, local_actor: &Iri) -> Result, Self::Error> { + fn list_follower_delivery_targets( + &self, + local_actor: &Iri, + ) -> Result, Self::Error> { let latest_follower = self .latest_follower .lock() @@ -229,7 +232,16 @@ impl FollowerDeliveryStore for ExampleStorage { Ok(latest_follower .as_ref() .filter(|(_, following)| following == local_actor) - .map(|(follower, _)| vec![follower.clone()]) + .map(|(follower, _)| { + vec![FollowerDeliveryTarget { + actor_id: follower.id.clone(), + inbox: follower.inbox.clone(), + shared_inbox: follower + .endpoints + .as_ref() + .and_then(|endpoints| endpoints.shared_inbox.clone()), + }] + }) .unwrap_or_default()) } } From 81b6fea5e4a46e3f0390e5b9405418f06dc73ef0 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 2 Aug 2026 16:37:38 +0900 Subject: [PATCH 68/72] Add SQLite storage to the reference server Provide file-backed and in-memory SQLite storage implementing the new server, Note, and follower-delivery storage capabilities. Preserve the existing followers, actor keys, and objects schema while adding persistent outbound Follow state. Synchronize connection access for shared Axum state and use an immediate transaction for exact pending-Follow confirmation. Add focused coverage for follower delivery facts, Notes, pending Follow transitions, actor keys, and Send and Sync compatibility. Assisted-by: Codex:gpt-5.6-sol --- Cargo.lock | 1 + crates/ref-feder-runtime-server/Cargo.toml | 1 + crates/ref-feder-runtime-server/src/lib.rs | 1 + .../src/storage/mod.rs | 44 ++ .../src/storage/sqlite.rs | 518 ++++++++++++++++++ 5 files changed, 565 insertions(+) create mode 100644 crates/ref-feder-runtime-server/src/storage/mod.rs create mode 100644 crates/ref-feder-runtime-server/src/storage/sqlite.rs diff --git a/Cargo.lock b/Cargo.lock index 20fe1af..6c622d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1148,6 +1148,7 @@ dependencies = [ "percent-encoding", "ref-feder-core", "reqwest", + "rusqlite", "serde", "serde_json", "thiserror", diff --git a/crates/ref-feder-runtime-server/Cargo.toml b/crates/ref-feder-runtime-server/Cargo.toml index 8f5c166..6ea3fea 100644 --- a/crates/ref-feder-runtime-server/Cargo.toml +++ b/crates/ref-feder-runtime-server/Cargo.toml @@ -21,6 +21,7 @@ reqwest = { default-features = false, features = ["rustls"], } +rusqlite = { version = "0.40.1", features = ["bundled"] } serde.workspace = true serde_json.workspace = true thiserror = "2.0.19" diff --git a/crates/ref-feder-runtime-server/src/lib.rs b/crates/ref-feder-runtime-server/src/lib.rs index 476edf0..ad98eee 100644 --- a/crates/ref-feder-runtime-server/src/lib.rs +++ b/crates/ref-feder-runtime-server/src/lib.rs @@ -27,6 +27,7 @@ pub mod negotiation; pub mod note; pub mod object; pub mod send; +pub mod storage; pub mod url; pub mod webfinger; diff --git a/crates/ref-feder-runtime-server/src/storage/mod.rs b/crates/ref-feder-runtime-server/src/storage/mod.rs new file mode 100644 index 0000000..2d2a0fa --- /dev/null +++ b/crates/ref-feder-runtime-server/src/storage/mod.rs @@ -0,0 +1,44 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +mod sqlite; + +pub use sqlite::SqliteStore; + +use ref_feder_core::key::KeyError; + +#[derive(Debug, thiserror::Error)] +pub enum StoreError { + #[error("I/O error")] + Io(#[from] std::io::Error), + + #[error("SQLite error")] + Sqlite(#[from] rusqlite::Error), + + #[error("JSON error")] + Json(#[from] serde_json::Error), + + #[error("invalid IRI: {0}")] + InvalidIri(String), + + #[error("unsupported stored object type: {0}")] + UnsupportedStoredObjectType(String), + + #[error("storage lock poisoned")] + LockPoisoned, + + #[error(transparent)] + ActorKey(#[from] KeyError), +} diff --git a/crates/ref-feder-runtime-server/src/storage/sqlite.rs b/crates/ref-feder-runtime-server/src/storage/sqlite.rs new file mode 100644 index 0000000..bfdbe6f --- /dev/null +++ b/crates/ref-feder-runtime-server/src/storage/sqlite.rs @@ -0,0 +1,518 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::{ + path::Path, + sync::{Mutex, MutexGuard}, +}; + +#[cfg(unix)] +use std::{ + fs::OpenOptions, + os::unix::fs::{OpenOptionsExt, PermissionsExt}, +}; + +use feder_vocab::{Actor, Iri, Note}; +use ref_feder_core::{ + follow::PendingFollow, + key::ActorKeyPair, + storage::{FollowerDeliveryStore, FollowerDeliveryTarget, NoteStore, ServerStorage, Storage}, +}; +use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params}; + +use super::StoreError; + +pub struct SqliteStore { + connection: Mutex, +} + +impl SqliteStore { + pub fn open(path: &Path) -> Result { + #[cfg(unix)] + let database_file = prepare_database_file(path)?; + + let store = Self { + connection: Mutex::new(Connection::open(path)?), + }; + + #[cfg(unix)] + drop(database_file); + + store.init()?; + Ok(store) + } + + pub fn open_in_memory() -> Result { + let store = Self { + connection: Mutex::new(Connection::open_in_memory()?), + }; + store.init()?; + Ok(store) + } + + pub fn insert_actor_key_pair( + &self, + actor_id: &Iri, + key_pair: &ActorKeyPair, + ) -> Result<(), StoreError> { + self.connection()?.execute( + r#" + INSERT INTO keys (actor_id, private_key_pem, public_key_pem) + VALUES (?1, ?2, ?3) + "#, + params![ + actor_id.as_str(), + key_pair.private_key_pem(), + key_pair.public_key_pem(), + ], + )?; + Ok(()) + } + + fn init(&self) -> Result<(), StoreError> { + self.connection()?.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS followers ( + follower_actor_id TEXT NOT NULL, + following_actor_id TEXT NOT NULL, + inbox_url TEXT, + shared_inbox_url TEXT, + PRIMARY KEY (follower_actor_id, following_actor_id) + ); + CREATE INDEX IF NOT EXISTS idx_followers_following_actor_id + ON followers (following_actor_id); + CREATE TABLE IF NOT EXISTS keys ( + actor_id TEXT PRIMARY KEY NOT NULL, + private_key_pem TEXT NOT NULL, + public_key_pem TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS objects ( + object_id TEXT PRIMARY KEY NOT NULL, + object_type TEXT NOT NULL, + object_json TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS outbound_follows ( + follow_activity_id TEXT PRIMARY KEY NOT NULL, + local_actor_id TEXT NOT NULL, + remote_actor_json TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'accepted')) + ); + "#, + )?; + Ok(()) + } + + fn connection(&self) -> Result, StoreError> { + self.connection.lock().map_err(|_| StoreError::LockPoisoned) + } +} + +#[cfg(unix)] +fn prepare_database_file(path: &Path) -> Result { + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(path)?; + + let mut permissions = file.metadata()?.permissions(); + permissions.set_mode(0o600); + file.set_permissions(permissions)?; + Ok(file) +} + +impl Storage for SqliteStore { + type Error = StoreError; +} + +impl ServerStorage for SqliteStore { + fn store_follower(&self, follower: &Actor, following: &Iri) -> Result<(), Self::Error> { + let shared_inbox = follower + .endpoints + .as_ref() + .and_then(|endpoints| endpoints.shared_inbox.as_ref()); + self.connection()?.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![ + follower.id.as_str(), + following.as_str(), + follower.inbox.as_str(), + shared_inbox.map(|inbox| inbox.as_str()), + ], + )?; + Ok(()) + } + + fn load_actor_key_pair(&self, actor_id: &Iri) -> Result, Self::Error> { + let encoded_keys = self + .connection()? + .query_row( + r#" + SELECT private_key_pem, public_key_pem + FROM keys + WHERE actor_id = ?1 + "#, + [actor_id.as_str()], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?; + + encoded_keys + .map(|(private_key_pem, public_key_pem)| { + ActorKeyPair::from_pem(private_key_pem, public_key_pem) + }) + .transpose() + .map_err(StoreError::from) + } + + fn remove_follower(&self, follower: &Iri, following: &Iri) -> Result<(), Self::Error> { + self.connection()?.execute( + r#" + DELETE FROM followers + WHERE follower_actor_id = ?1 AND following_actor_id = ?2 + "#, + params![follower.as_str(), following.as_str()], + )?; + Ok(()) + } + + fn list_followers(&self, following: &Iri) -> Result, Self::Error> { + let connection = self.connection()?; + let mut statement = connection.prepare( + r#" + SELECT follower_actor_id + FROM followers + WHERE following_actor_id = ?1 + ORDER BY follower_actor_id + "#, + )?; + let rows = statement.query_map([following.as_str()], |row| row.get::<_, String>(0))?; + rows.map(|row| parse_iri(row?)).collect() + } + + fn store_pending_follow(&self, follow: &PendingFollow) -> Result<(), Self::Error> { + let remote_actor_json = serde_json::to_string(&follow.remote_actor)?; + self.connection()?.execute( + r#" + INSERT INTO outbound_follows ( + follow_activity_id, + local_actor_id, + remote_actor_json, + state + ) + VALUES (?1, ?2, ?3, 'pending') + ON CONFLICT(follow_activity_id) DO UPDATE SET + local_actor_id = excluded.local_actor_id, + remote_actor_json = excluded.remote_actor_json, + state = 'pending' + "#, + params![ + follow.follow_activity.as_str(), + follow.local_actor.as_str(), + remote_actor_json, + ], + )?; + Ok(()) + } + + fn load_pending_follow( + &self, + follow_activity: &Iri, + ) -> Result, Self::Error> { + let connection = self.connection()?; + load_pending_follow(&connection, follow_activity) + } + + fn confirm_pending_follow(&self, expected: &PendingFollow) -> Result { + let mut connection = self.connection()?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let stored = load_pending_follow(&transaction, &expected.follow_activity)?; + if stored.as_ref() != Some(expected) { + return Ok(false); + } + let changed = transaction.execute( + r#" + UPDATE outbound_follows + SET state = 'accepted' + WHERE follow_activity_id = ?1 AND state = 'pending' + "#, + [expected.follow_activity.as_str()], + )?; + transaction.commit()?; + Ok(changed == 1) + } +} + +impl NoteStore for SqliteStore { + fn store_note(&self, note: &Note) -> Result<(), Self::Error> { + let note_json = serde_json::to_string(note)?; + self.connection()?.execute( + r#" + INSERT INTO objects (object_id, object_type, object_json) + VALUES (?1, 'Note', ?2) + ON CONFLICT(object_id) DO UPDATE SET + object_type = excluded.object_type, + object_json = excluded.object_json + "#, + params![note.id.as_str(), note_json], + )?; + Ok(()) + } + + fn load_note(&self, note_id: &Iri) -> Result, Self::Error> { + let stored = self + .connection()? + .query_row( + r#" + SELECT object_type, object_json + FROM objects + WHERE object_id = ?1 + "#, + [note_id.as_str()], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?; + + stored + .map(|(object_type, object_json)| { + if object_type != "Note" { + return Err(StoreError::UnsupportedStoredObjectType(object_type)); + } + serde_json::from_str(&object_json).map_err(StoreError::from) + }) + .transpose() + } +} + +impl FollowerDeliveryStore for SqliteStore { + fn list_follower_delivery_targets( + &self, + local_actor: &Iri, + ) -> Result, Self::Error> { + let connection = self.connection()?; + let mut statement = connection.prepare( + r#" + SELECT follower_actor_id, inbox_url, shared_inbox_url + FROM followers + WHERE following_actor_id = ?1 AND inbox_url IS NOT NULL + ORDER BY follower_actor_id + "#, + )?; + let rows = statement.query_map([local_actor.as_str()], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + )) + })?; + + rows.map(|row| { + let (actor_id, inbox, shared_inbox) = row?; + Ok(FollowerDeliveryTarget { + actor_id: parse_iri(actor_id)?, + inbox: parse_iri(inbox)?, + shared_inbox: shared_inbox.map(parse_iri).transpose()?, + }) + }) + .collect() + } +} + +fn load_pending_follow( + connection: &Connection, + follow_activity: &Iri, +) -> Result, StoreError> { + let stored = connection + .query_row( + r#" + SELECT local_actor_id, remote_actor_json + FROM outbound_follows + WHERE follow_activity_id = ?1 AND state = 'pending' + "#, + [follow_activity.as_str()], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?; + stored + .map(|(local_actor, remote_actor_json)| { + Ok(PendingFollow { + local_actor: parse_iri(local_actor)?, + remote_actor: serde_json::from_str(&remote_actor_json)?, + follow_activity: follow_activity.clone(), + }) + }) + .transpose() +} + +fn parse_iri(value: String) -> Result { + value + .parse() + .map_err(|_| StoreError::InvalidIri(value.to_owned())) +} + +#[cfg(test)] +mod tests { + use feder_vocab::{Endpoints, Reference}; + + use super::*; + + const PRIVATE_KEY_PEM: &str = + include_str!("../../../feder-core/tests/fixtures/rsa-private-key.pem"); + const PUBLIC_KEY_PEM: &str = + include_str!("../../../feder-core/tests/fixtures/rsa-public-key.pem"); + + fn iri(value: &str) -> Iri { + value.parse().expect("valid test IRI") + } + + fn actor(id: &str) -> Actor { + Actor::person( + iri(id), + iri(&format!("{id}/inbox")), + iri(&format!("{id}/outbox")), + ) + } + + #[test] + fn stores_lists_and_removes_follower_delivery_facts() { + let store = SqliteStore::open_in_memory().expect("open store"); + let following = iri("https://local.example/users/alice"); + let mut follower = actor("https://remote.example/users/bob"); + follower.endpoints = Some(Endpoints { + shared_inbox: Some(iri("https://remote.example/inbox")), + }); + + store + .store_follower(&follower, &following) + .expect("store follower"); + + assert_eq!( + store.list_followers(&following).expect("list followers"), + vec![follower.id.clone()] + ); + assert_eq!( + store + .list_follower_delivery_targets(&following) + .expect("list delivery targets"), + vec![FollowerDeliveryTarget { + actor_id: follower.id.clone(), + inbox: follower.inbox.clone(), + shared_inbox: follower + .endpoints + .and_then(|endpoints| endpoints.shared_inbox), + }] + ); + + store + .remove_follower(&follower.id, &following) + .expect("remove follower"); + assert!( + store + .list_followers(&following) + .expect("list followers") + .is_empty() + ); + } + + #[test] + fn stores_and_loads_note() { + let store = SqliteStore::open_in_memory().expect("open store"); + let mut note = Note::new(iri("https://local.example/posts/1")); + note.attributed_to = Some(Reference::id(iri("https://local.example/users/alice"))); + note.content = Some("hello".to_string()); + + store.store_note(¬e).expect("store Note"); + + assert_eq!(store.load_note(¬e.id).expect("load Note"), Some(note)); + } + + #[test] + fn confirms_only_the_expected_pending_follow() { + let store = SqliteStore::open_in_memory().expect("open store"); + let pending = PendingFollow { + local_actor: iri("https://local.example/users/alice"), + remote_actor: actor("https://remote.example/users/bob"), + follow_activity: iri("https://local.example/activities/follow/1"), + }; + store + .store_pending_follow(&pending) + .expect("store pending Follow"); + + let mut wrong = pending.clone(); + wrong.local_actor = iri("https://local.example/users/mallory"); + assert!( + !store + .confirm_pending_follow(&wrong) + .expect("reject mismatch") + ); + assert_eq!( + store + .load_pending_follow(&pending.follow_activity) + .expect("load pending Follow"), + Some(pending.clone()) + ); + + assert!( + store + .confirm_pending_follow(&pending) + .expect("confirm pending Follow") + ); + assert_eq!( + store + .load_pending_follow(&pending.follow_activity) + .expect("load accepted Follow"), + None + ); + } + + #[test] + fn actor_key_pair_roundtrips() { + let store = SqliteStore::open_in_memory().expect("open store"); + let actor_id = iri("https://local.example/users/alice"); + let key_pair = + ActorKeyPair::from_pem(PRIVATE_KEY_PEM.to_string(), PUBLIC_KEY_PEM.to_string()) + .expect("valid key pair"); + + store + .insert_actor_key_pair(&actor_id, &key_pair) + .expect("store actor keys"); + + assert_eq!( + store + .load_actor_key_pair(&actor_id) + .expect("load actor keys"), + Some(key_pair) + ); + } + + #[test] + fn sqlite_store_is_send_and_sync() { + fn assert_send_sync() {} + assert_send_sync::(); + } +} From 5b670839fa305b61fc655ef0ff1d44d4ea582345 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 2 Aug 2026 16:44:02 +0900 Subject: [PATCH 69/72] Provision persistent actor signing keys Add a SQLite-backed load-or-generate operation for actor signing identities. Reuse existing actor keys without invoking the random number generator. When a key is absent, generate it outside the database lock and insert it without replacing an identity established by a concurrent provisioner. Add focused coverage that verifies initial provisioning and stable reuse. Assisted-by: Codex:gpt-5.6-sol --- Cargo.lock | 1 + crates/ref-feder-runtime-server/Cargo.toml | 1 + .../src/storage/mod.rs | 3 + .../src/storage/sqlite.rs | 114 +++++++++++++++--- 4 files changed, 99 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6c622d6..6beb731 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1146,6 +1146,7 @@ dependencies = [ "ipnet", "mime", "percent-encoding", + "rand_core 0.6.4", "ref-feder-core", "reqwest", "rusqlite", diff --git a/crates/ref-feder-runtime-server/Cargo.toml b/crates/ref-feder-runtime-server/Cargo.toml index 6ea3fea..dcd6f17 100644 --- a/crates/ref-feder-runtime-server/Cargo.toml +++ b/crates/ref-feder-runtime-server/Cargo.toml @@ -15,6 +15,7 @@ feder-vocab.workspace = true httpdate = "1" mime = "0.3.17" percent-encoding = "2.3.2" +rand_core.workspace = true ref-feder-core = { workspace = true, features = ["http-signatures"] } reqwest = { version = "0.13.1", diff --git a/crates/ref-feder-runtime-server/src/storage/mod.rs b/crates/ref-feder-runtime-server/src/storage/mod.rs index 2d2a0fa..9975e3d 100644 --- a/crates/ref-feder-runtime-server/src/storage/mod.rs +++ b/crates/ref-feder-runtime-server/src/storage/mod.rs @@ -39,6 +39,9 @@ pub enum StoreError { #[error("storage lock poisoned")] LockPoisoned, + #[error("actor key was not stored after provisioning")] + ActorKeyProvisioning, + #[error(transparent)] ActorKey(#[from] KeyError), } diff --git a/crates/ref-feder-runtime-server/src/storage/sqlite.rs b/crates/ref-feder-runtime-server/src/storage/sqlite.rs index bfdbe6f..885875d 100644 --- a/crates/ref-feder-runtime-server/src/storage/sqlite.rs +++ b/crates/ref-feder-runtime-server/src/storage/sqlite.rs @@ -25,9 +25,10 @@ use std::{ }; use feder_vocab::{Actor, Iri, Note}; +use rand_core::CryptoRngCore; use ref_feder_core::{ follow::PendingFollow, - key::ActorKeyPair, + key::{ActorKeyPair, generate_actor_key_pair}, storage::{FollowerDeliveryStore, FollowerDeliveryTarget, NoteStore, ServerStorage, Storage}, }; use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params}; @@ -81,6 +82,53 @@ impl SqliteStore { Ok(()) } + /// Loads the actor's existing signing identity or provisions it once. + /// + /// Concurrent provisioners keep the first key pair inserted for the actor; + /// an existing identity is never replaced. + pub fn load_or_generate_actor_key_pair( + &self, + actor_id: &Iri, + rng: &mut (impl CryptoRngCore + ?Sized), + ) -> Result { + self.load_or_insert_actor_key_pair(actor_id, || { + generate_actor_key_pair(rng).map_err(StoreError::from) + }) + } + + fn load_or_insert_actor_key_pair( + &self, + actor_id: &Iri, + generate: impl FnOnce() -> Result, + ) -> Result { + { + let connection = self.connection()?; + if let Some(key_pair) = load_actor_key_pair(&connection, actor_id)? { + return Ok(key_pair); + } + } + + let generated = generate()?; + let connection = self.connection()?; + let inserted = connection.execute( + r#" + INSERT INTO keys (actor_id, private_key_pem, public_key_pem) + VALUES (?1, ?2, ?3) + ON CONFLICT(actor_id) DO NOTHING + "#, + params![ + actor_id.as_str(), + generated.private_key_pem(), + generated.public_key_pem(), + ], + )?; + if inserted == 1 { + Ok(generated) + } else { + load_actor_key_pair(&connection, actor_id)?.ok_or(StoreError::ActorKeyProvisioning) + } + } + fn init(&self) -> Result<(), StoreError> { self.connection()?.execute_batch( r#" @@ -169,25 +217,8 @@ impl ServerStorage for SqliteStore { } fn load_actor_key_pair(&self, actor_id: &Iri) -> Result, Self::Error> { - let encoded_keys = self - .connection()? - .query_row( - r#" - SELECT private_key_pem, public_key_pem - FROM keys - WHERE actor_id = ?1 - "#, - [actor_id.as_str()], - |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), - ) - .optional()?; - - encoded_keys - .map(|(private_key_pem, public_key_pem)| { - ActorKeyPair::from_pem(private_key_pem, public_key_pem) - }) - .transpose() - .map_err(StoreError::from) + let connection = self.connection()?; + load_actor_key_pair(&connection, actor_id) } fn remove_follower(&self, follower: &Iri, following: &Iri) -> Result<(), Self::Error> { @@ -369,6 +400,30 @@ fn load_pending_follow( .transpose() } +fn load_actor_key_pair( + connection: &Connection, + actor_id: &Iri, +) -> Result, StoreError> { + let encoded_keys = connection + .query_row( + r#" + SELECT private_key_pem, public_key_pem + FROM keys + WHERE actor_id = ?1 + "#, + [actor_id.as_str()], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?; + + encoded_keys + .map(|(private_key_pem, public_key_pem)| { + ActorKeyPair::from_pem(private_key_pem, public_key_pem) + }) + .transpose() + .map_err(StoreError::from) +} + fn parse_iri(value: String) -> Result { value .parse() @@ -510,6 +565,25 @@ mod tests { ); } + #[test] + fn provisions_an_actor_key_once() { + let store = SqliteStore::open_in_memory().expect("open store"); + let actor_id = iri("https://local.example/users/alice"); + let key_pair = + ActorKeyPair::from_pem(PRIVATE_KEY_PEM.to_string(), PUBLIC_KEY_PEM.to_string()) + .expect("valid key pair"); + + let first = store + .load_or_insert_actor_key_pair(&actor_id, || Ok(key_pair.clone())) + .expect("provision actor key"); + let second = store + .load_or_insert_actor_key_pair(&actor_id, || panic!("existing key must be reused")) + .expect("load actor key"); + + assert_eq!(first, key_pair); + assert_eq!(second, first); + } + #[test] fn sqlite_store_is_send_and_sync() { fn assert_send_sync() {} From 5d9521ec055509b0ec84ec8caef2679139bac035 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 2 Aug 2026 16:55:25 +0900 Subject: [PATCH 70/72] Migrate the single-user server to the reference runtime Replace the original RuntimeConfig-based example with the new FederServer, actor dispatcher, and built-in SQLite storage adapter. Provision the actor signing key through SQLite, publish its public key on the actor document, and preserve the signing identity across server restarts. Allow the database path to be configured through FEDER_DATABASE. Update the example documentation with actor and WebFinger requests. Assisted-by: Codex:gpt-5.6-sol --- Cargo.lock | 5 +- examples/single-user-server/Cargo.toml | 5 +- examples/single-user-server/README.md | 19 ++-- examples/single-user-server/src/main.rs | 114 ++++++++++++++++++------ 4 files changed, 107 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6beb731..69ba36b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1541,7 +1541,10 @@ name = "single-user-server" version = "0.1.0" dependencies = [ "axum", - "feder-runtime-server", + "feder-vocab", + "rand_core 0.6.4", + "ref-feder-core", + "ref-feder-runtime-server", "tokio", "tracing", "tracing-subscriber", diff --git a/examples/single-user-server/Cargo.toml b/examples/single-user-server/Cargo.toml index 5cac0c3..07f5e08 100644 --- a/examples/single-user-server/Cargo.toml +++ b/examples/single-user-server/Cargo.toml @@ -6,7 +6,10 @@ license.workspace = true [dependencies] axum = "0.8" -feder-runtime-server = { path = "../../crates/feder-runtime-server" } +feder-vocab.workspace = true +rand_core.workspace = true +ref-feder-core = { workspace = true, features = ["http-signatures"] } +ref-feder-runtime-server = { path = "../../crates/ref-feder-runtime-server" } tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/examples/single-user-server/README.md b/examples/single-user-server/README.md index e5527cc..f49c93a 100644 --- a/examples/single-user-server/README.md +++ b/examples/single-user-server/README.md @@ -1,7 +1,8 @@ Single-User Server Example ========================== -Demo app using `feder-runtime-server` with one hardcoded local actor. +Demo app using `ref-feder-runtime-server` with one hardcoded local actor and +the built-in SQLite storage adapter. Run @@ -13,6 +14,9 @@ The example currently targets Linux: RUST_LOG=info cargo run -p single-user-server ~~~~ +The server stores its actor signing identity, followers, outbound Follow +state, and Notes in `feder.sqlite3`. Set `FEDER_DATABASE` to use another path. + The demo actor is: ~~~~ text @@ -25,14 +29,17 @@ The server listens on: 127.0.0.1:3000 ~~~~ -Check the process: +Fetch the actor document: ~~~~ sh -curl -i http://127.0.0.1:3000/healthz +curl -i \ + -H 'Accept: application/activity+json' \ + http://127.0.0.1:3000/users/alice ~~~~ -Expected response: +Discover the actor through WebFinger: -~~~~ text -HTTP/1.1 204 No Content +~~~~ sh +curl -i \ + 'http://127.0.0.1:3000/.well-known/webfinger?resource=acct:alice@127.0.0.1:3000' ~~~~ diff --git a/examples/single-user-server/src/main.rs b/examples/single-user-server/src/main.rs index d4d1f37..123b6af 100644 --- a/examples/single-user-server/src/main.rs +++ b/examples/single-user-server/src/main.rs @@ -13,49 +13,107 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use feder_runtime_server::{ - Error, InboxAuthPolicy, OutboundAddressPolicy, RuntimeConfig, StorageConfig, build_router, +use std::{convert::Infallible, env, error::Error, net::SocketAddr, path::PathBuf}; + +use feder_vocab::{Actor, CryptographicKey, Endpoints, Iri, Reference}; +use rand_core::OsRng; +use ref_feder_core::{ActorDispatcher, key::ActorKeyPair}; +use ref_feder_runtime_server::{ + FederServer, InboxAuthPolicy, OutboundAddressPolicy, build_router, storage::SqliteStore, }; -fn default_local() -> RuntimeConfig { - RuntimeConfig { - bind: "127.0.0.1:3000" +const IDENTIFIER: &str = "alice"; +const ORIGIN: &str = "http://127.0.0.1:3000"; +const HANDLE_HOST: &str = "127.0.0.1:3000"; +const DEFAULT_DATABASE_PATH: &str = "feder.sqlite3"; + +struct SingleActorDispatcher { + actor: Actor, +} + +impl ActorDispatcher for SingleActorDispatcher { + type Error = Infallible; + + fn get_actor(&self, identifier: &str) -> Result, Self::Error> { + Ok((identifier == IDENTIFIER).then(|| self.actor.clone())) + } + + fn get_actor_by_id(&self, actor_id: &Iri) -> Result, Self::Error> { + Ok((actor_id == &self.actor.id).then(|| self.actor.clone())) + } +} + +fn local_actor(key_pair: &ActorKeyPair) -> Actor { + let actor_id = format!("{ORIGIN}/users/{IDENTIFIER}"); + let mut actor = Actor::person( + actor_id.parse().expect("valid actor IRI"), + format!("{actor_id}/inbox") .parse() - .expect("valid default bind address"), - actor_id: "http://127.0.0.1:3000/users/alice" + .expect("valid inbox IRI"), + format!("{actor_id}/outbox") .parse() - .expect("valid default actor IRI"), - inbox: "http://127.0.0.1:3000/users/alice/inbox" + .expect("valid outbox IRI"), + ); + actor.preferred_username = Some(IDENTIFIER.to_string()); + actor.name = Some("Alice".to_string()); + actor.followers = Some( + format!("{actor_id}/followers") .parse() - .expect("valid default inbox IRI"), - outbox: "http://127.0.0.1:3000/users/alice/outbox" + .expect("valid followers collection IRI"), + ); + actor.endpoints = Some(Endpoints { + shared_inbox: Some( + format!("{ORIGIN}/inbox") + .parse() + .expect("valid shared inbox IRI"), + ), + }); + actor.set_public_key(Reference::object(CryptographicKey::new( + format!("{actor_id}#main-key") .parse() - .expect("valid default outbox IRI"), - username: "alice".to_string(), - handle_host: "127.0.0.1:3000".to_string(), - inbox_auth_policy: InboxAuthPolicy::AllowUnsignedInsecureDev, - outbound_address_policy: OutboundAddressPolicy::AllowPrivateAddress, - storage: StorageConfig::InMemory, - } + .expect("valid actor key IRI"), + actor.id.clone(), + key_pair.public_key_pem().to_string(), + ))); + actor } #[tokio::main] -async fn main() -> Result<(), Error> { +async fn main() -> Result<(), Box> { tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); - let config = default_local(); - let bind = config.bind; - let actor_id = config.actor_id.clone(); - let app = build_router(config)?; + let database_path = env::var_os("FEDER_DATABASE") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(DEFAULT_DATABASE_PATH)); + let storage = SqliteStore::open(&database_path)?; + let actor_id = format!("{ORIGIN}/users/{IDENTIFIER}") + .parse() + .expect("valid actor IRI"); + let actor_key_pair = storage.load_or_generate_actor_key_pair(&actor_id, &mut OsRng)?; + let dispatcher = SingleActorDispatcher { + actor: local_actor(&actor_key_pair), + }; + let server = FederServer::with_outbound_address_policy( + dispatcher, + storage, + HANDLE_HOST, + OutboundAddressPolicy::AllowPrivateAddress, + )? + .with_inbox_auth_policy(InboxAuthPolicy::AllowUnsignedInsecureDev); + let app = build_router(server); + let bind: SocketAddr = "127.0.0.1:3000".parse()?; - tracing::info!(bind = %bind, actor = %actor_id, "starting Feder single-user example"); + tracing::info!( + bind = %bind, + actor = %actor_id, + database = %database_path.display(), + "starting Feder single-user example" + ); - let listener = tokio::net::TcpListener::bind(bind) - .await - .map_err(Error::Bind)?; - axum::serve(listener, app).await.map_err(Error::Serve)?; + let listener = tokio::net::TcpListener::bind(bind).await?; + axum::serve(listener, app).await?; Ok(()) } From 2446f3e4ee6e34763d33e657093f8ac962d238ea Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 2 Aug 2026 17:17:10 +0900 Subject: [PATCH 71/72] Port migration coverage into the reference crate test suites Move protocol transition tests into ref-feder-core/tests, covering Follow, Accept, Undo, Note construction, actor keys, digests, and HTTP signatures. Move the SQLite adapter tests out of the implementation module and into the ref-feder-runtime-server integration test suite. Add runtime coverage for actor and object endpoints, WebFinger, follower collections, actor resolution, outbound delivery, signed personal and shared inboxes, outbound Follow, incoming Undo, and Note persistence and delivery. Keep test keys and shared test infrastructure local to each crate so the reference crates can be tested independently. Assisted-by: Codex:gpt-5.6-sol --- Cargo.lock | 1 + crates/ref-feder-core/Cargo.toml | 5 + crates/ref-feder-core/tests/common/mod.rs | 13 + .../tests/fixtures/rsa-other-public-key.pem | 9 + .../tests/fixtures/rsa-private-key.pem | 28 ++ .../tests/fixtures/rsa-public-key.pem | 9 + crates/ref-feder-core/tests/follow.rs | 142 +++++++ crates/ref-feder-core/tests/key.rs | 72 ++++ crates/ref-feder-core/tests/note.rs | 92 +++++ crates/ref-feder-core/tests/undo.rs | 70 ++++ crates/ref-feder-runtime-server/Cargo.toml | 4 + .../src/storage/sqlite.rs | 161 -------- .../tests/cases/actor.rs | 161 ++++++++ .../tests/cases/followers.rs | 115 ++++++ .../tests/cases/inbox.rs | 347 ++++++++++++++++++ .../tests/cases/object.rs | 129 +++++++ .../tests/cases/operation.rs | 148 ++++++++ .../tests/cases/send.rs | 113 ++++++ .../tests/cases/webfinger.rs | 62 ++++ .../tests/common/mod.rs | 143 ++++++++ .../tests/fixtures/rsa-private-key.pem | 28 ++ .../tests/fixtures/rsa-public-key.pem | 9 + .../ref-feder-runtime-server/tests/runtime.rs | 16 + .../ref-feder-runtime-server/tests/storage.rs | 160 ++++++++ 24 files changed, 1876 insertions(+), 161 deletions(-) create mode 100644 crates/ref-feder-core/tests/common/mod.rs create mode 100644 crates/ref-feder-core/tests/fixtures/rsa-other-public-key.pem create mode 100644 crates/ref-feder-core/tests/fixtures/rsa-private-key.pem create mode 100644 crates/ref-feder-core/tests/fixtures/rsa-public-key.pem create mode 100644 crates/ref-feder-core/tests/follow.rs create mode 100644 crates/ref-feder-core/tests/key.rs create mode 100644 crates/ref-feder-core/tests/note.rs create mode 100644 crates/ref-feder-core/tests/undo.rs create mode 100644 crates/ref-feder-runtime-server/tests/cases/actor.rs create mode 100644 crates/ref-feder-runtime-server/tests/cases/followers.rs create mode 100644 crates/ref-feder-runtime-server/tests/cases/inbox.rs create mode 100644 crates/ref-feder-runtime-server/tests/cases/object.rs create mode 100644 crates/ref-feder-runtime-server/tests/cases/operation.rs create mode 100644 crates/ref-feder-runtime-server/tests/cases/send.rs create mode 100644 crates/ref-feder-runtime-server/tests/cases/webfinger.rs create mode 100644 crates/ref-feder-runtime-server/tests/common/mod.rs create mode 100644 crates/ref-feder-runtime-server/tests/fixtures/rsa-private-key.pem create mode 100644 crates/ref-feder-runtime-server/tests/fixtures/rsa-public-key.pem create mode 100644 crates/ref-feder-runtime-server/tests/runtime.rs create mode 100644 crates/ref-feder-runtime-server/tests/storage.rs diff --git a/Cargo.lock b/Cargo.lock index 69ba36b..c781ab5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1154,6 +1154,7 @@ dependencies = [ "serde_json", "thiserror", "tokio", + "tower", "url", ] diff --git a/crates/ref-feder-core/Cargo.toml b/crates/ref-feder-core/Cargo.toml index a95a60e..81fe410 100644 --- a/crates/ref-feder-core/Cargo.toml +++ b/crates/ref-feder-core/Cargo.toml @@ -20,3 +20,8 @@ zeroize = { workspace = true, optional = true } [lints] workspace = true + +[[test]] +name = "key" +path = "tests/key.rs" +required-features = ["http-signatures"] diff --git a/crates/ref-feder-core/tests/common/mod.rs b/crates/ref-feder-core/tests/common/mod.rs new file mode 100644 index 0000000..5a9593b --- /dev/null +++ b/crates/ref-feder-core/tests/common/mod.rs @@ -0,0 +1,13 @@ +use feder_vocab::{Actor, Iri}; + +pub fn iri(value: &str) -> Iri { + value.parse().expect("valid test IRI") +} + +pub fn actor(id: &str) -> Actor { + Actor::person( + iri(id), + iri(&format!("{id}/inbox")), + iri(&format!("{id}/outbox")), + ) +} diff --git a/crates/ref-feder-core/tests/fixtures/rsa-other-public-key.pem b/crates/ref-feder-core/tests/fixtures/rsa-other-public-key.pem new file mode 100644 index 0000000..86fa3a6 --- /dev/null +++ b/crates/ref-feder-core/tests/fixtures/rsa-other-public-key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAo9l6PexyP7BtQOAvgpFV +Sb+iFq5jPL3VoFdr1jevGhBHslqP881CUQHOsSzjJFDkHFTHhN30uuucceAajt9N +IpPAoS9Ft6ouYDheg/MZEPNvbnUuen7IAx1hOSAGLzdXKHIFbQp9eBEcc8pKXuR5 +mhowALUjioqA7Ax3N6iw7uI7ANzrMX+VSmCmuHgUcXbJUy/4SqCmI1M3xyKvxEFh ++KPQCMYRpIh02j6tFU5k7P5aynDitPwqXJr1w+NVcLl1qh4y4tGQ6GpscS5KGEqd +sojcVZuweV0hku4fT5onA2WAd4CWZVUVM3gBau1OzTgyCajjbgWURQjH9DrAHSrO +FQIDAQAB +-----END PUBLIC KEY----- diff --git a/crates/ref-feder-core/tests/fixtures/rsa-private-key.pem b/crates/ref-feder-core/tests/fixtures/rsa-private-key.pem new file mode 100644 index 0000000..0355a1e --- /dev/null +++ b/crates/ref-feder-core/tests/fixtures/rsa-private-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDSXUHF1268trok +ZnAB9gVqnh4tL5gZc3WBSDIeNG/1niVRMVhMZ6kvLwv+WVqoyphMvTajUgeXAHIH +WIrUgJNQ8N7JhoDpqplt7+q+09l0treTRInuc+A6vjidawyMUFS1qxDK73JHmFO7 +5w+rbQneikedkGUIXL3vh7B1iOjz6o6f7g0cL0ykiVG05WhkWedY3iuHOYzL8YG+ +VazI1/7jBptlVs0OMg50y4jRogTwkmKoqUCae5F3kER2F5nw51a8D31l+KcOCNJF +7ZzPDrqaJgDXu/G1JaGSOuIizZS1d5BW6Pm6ftzv9UzOzeKtK4zEsLeBZhi0FrtI +5vKhe/l1AgMBAAECggEAMKIhPNstrYDEH3q0PevR/EBiYxlr/URRX+pgNdXzJVJi +t7bj/kP/29nxWKPxPvEZjTI4UcE64njWo+afL/oqtK1/IBGRt5O6hW1QNL5W+XHt +lmUjy0YsSoBkJ9aSD9VZhCdwii4Z2j3368rDN2NNww5ueJmjle+U9K3GyKF2k78U +2mczUoJ6LEJiAOpKVrrIdLEdb/NBE8b2lwqITHaL2Pidj9cbBfrU16pb1RG6z7Eb +Xv2AtobemO54oz+RnzLk6ApY9v27r5uXE/61WKN9iWFNHncoK/l95W/lktoCJLwM +3cMVZVPtEJdRCpEPbyfXG6pyAmgIjJDUj2lSwnr2kQKBgQDrkrVlZ+5H3qxoc6j7 +K4aWl1T7n4IHu3mGLE6ByLMHhjirq8SlG9tqJ09kpeprT5S+PtQiyVhud8PgejNJ +9eo/pU4ybE9VdAFacRCAgKN+jIFM6TniyLraNUK9+tHX53ca6Aolus8yiEaUQln5 +n8J9wrS18JV8T+9+OOJZPwql5QKBgQDkmvWOXD+wb23Sb4F8aD0WXOIqQacSHkjy +Fllp1FslvhgMGDIfwCAHDIT9osE4aK5Yy+88Fg5MGT0kgU0viguJ+fJheJ3oEwb+ +/wceEO11ts6+vaS9ZqAQO95lM+XoV6PU3uoyWE9uLYkxJd6Siz7BGUzl/TzZbWsw +ZzWD6I/MUQKBgCR1hUuXhUJsTSSxWeLdvqvJ6iYzbq2Br3I7oz7k8AhnFphDMmEX +aaMJSHlcUGahX3T+RljH7r7SHGe+ofd9bu7Ax9R3/ONN2/PCcfphbmxklJJxujrG +NF0XRygeDKIsubtZVFC4k97PRpUlm8VNm41ZOBy8inY97OQNK8MCRcSdAoGAVWXV +yWKIoD5gBjaFZpYCC/KSwjpYURpjIZxbtn8PtZ+3l/0J7HZ3AGsa2y0LhSkFyEIW +kpmiqabcAmETFmk5OkfW1babNnC1MljOrdqg+lJaFUL+4YoOzUGwKJokjpD+sKy9 +TCVVNtFn6KY+6Pt/a98prNjW/Fo1qpVDlo0v+qECgYBNH81yghesDmxGiRTcXrSF +l/eX53n77wqNnk7kay/hJj4uSH5QYkUEGRbwxLeO/X7cZ7X7mrc86QV5AXJbbdS9 +/zhWdXp7A16vqyCpSBJ2QcUreA+bZJ3eyTibBlE7pL8DMP4EDRryg4Zf563iCybO +JxO6BZUqb4N5zLW8t8GfXw== +-----END PRIVATE KEY----- diff --git a/crates/ref-feder-core/tests/fixtures/rsa-public-key.pem b/crates/ref-feder-core/tests/fixtures/rsa-public-key.pem new file mode 100644 index 0000000..6dfef20 --- /dev/null +++ b/crates/ref-feder-core/tests/fixtures/rsa-public-key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0l1BxdduvLa6JGZwAfYF +ap4eLS+YGXN1gUgyHjRv9Z4lUTFYTGepLy8L/llaqMqYTL02o1IHlwByB1iK1ICT +UPDeyYaA6aqZbe/qvtPZdLa3k0SJ7nPgOr44nWsMjFBUtasQyu9yR5hTu+cPq20J +3opHnZBlCFy974ewdYjo8+qOn+4NHC9MpIlRtOVoZFnnWN4rhzmMy/GBvlWsyNf+ +4wabZVbNDjIOdMuI0aIE8JJiqKlAmnuRd5BEdheZ8OdWvA99ZfinDgjSRe2czw66 +miYA17vxtSWhkjriIs2UtXeQVuj5un7c7/VMzs3irSuMxLC3gWYYtBa7SObyoXv5 +dQIDAQAB +-----END PUBLIC KEY----- diff --git a/crates/ref-feder-core/tests/follow.rs b/crates/ref-feder-core/tests/follow.rs new file mode 100644 index 0000000..5d06763 --- /dev/null +++ b/crates/ref-feder-core/tests/follow.rs @@ -0,0 +1,142 @@ +mod common; + +use feder_vocab::{Accept, Follow, Reference}; +use ref_feder_core::follow::{ + AcceptFollowError, FollowError, PendingFollow, create_follow, receive_accept_follow, + receive_follow, +}; + +use common::{actor, iri}; + +#[test] +fn creates_outbound_follow_and_pending_relationship() { + let local = actor("https://local.example/users/alice"); + let remote = actor("https://remote.example/users/bob"); + let follow_id = iri("https://local.example/activities/follow/1"); + + let outcome = create_follow(&local, &remote, follow_id.clone()); + + assert_eq!(outcome.relationship.local_actor, local.id); + assert_eq!(outcome.relationship.remote_actor, remote); + assert_eq!(outcome.relationship.follow_activity, follow_id); + assert_eq!(outcome.activity.id, outcome.relationship.follow_activity); + assert_eq!(outcome.activity.actor, Reference::id(local.id)); + assert_eq!( + outcome.activity.object, + Reference::id(outcome.relationship.remote_actor.id) + ); +} + +#[test] +fn receives_follow_as_transient_storage_and_delivery_outcome() { + let local = actor("https://local.example/users/alice"); + let remote = actor("https://remote.example/users/bob"); + let follow = Follow::new( + iri("https://remote.example/activities/follow/1"), + Reference::id(remote.id.clone()), + Reference::id(local.id.clone()), + ); + + let outcome = receive_follow( + &local, + &remote, + follow.clone(), + iri("https://local.example/activities/accept/1"), + ) + .expect("valid Follow"); + + assert_eq!(outcome.follower, remote); + assert_eq!(outcome.following, local.id); + assert_eq!(outcome.recipient_inbox, outcome.follower.inbox); + assert_eq!(outcome.accept.actor, Reference::id(outcome.following)); + let Reference::Object(accepted_follow) = outcome.accept.object else { + panic!("Accept must embed the verified Follow"); + }; + assert_eq!(accepted_follow.id, follow.id); + assert_eq!(accepted_follow.object, follow.object); + assert_eq!( + accepted_follow.actor, + Reference::object(outcome.follower.clone()) + ); +} + +#[test] +fn rejects_follow_with_wrong_actor_or_object() { + let local = actor("https://local.example/users/alice"); + let remote = actor("https://remote.example/users/bob"); + let other = actor("https://remote.example/users/mallory"); + let accept_id = iri("https://local.example/activities/accept/1"); + let wrong_actor = Follow::new( + iri("https://remote.example/activities/follow/1"), + Reference::id(other.id), + Reference::id(local.id.clone()), + ); + let wrong_object = Follow::new( + iri("https://remote.example/activities/follow/2"), + Reference::id(remote.id.clone()), + Reference::id(iri("https://local.example/users/mallory")), + ); + + assert_eq!( + receive_follow(&local, &remote, wrong_actor, accept_id.clone()), + Err(FollowError::WrongActor) + ); + assert_eq!( + receive_follow(&local, &remote, wrong_object, accept_id), + Err(FollowError::WrongObject) + ); +} + +#[test] +fn confirms_accept_for_the_exact_pending_follow() { + let local = actor("https://local.example/users/alice"); + let remote = actor("https://remote.example/users/bob"); + let pending = PendingFollow { + local_actor: local.id.clone(), + remote_actor: remote.clone(), + follow_activity: iri("https://local.example/activities/follow/1"), + }; + let follow = Follow::new( + pending.follow_activity.clone(), + Reference::id(local.id.clone()), + Reference::id(remote.id.clone()), + ); + let accept = Accept::new( + iri("https://remote.example/activities/accept/1"), + Reference::id(remote.id.clone()), + Reference::object(follow), + ); + + receive_accept_follow(&local, &remote, &pending, accept).expect("valid Accept"); +} + +#[test] +fn rejects_accept_that_does_not_match_pending_relationship() { + let local = actor("https://local.example/users/alice"); + let remote = actor("https://remote.example/users/bob"); + let other = actor("https://remote.example/users/mallory"); + let pending = PendingFollow { + local_actor: local.id.clone(), + remote_actor: remote.clone(), + follow_activity: iri("https://local.example/activities/follow/1"), + }; + let wrong_actor = Accept::new( + iri("https://remote.example/activities/accept/1"), + Reference::id(other.id), + Reference::id(pending.follow_activity.clone()), + ); + let wrong_follow = Accept::new( + iri("https://remote.example/activities/accept/2"), + Reference::id(remote.id.clone()), + Reference::id(iri("https://local.example/activities/follow/other")), + ); + + assert_eq!( + receive_accept_follow(&local, &remote, &pending, wrong_actor), + Err(AcceptFollowError::WrongActor) + ); + assert_eq!( + receive_accept_follow(&local, &remote, &pending, wrong_follow), + Err(AcceptFollowError::WrongFollow) + ); +} diff --git a/crates/ref-feder-core/tests/key.rs b/crates/ref-feder-core/tests/key.rs new file mode 100644 index 0000000..cd79aa0 --- /dev/null +++ b/crates/ref-feder-core/tests/key.rs @@ -0,0 +1,72 @@ +use ref_feder_core::key::{ + ActorKeyPair, KeyError, create_sha256_digest_header, sign_draft_cavage, verify_draft_cavage, +}; + +const PRIVATE_KEY_PEM: &str = include_str!("fixtures/rsa-private-key.pem"); +const PUBLIC_KEY_PEM: &str = include_str!("fixtures/rsa-public-key.pem"); +const OTHER_PUBLIC_KEY_PEM: &str = include_str!("fixtures/rsa-other-public-key.pem"); + +fn actor_key_pair() -> ActorKeyPair { + ActorKeyPair::from_pem(PRIVATE_KEY_PEM.to_string(), PUBLIC_KEY_PEM.to_string()) + .expect("valid actor key pair") +} + +#[test] +fn rejects_mismatched_persisted_keys() { + let result = ActorKeyPair::from_pem( + PRIVATE_KEY_PEM.to_string(), + OTHER_PUBLIC_KEY_PEM.to_string(), + ); + + assert!(matches!(result, Err(KeyError::MismatchedKeyPair))); +} + +#[test] +fn redacts_private_key_from_debug_output() { + let pair = actor_key_pair(); + let debug = format!("{pair:?}"); + + assert!(debug.contains("[REDACTED]")); + assert!(!debug.contains(pair.private_key_pem())); +} + +#[test] +fn creates_known_sha256_digest() { + assert_eq!( + create_sha256_digest_header(b"Hello, world!"), + "SHA-256=MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=" + ); +} + +#[test] +fn signs_and_verifies_draft_cavage_request() { + let pair = actor_key_pair(); + let headers = [ + ("date", "Tue, 05 Mar 2024 07:49:44 GMT"), + ( + "digest", + "SHA-256=MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=", + ), + ("host", "example.com"), + ]; + let signature_header = + sign_draft_cavage(&pair, "https://example.com/key", "POST", "/inbox", &headers) + .expect("sign request"); + let signature = signature_header + .rsplit_once("signature=\"") + .and_then(|(_, signature)| signature.strip_suffix('"')) + .expect("signature parameter"); + + verify_draft_cavage(pair.public_key_pem(), "POST", "/inbox", &headers, signature) + .expect("verify request"); + assert!( + verify_draft_cavage( + pair.public_key_pem(), + "POST", + "/other-inbox", + &headers, + signature, + ) + .is_err() + ); +} diff --git a/crates/ref-feder-core/tests/note.rs b/crates/ref-feder-core/tests/note.rs new file mode 100644 index 0000000..ca10a2a --- /dev/null +++ b/crates/ref-feder-core/tests/note.rs @@ -0,0 +1,92 @@ +mod common; + +use feder_vocab::{Note, Reference, References}; +use ref_feder_core::note::{ + CreateNoteInput, NoteRecipient, PUBLIC_COLLECTION, create_note, is_public_note, +}; + +use common::{actor, iri}; + +#[test] +fn creates_note_and_create_activity_from_runtime_facts() { + let mut local = actor("https://local.example/users/alice"); + local.followers = Some(iri("https://local.example/users/alice/followers")); + let remote = iri("https://remote.example/users/bob"); + let input = CreateNoteInput { + note_id: iri("https://local.example/posts/1"), + create_id: iri("https://local.example/activities/create/1"), + to: References::one(iri(PUBLIC_COLLECTION)), + cc: References::many([local.followers.clone().expect("followers"), remote.clone()]), + content: "hello".to_string(), + media_type: Some("text/html".to_string()), + published: Some("2026-08-02T00:00:00Z".to_string()), + url: Some(iri("https://local.example/@alice/1")), + }; + + let outcome = create_note(&local, input); + + assert_eq!( + outcome.note.attributed_to, + Some(Reference::id(local.id.clone())) + ); + assert_eq!(outcome.note.content.as_deref(), Some("hello")); + assert_eq!( + outcome.activity.object, + Reference::object(outcome.note.clone()) + ); + assert_eq!(outcome.activity.actor, Reference::id(local.id.clone())); + assert_eq!(outcome.activity.to, outcome.note.to); + assert_eq!(outcome.activity.cc, outcome.note.cc); + assert_eq!( + outcome.recipients, + vec![ + NoteRecipient::Followers(local.id), + NoteRecipient::Actor(remote), + ] + ); +} + +#[test] +fn deduplicates_note_recipients_and_skips_public_and_local_addresses() { + let mut local = actor("https://local.example/users/alice"); + let followers = iri("https://local.example/users/alice/followers"); + local.followers = Some(followers.clone()); + let remote = iri("https://remote.example/users/bob"); + let outcome = create_note( + &local, + CreateNoteInput { + note_id: iri("https://local.example/posts/1"), + create_id: iri("https://local.example/activities/create/1"), + to: References::many([ + iri(PUBLIC_COLLECTION), + local.id.clone(), + followers.clone(), + remote.clone(), + ]), + cc: References::many([followers, remote.clone()]), + content: "hello".to_string(), + media_type: None, + published: None, + url: None, + }, + ); + + assert_eq!( + outcome.recipients, + vec![ + NoteRecipient::Followers(local.id), + NoteRecipient::Actor(remote), + ] + ); +} + +#[test] +fn recognizes_public_note_addressing() { + let mut public = Note::new(iri("https://local.example/posts/1")); + public.cc = References::one(iri(PUBLIC_COLLECTION)); + let mut private = Note::new(iri("https://local.example/posts/2")); + private.to = References::one(iri("https://remote.example/users/bob")); + + assert!(is_public_note(&public)); + assert!(!is_public_note(&private)); +} diff --git a/crates/ref-feder-core/tests/undo.rs b/crates/ref-feder-core/tests/undo.rs new file mode 100644 index 0000000..8ed5f5a --- /dev/null +++ b/crates/ref-feder-core/tests/undo.rs @@ -0,0 +1,70 @@ +mod common; + +use feder_vocab::{Follow, Reference, Undo}; +use ref_feder_core::undo::{UndoFollowError, receive_undo_follow}; + +use common::{actor, iri}; + +#[test] +fn receives_undo_for_embedded_follow() { + let local = actor("https://local.example/users/alice"); + let remote = actor("https://remote.example/users/bob"); + let follow = Follow::new( + iri("https://remote.example/activities/follow/1"), + Reference::id(remote.id.clone()), + Reference::id(local.id.clone()), + ); + let undo = Undo::new( + iri("https://remote.example/activities/undo/1"), + Reference::id(remote.id.clone()), + Reference::object(follow), + ); + + let outcome = receive_undo_follow(&local, &remote, undo).expect("valid Undo"); + + assert_eq!(outcome.follower, remote.id); + assert_eq!(outcome.following, local.id); +} + +#[test] +fn rejects_linked_follow_and_wrong_actor_or_object() { + let local = actor("https://local.example/users/alice"); + let remote = actor("https://remote.example/users/bob"); + let other = actor("https://remote.example/users/mallory"); + let linked = Undo::new( + iri("https://remote.example/activities/undo/1"), + Reference::id(remote.id.clone()), + Reference::id(iri("https://remote.example/activities/follow/1")), + ); + let wrong_actor = Undo::new( + iri("https://remote.example/activities/undo/2"), + Reference::id(other.id), + Reference::object(Follow::new( + iri("https://remote.example/activities/follow/2"), + Reference::id(remote.id.clone()), + Reference::id(local.id.clone()), + )), + ); + let wrong_object = Undo::new( + iri("https://remote.example/activities/undo/3"), + Reference::id(remote.id.clone()), + Reference::object(Follow::new( + iri("https://remote.example/activities/follow/3"), + Reference::id(remote.id.clone()), + Reference::id(iri("https://local.example/users/mallory")), + )), + ); + + assert_eq!( + receive_undo_follow(&local, &remote, linked), + Err(UndoFollowError::LinkedFollow) + ); + assert_eq!( + receive_undo_follow(&local, &remote, wrong_actor), + Err(UndoFollowError::WrongActor) + ); + assert_eq!( + receive_undo_follow(&local, &remote, wrong_object), + Err(UndoFollowError::WrongObject) + ); +} diff --git a/crates/ref-feder-runtime-server/Cargo.toml b/crates/ref-feder-runtime-server/Cargo.toml index dcd6f17..858c9a2 100644 --- a/crates/ref-feder-runtime-server/Cargo.toml +++ b/crates/ref-feder-runtime-server/Cargo.toml @@ -30,5 +30,9 @@ url = "2" ipnet = "2.11.0" tokio = { version = "1", features = ["net"] } +[dev-dependencies] +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "sync"] } +tower.workspace = true + [lints] workspace = true diff --git a/crates/ref-feder-runtime-server/src/storage/sqlite.rs b/crates/ref-feder-runtime-server/src/storage/sqlite.rs index 885875d..c1e888e 100644 --- a/crates/ref-feder-runtime-server/src/storage/sqlite.rs +++ b/crates/ref-feder-runtime-server/src/storage/sqlite.rs @@ -429,164 +429,3 @@ fn parse_iri(value: String) -> Result { .parse() .map_err(|_| StoreError::InvalidIri(value.to_owned())) } - -#[cfg(test)] -mod tests { - use feder_vocab::{Endpoints, Reference}; - - use super::*; - - const PRIVATE_KEY_PEM: &str = - include_str!("../../../feder-core/tests/fixtures/rsa-private-key.pem"); - const PUBLIC_KEY_PEM: &str = - include_str!("../../../feder-core/tests/fixtures/rsa-public-key.pem"); - - fn iri(value: &str) -> Iri { - value.parse().expect("valid test IRI") - } - - fn actor(id: &str) -> Actor { - Actor::person( - iri(id), - iri(&format!("{id}/inbox")), - iri(&format!("{id}/outbox")), - ) - } - - #[test] - fn stores_lists_and_removes_follower_delivery_facts() { - let store = SqliteStore::open_in_memory().expect("open store"); - let following = iri("https://local.example/users/alice"); - let mut follower = actor("https://remote.example/users/bob"); - follower.endpoints = Some(Endpoints { - shared_inbox: Some(iri("https://remote.example/inbox")), - }); - - store - .store_follower(&follower, &following) - .expect("store follower"); - - assert_eq!( - store.list_followers(&following).expect("list followers"), - vec![follower.id.clone()] - ); - assert_eq!( - store - .list_follower_delivery_targets(&following) - .expect("list delivery targets"), - vec![FollowerDeliveryTarget { - actor_id: follower.id.clone(), - inbox: follower.inbox.clone(), - shared_inbox: follower - .endpoints - .and_then(|endpoints| endpoints.shared_inbox), - }] - ); - - store - .remove_follower(&follower.id, &following) - .expect("remove follower"); - assert!( - store - .list_followers(&following) - .expect("list followers") - .is_empty() - ); - } - - #[test] - fn stores_and_loads_note() { - let store = SqliteStore::open_in_memory().expect("open store"); - let mut note = Note::new(iri("https://local.example/posts/1")); - note.attributed_to = Some(Reference::id(iri("https://local.example/users/alice"))); - note.content = Some("hello".to_string()); - - store.store_note(¬e).expect("store Note"); - - assert_eq!(store.load_note(¬e.id).expect("load Note"), Some(note)); - } - - #[test] - fn confirms_only_the_expected_pending_follow() { - let store = SqliteStore::open_in_memory().expect("open store"); - let pending = PendingFollow { - local_actor: iri("https://local.example/users/alice"), - remote_actor: actor("https://remote.example/users/bob"), - follow_activity: iri("https://local.example/activities/follow/1"), - }; - store - .store_pending_follow(&pending) - .expect("store pending Follow"); - - let mut wrong = pending.clone(); - wrong.local_actor = iri("https://local.example/users/mallory"); - assert!( - !store - .confirm_pending_follow(&wrong) - .expect("reject mismatch") - ); - assert_eq!( - store - .load_pending_follow(&pending.follow_activity) - .expect("load pending Follow"), - Some(pending.clone()) - ); - - assert!( - store - .confirm_pending_follow(&pending) - .expect("confirm pending Follow") - ); - assert_eq!( - store - .load_pending_follow(&pending.follow_activity) - .expect("load accepted Follow"), - None - ); - } - - #[test] - fn actor_key_pair_roundtrips() { - let store = SqliteStore::open_in_memory().expect("open store"); - let actor_id = iri("https://local.example/users/alice"); - let key_pair = - ActorKeyPair::from_pem(PRIVATE_KEY_PEM.to_string(), PUBLIC_KEY_PEM.to_string()) - .expect("valid key pair"); - - store - .insert_actor_key_pair(&actor_id, &key_pair) - .expect("store actor keys"); - - assert_eq!( - store - .load_actor_key_pair(&actor_id) - .expect("load actor keys"), - Some(key_pair) - ); - } - - #[test] - fn provisions_an_actor_key_once() { - let store = SqliteStore::open_in_memory().expect("open store"); - let actor_id = iri("https://local.example/users/alice"); - let key_pair = - ActorKeyPair::from_pem(PRIVATE_KEY_PEM.to_string(), PUBLIC_KEY_PEM.to_string()) - .expect("valid key pair"); - - let first = store - .load_or_insert_actor_key_pair(&actor_id, || Ok(key_pair.clone())) - .expect("provision actor key"); - let second = store - .load_or_insert_actor_key_pair(&actor_id, || panic!("existing key must be reused")) - .expect("load actor key"); - - assert_eq!(first, key_pair); - assert_eq!(second, first); - } - - #[test] - fn sqlite_store_is_send_and_sync() { - fn assert_send_sync() {} - assert_send_sync::(); - } -} diff --git a/crates/ref-feder-runtime-server/tests/cases/actor.rs b/crates/ref-feder-runtime-server/tests/cases/actor.rs new file mode 100644 index 0000000..c629eba --- /dev/null +++ b/crates/ref-feder-runtime-server/tests/cases/actor.rs @@ -0,0 +1,161 @@ +use axum::{ + Json, Router, + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, + routing::get, +}; +use feder_vocab::Actor; +use ref_feder_runtime_server::{ActorResolveError, ActorResolver, OutboundAddressPolicy}; +use serde_json::Value; +use tower::ServiceExt; + +use crate::common::{iri, test_router}; + +#[tokio::test] +async fn returns_local_actor() { + let response = test_router() + .oneshot( + Request::builder() + .uri("/users/alice") + .header(header::ACCEPT, "application/activity+json") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::CONTENT_TYPE).unwrap(), + "application/activity+json" + ); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); + + let body = to_bytes(response.into_body(), 8192) + .await + .expect("read response body"); + let json: Value = serde_json::from_slice(&body).expect("valid JSON"); + assert_eq!(json["id"], "http://127.0.0.1:3000/users/alice"); + assert_eq!(json["preferredUsername"], "alice"); + assert_eq!( + json["publicKey"]["id"], + "http://127.0.0.1:3000/users/alice#main-key" + ); + assert_eq!( + json["publicKey"]["publicKeyPem"], + include_str!("../fixtures/rsa-public-key.pem") + ); +} + +#[tokio::test] +async fn rejects_actor_request_without_acceptable_media_type() { + for accept in [None, Some("text/html, application/activity+json;q=0.8")] { + let mut request = Request::builder().uri("/users/alice"); + if let Some(accept) = accept { + request = request.header(header::ACCEPT, accept); + } + let response = test_router() + .oneshot(request.body(Body::empty()).expect("valid request")) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); + } +} + +#[tokio::test] +async fn rejects_unknown_actor() { + let response = test_router() + .oneshot( + Request::builder() + .uri("/users/bob") + .header(header::ACCEPT, "application/activity+json") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +async fn spawn_actor_server( + content_type: &'static str, + mismatched_id: bool, +) -> (feder_vocab::Iri, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind actor server"); + let address = listener.local_addr().expect("actor server address"); + let actor_id = iri(&format!("http://{address}/users/bob")); + let returned_id = if mismatched_id { + iri(&format!("http://{address}/users/mallory")) + } else { + actor_id.clone() + }; + let actor = Actor::person( + returned_id, + iri(&format!("http://{address}/users/bob/inbox")), + iri(&format!("http://{address}/users/bob/outbox")), + ); + let app = Router::new().route( + "/users/bob", + get(move || { + let actor = actor.clone(); + async move { ([(header::CONTENT_TYPE, content_type)], Json(actor)) } + }), + ); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve actor"); + }); + (actor_id, task) +} + +#[tokio::test] +async fn resolves_remote_actor_and_checks_canonical_id() { + let resolver = + ActorResolver::new(OutboundAddressPolicy::AllowPrivateAddress).expect("construct resolver"); + let (actor_id, server) = spawn_actor_server("application/activity+json", false).await; + + let actor = resolver.resolve(&actor_id).await.expect("resolve actor"); + + assert_eq!(actor.id, actor_id); + server.abort(); +} + +#[tokio::test] +async fn rejects_mismatched_actor_id_and_unsupported_content_type() { + let resolver = + ActorResolver::new(OutboundAddressPolicy::AllowPrivateAddress).expect("construct resolver"); + let (mismatched_id, mismatched_server) = + spawn_actor_server("application/activity+json", true).await; + let mismatch = resolver.resolve(&mismatched_id).await; + assert!(matches!( + mismatch, + Err(ActorResolveError::ActorIdMismatch { .. }) + )); + mismatched_server.abort(); + + let (html_id, html_server) = spawn_actor_server("text/html", false).await; + let unsupported = resolver.resolve(&html_id).await; + assert!(matches!( + unsupported, + Err(ActorResolveError::UnsupportedContentType(_)) + )); + html_server.abort(); +} + +#[tokio::test] +async fn public_policy_blocks_loopback_actor_resolution() { + let resolver = + ActorResolver::new(OutboundAddressPolicy::PublicOnly).expect("construct resolver"); + let actor_id = iri("http://127.0.0.1:3000/users/bob"); + + let result = resolver.resolve(&actor_id).await; + + assert!(matches!( + result, + Err(ActorResolveError::PrivateResourceAddress { address, .. }) if address.is_loopback() + )); +} diff --git a/crates/ref-feder-runtime-server/tests/cases/followers.rs b/crates/ref-feder-runtime-server/tests/cases/followers.rs new file mode 100644 index 0000000..698e2d4 --- /dev/null +++ b/crates/ref-feder-runtime-server/tests/cases/followers.rs @@ -0,0 +1,115 @@ +use axum::{ + Router, + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, +}; +use feder_vocab::Actor; +use ref_feder_core::storage::ServerStorage; +use serde_json::Value; +use tower::ServiceExt; + +use crate::common::{iri, test_router, test_router_with_storage}; + +async fn get_followers( + app: Router, + identifier: &str, + accept: Option<&str>, +) -> axum::response::Response { + let mut request = Request::builder().uri(format!("/users/{identifier}/followers")); + if let Some(accept) = accept { + request = request.header(header::ACCEPT, accept); + } + app.oneshot(request.body(Body::empty()).expect("valid request")) + .await + .expect("response") +} + +async fn response_json(response: axum::response::Response) -> Value { + let body = to_bytes(response.into_body(), 4096) + .await + .expect("read response body"); + serde_json::from_slice(&body).expect("valid JSON") +} + +fn remote_actor(id: &str) -> Actor { + Actor::person( + iri(id), + iri(&format!("{id}/inbox")), + iri(&format!("{id}/outbox")), + ) +} + +#[tokio::test] +async fn returns_empty_followers_collection_with_activitypub_headers() { + let response = get_followers(test_router(), "alice", Some("application/activity+json")).await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::CONTENT_TYPE).unwrap(), + "application/activity+json" + ); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); + let json = response_json(response).await; + assert_eq!(json["type"], "OrderedCollection"); + assert_eq!(json["totalItems"], 0); + assert_eq!(json["orderedItems"], serde_json::json!([])); +} + +#[tokio::test] +async fn returns_stored_followers_in_stable_order() { + let app = test_router_with_storage(|storage| { + let following = iri("http://127.0.0.1:3000/users/alice"); + storage + .store_follower( + &remote_actor("https://remote.example/users/carol"), + &following, + ) + .expect("store Carol"); + storage + .store_follower( + &remote_actor("https://remote.example/users/bob"), + &following, + ) + .expect("store Bob"); + }); + + let response = get_followers(app, "alice", Some("application/activity+json")).await; + let json = response_json(response).await; + assert_eq!(json["totalItems"], 2); + assert_eq!( + json["orderedItems"], + serde_json::json!([ + "https://remote.example/users/bob", + "https://remote.example/users/carol" + ]) + ); +} + +#[tokio::test] +async fn reflects_follower_removal() { + let app = test_router_with_storage(|storage| { + let following = iri("http://127.0.0.1:3000/users/alice"); + let follower = remote_actor("https://remote.example/users/bob"); + storage + .store_follower(&follower, &following) + .expect("store follower"); + storage + .remove_follower(&follower.id, &following) + .expect("remove follower"); + }); + + let response = get_followers(app, "alice", Some("application/activity+json")).await; + assert_eq!(response_json(response).await["totalItems"], 0); +} + +#[tokio::test] +async fn rejects_unknown_actor_and_unacceptable_media_types() { + let unknown = get_followers(test_router(), "bob", Some("application/activity+json")).await; + assert_eq!(unknown.status(), StatusCode::NOT_FOUND); + + for accept in [None, Some("text/html, application/activity+json;q=0.8")] { + let response = get_followers(test_router(), "alice", accept).await; + assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); + } +} diff --git a/crates/ref-feder-runtime-server/tests/cases/inbox.rs b/crates/ref-feder-runtime-server/tests/cases/inbox.rs new file mode 100644 index 0000000..fdab792 --- /dev/null +++ b/crates/ref-feder-runtime-server/tests/cases/inbox.rs @@ -0,0 +1,347 @@ +use axum::{ + Json, Router, + body::{Body, Bytes, to_bytes}, + http::{HeaderMap, Request, StatusCode, Uri, header::CONTENT_TYPE}, + routing::{get, post}, +}; +use ref_feder_core::key::{create_sha256_digest_header, sign_draft_cavage}; +use ref_feder_runtime_server::InboxAuthPolicy; +use serde_json::{Value, json}; +use tower::ServiceExt; + +use crate::common::{ + HANDLE_HOST, ORIGIN, RecordedRequest, actor_key_pair, test_router, test_router_with_policy, +}; + +fn follow_body(actor_id: &str) -> Vec { + serde_json::to_vec(&json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Follow", + "id": format!("{actor_id}/follows/1"), + "actor": actor_id, + "object": format!("{ORIGIN}/users/alice") + })) + .expect("serialize Follow") +} + +fn undo_follow_body(actor_id: &str) -> Vec { + serde_json::to_vec(&json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Undo", + "id": format!("{actor_id}/undos/1"), + "actor": actor_id, + "object": { + "type": "Follow", + "id": format!("{actor_id}/follows/1"), + "actor": actor_id, + "object": format!("{ORIGIN}/users/alice") + } + })) + .expect("serialize Undo") +} + +async fn spawn_remote_actor() -> ( + String, + tokio::sync::mpsc::Receiver, + tokio::task::JoinHandle<()>, +) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind remote actor server"); + let address = listener.local_addr().expect("remote actor server address"); + let actor_id = format!("http://{address}/users/bob"); + let key_id = format!("{actor_id}#main-key"); + let inbox = format!("http://{address}/inbox"); + let actor = json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Person", + "id": actor_id, + "inbox": inbox, + "outbox": format!("http://{address}/users/bob/outbox"), + "preferredUsername": "bob", + "publicKey": { + "id": key_id, + "owner": actor_id, + "publicKeyPem": actor_key_pair().public_key_pem() + } + }); + let (sender, receiver) = tokio::sync::mpsc::channel(2); + let app = Router::new() + .route( + "/users/bob", + get(move || { + let actor = actor.clone(); + async move { ([(CONTENT_TYPE, "application/activity+json")], Json(actor)) } + }), + ) + .route( + "/inbox", + post(move |headers: HeaderMap, uri: Uri, body: Bytes| { + let sender = sender.clone(); + async move { + sender + .send(RecordedRequest { headers, uri, body }) + .await + .expect("request receiver remains open"); + StatusCode::ACCEPTED + } + }), + ); + let task = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("serve remote actor"); + }); + + (actor_id, receiver, task) +} + +async fn post_inbox( + app: Router, + uri: &str, + content_type: &str, + body: impl Into, +) -> axum::response::Response { + app.oneshot( + Request::builder() + .method("POST") + .uri(uri) + .header(CONTENT_TYPE, content_type) + .body(body.into()) + .expect("valid inbox request"), + ) + .await + .expect("inbox response") +} + +async fn post_signed_inbox( + app: Router, + uri: &str, + actor_id: &str, + signed_body: &[u8], + delivered_body: impl Into, + host: &str, +) -> axum::response::Response { + let date = httpdate::fmt_http_date(std::time::SystemTime::now()); + let digest = create_sha256_digest_header(signed_body); + let headers = [ + ("content-type", "application/activity+json"), + ("date", date.as_str()), + ("digest", digest.as_str()), + ("host", host), + ]; + let signature = sign_draft_cavage( + &actor_key_pair(), + &format!("{actor_id}#main-key"), + "POST", + uri, + &headers, + ) + .expect("sign inbox request"); + + app.oneshot( + Request::builder() + .method("POST") + .uri(uri) + .header(CONTENT_TYPE, "application/activity+json") + .header("date", date) + .header("digest", digest) + .header("host", host) + .header("signature", signature) + .body(delivered_body.into()) + .expect("valid signed inbox request"), + ) + .await + .expect("signed inbox response") +} + +async fn follower_count(app: Router) -> u64 { + let response = app + .oneshot( + Request::builder() + .uri("/users/alice/followers") + .header("accept", "application/activity+json") + .body(Body::empty()) + .expect("valid followers request"), + ) + .await + .expect("followers response"); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("read followers response"); + let collection: Value = serde_json::from_slice(&body).expect("valid followers collection"); + collection["totalItems"] + .as_u64() + .expect("numeric follower count") +} + +#[tokio::test] +async fn valid_signed_follow_is_stored_and_accept_is_sent() { + let (actor_id, mut requests, remote_server) = spawn_remote_actor().await; + let app = test_router_with_policy(InboxAuthPolicy::RequireSigned); + let body = follow_body(&actor_id); + + let response = post_signed_inbox( + app.clone(), + "/users/alice/inbox", + &actor_id, + &body, + body.clone(), + HANDLE_HOST, + ) + .await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert_eq!(follower_count(app).await, 1); + let request = requests.recv().await.expect("receive Accept activity"); + assert!(request.headers.contains_key("signature")); + let activity: Value = serde_json::from_slice(&request.body).expect("valid Accept activity"); + assert_eq!(activity["type"], "Accept"); + assert_eq!(activity["actor"], format!("{ORIGIN}/users/alice")); + remote_server.abort(); +} + +#[tokio::test] +async fn unsigned_follow_is_rejected_when_signatures_are_required() { + let (actor_id, _requests, remote_server) = spawn_remote_actor().await; + let body = follow_body(&actor_id); + + let response = post_inbox( + test_router_with_policy(InboxAuthPolicy::RequireSigned), + "/users/alice/inbox", + "application/activity+json", + body, + ) + .await; + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + remote_server.abort(); +} + +#[tokio::test] +async fn signed_follow_rejects_wrong_host() { + let (actor_id, _requests, remote_server) = spawn_remote_actor().await; + let body = follow_body(&actor_id); + + let response = post_signed_inbox( + test_router_with_policy(InboxAuthPolicy::RequireSigned), + "/users/alice/inbox", + &actor_id, + &body, + body.clone(), + "other.example", + ) + .await; + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + remote_server.abort(); +} + +#[tokio::test] +async fn signed_follow_rejects_a_tampered_body() { + let (actor_id, _requests, remote_server) = spawn_remote_actor().await; + let signed_body = follow_body(&actor_id); + let delivered_body = follow_body("https://attacker.example/users/mallory"); + + let response = post_signed_inbox( + test_router_with_policy(InboxAuthPolicy::RequireSigned), + "/users/alice/inbox", + &actor_id, + &signed_body, + delivered_body, + HANDLE_HOST, + ) + .await; + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + remote_server.abort(); +} + +#[tokio::test] +async fn signed_undo_removes_the_persisted_follower() { + let (actor_id, mut requests, remote_server) = spawn_remote_actor().await; + let app = test_router_with_policy(InboxAuthPolicy::RequireSigned); + let follow = follow_body(&actor_id); + let follow_response = post_signed_inbox( + app.clone(), + "/users/alice/inbox", + &actor_id, + &follow, + follow.clone(), + HANDLE_HOST, + ) + .await; + assert_eq!(follow_response.status(), StatusCode::ACCEPTED); + requests.recv().await.expect("receive Accept activity"); + + let undo = undo_follow_body(&actor_id); + let undo_response = post_signed_inbox( + app.clone(), + "/users/alice/inbox", + &actor_id, + &undo, + undo.clone(), + HANDLE_HOST, + ) + .await; + + assert_eq!(undo_response.status(), StatusCode::ACCEPTED); + assert_eq!(follower_count(app).await, 0); + remote_server.abort(); +} + +#[tokio::test] +async fn shared_inbox_routes_a_signed_follow() { + let (actor_id, mut requests, remote_server) = spawn_remote_actor().await; + let app = test_router_with_policy(InboxAuthPolicy::RequireSigned); + let body = follow_body(&actor_id); + + let response = post_signed_inbox( + app.clone(), + "/inbox", + &actor_id, + &body, + body.clone(), + HANDLE_HOST, + ) + .await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert_eq!(follower_count(app).await, 1); + requests.recv().await.expect("receive Accept activity"); + remote_server.abort(); +} + +#[tokio::test] +async fn inbox_rejects_invalid_content_before_dispatch() { + let unsupported = post_inbox( + test_router(), + "/users/alice/inbox", + "application/json", + "{}", + ) + .await; + assert_eq!(unsupported.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + + let malformed = post_inbox( + test_router(), + "/users/alice/inbox", + "application/activity+json", + "{not json", + ) + .await; + assert_eq!(malformed.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn personal_inbox_rejects_an_unknown_local_actor() { + let response = post_inbox( + test_router(), + "/users/bob/inbox", + "application/activity+json", + "{}", + ) + .await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} diff --git a/crates/ref-feder-runtime-server/tests/cases/object.rs b/crates/ref-feder-runtime-server/tests/cases/object.rs new file mode 100644 index 0000000..d00bd2e --- /dev/null +++ b/crates/ref-feder-runtime-server/tests/cases/object.rs @@ -0,0 +1,129 @@ +use axum::{ + Router, + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, +}; +use feder_vocab::{Note, Reference, References}; +use ref_feder_core::{note::PUBLIC_COLLECTION, storage::NoteStore}; +use serde_json::Value; +use tower::ServiceExt; + +use crate::common::{iri, test_router_with_storage}; + +fn stored_note() -> Note { + let mut note = Note::new(iri("http://127.0.0.1:3000/users/alice/posts/1")); + note.attributed_to = Some(Reference::id(iri("http://127.0.0.1:3000/users/alice"))); + note.to = References::one(iri(PUBLIC_COLLECTION)); + note.cc = References::one(iri("http://127.0.0.1:3000/users/alice/followers")); + note.content = Some("Hello from Feder.".to_string()); + note.media_type = Some("text/html".to_string()); + note +} + +fn router_with_note(note: Note) -> Router { + test_router_with_storage(|storage| storage.store_note(¬e).expect("store Note")) +} + +async fn get_object(app: Router, uri: &str, accept: Option<&str>) -> axum::response::Response { + let mut request = Request::builder().uri(uri); + if let Some(accept) = accept { + request = request.header(header::ACCEPT, accept); + } + app.oneshot(request.body(Body::empty()).expect("valid request")) + .await + .expect("response") +} + +#[tokio::test] +async fn returns_public_stored_note_with_activitypub_headers() { + let response = get_object( + router_with_note(stored_note()), + "/users/alice/posts/1", + Some("application/activity+json"), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::CONTENT_TYPE).unwrap(), + "application/activity+json" + ); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); + let body = to_bytes(response.into_body(), 4096) + .await + .expect("read response body"); + let json: Value = serde_json::from_slice(&body).expect("valid JSON"); + assert_eq!(json["type"], "Note"); + assert_eq!(json["content"], "Hello from Feder."); +} + +#[tokio::test] +async fn returns_note_when_public_is_in_cc() { + let mut note = stored_note(); + note.to = References::one(iri("https://remote.example/users/bob")); + note.cc = References::one(iri(PUBLIC_COLLECTION)); + + let response = get_object( + router_with_note(note), + "/users/alice/posts/1", + Some("application/activity+json"), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); +} + +#[tokio::test] +async fn hides_non_public_notes() { + for (to, cc) in [ + ( + References::one(iri("https://remote.example/users/bob")), + References::new(), + ), + ( + References::one(iri("http://127.0.0.1:3000/users/alice/followers")), + References::new(), + ), + (References::new(), References::new()), + ] { + let mut note = stored_note(); + note.to = to; + note.cc = cc; + let response = get_object( + router_with_note(note), + "/users/alice/posts/1", + Some("application/activity+json"), + ) + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } +} + +#[tokio::test] +async fn rejects_unknown_routes_and_unacceptable_media_types() { + let unknown_note = get_object( + router_with_note(stored_note()), + "/users/alice/posts/unknown", + Some("application/activity+json"), + ) + .await; + assert_eq!(unknown_note.status(), StatusCode::NOT_FOUND); + + let unknown_actor = get_object( + router_with_note(stored_note()), + "/users/bob/posts/1", + Some("application/activity+json"), + ) + .await; + assert_eq!(unknown_actor.status(), StatusCode::NOT_FOUND); + + for accept in [None, Some("text/html, application/activity+json;q=0.8")] { + let response = get_object( + router_with_note(stored_note()), + "/users/alice/posts/1", + accept, + ) + .await; + assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); + assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); + } +} diff --git a/crates/ref-feder-runtime-server/tests/cases/operation.rs b/crates/ref-feder-runtime-server/tests/cases/operation.rs new file mode 100644 index 0000000..37e1805 --- /dev/null +++ b/crates/ref-feder-runtime-server/tests/cases/operation.rs @@ -0,0 +1,148 @@ +use std::sync::Arc; + +use axum::{Json, Router, body::Body, http::header::CONTENT_TYPE, routing::get}; +use feder_vocab::{Actor, Iri, References}; +use ref_feder_core::{ + note::{CreateNoteInput, PUBLIC_COLLECTION}, + storage::ServerStorage, +}; +use ref_feder_runtime_server::{InboxAuthPolicy, build_router_with_state, storage::SqliteStore}; +use serde_json::{Value, json}; +use tower::ServiceExt; + +use crate::common::{ORIGIN, RecordedRequest, iri, spawn_inbox_server, test_server_with_storage}; + +fn create_note_input() -> CreateNoteInput { + CreateNoteInput { + note_id: iri(&format!("{ORIGIN}/users/alice/posts/1")), + create_id: iri(&format!("{ORIGIN}/users/alice/activities/create/1")), + to: References::one(iri(PUBLIC_COLLECTION)), + cc: References::one(iri(&format!("{ORIGIN}/users/alice/followers"))), + content: "Hello from Feder.".to_string(), + media_type: Some("text/html".to_string()), + published: Some("2026-07-21T00:00:00Z".to_string()), + url: Some(iri(&format!("{ORIGIN}/@alice/1"))), + } +} + +async fn spawn_remote_actor() -> ( + Iri, + tokio::sync::mpsc::Receiver, + tokio::task::JoinHandle<()>, +) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind remote actor server"); + let address = listener.local_addr().expect("remote actor server address"); + let actor_id = iri(&format!("http://{address}/users/bob")); + let inbox = format!("http://{address}/inbox"); + let actor = json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Person", + "id": actor_id, + "inbox": inbox, + "outbox": format!("http://{address}/users/bob/outbox") + }); + let (sender, receiver) = tokio::sync::mpsc::channel(2); + let app = Router::new() + .route( + "/users/bob", + get(move || { + let actor = actor.clone(); + async move { ([(CONTENT_TYPE, "application/activity+json")], Json(actor)) } + }), + ) + .route( + "/inbox", + axum::routing::post( + move |headers: axum::http::HeaderMap, + uri: axum::http::Uri, + body: axum::body::Bytes| { + let sender = sender.clone(); + async move { + sender + .send(RecordedRequest { headers, uri, body }) + .await + .expect("request receiver remains open"); + axum::http::StatusCode::ACCEPTED + } + }, + ), + ); + let task = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("serve remote actor"); + }); + + (actor_id, receiver, task) +} + +#[tokio::test] +async fn outbound_follow_is_persisted_before_signed_delivery() { + let (remote_actor_id, mut requests, remote_server) = spawn_remote_actor().await; + let server = test_server_with_storage(|_| {}, InboxAuthPolicy::RequireSigned); + let local_actor_id = iri(&format!("{ORIGIN}/users/alice")); + let follow_id = iri(&format!("{ORIGIN}/users/alice/activities/follow/1")); + + let follow = server + .follow_actor(&local_actor_id, &remote_actor_id, follow_id.clone()) + .await + .expect("follow remote actor"); + + assert_eq!(follow.id, follow_id); + let request = requests.recv().await.expect("receive Follow delivery"); + assert!(request.headers.contains_key("signature")); + let activity: Value = serde_json::from_slice(&request.body).expect("valid Follow activity"); + assert_eq!(activity["type"], "Follow"); + assert_eq!(activity["actor"], local_actor_id.as_str()); + assert_eq!(activity["object"], remote_actor_id.as_str()); + remote_server.abort(); +} + +#[tokio::test] +async fn create_note_persists_and_delivers_to_followers() { + let (inbox, mut requests, inbox_server) = + spawn_inbox_server(axum::http::StatusCode::ACCEPTED).await; + let remote_actor_id = iri("https://remote.example/users/bob"); + let server = test_server_with_storage( + |storage: &SqliteStore| { + let remote_actor = Actor::person( + remote_actor_id.clone(), + iri(&inbox), + iri("https://remote.example/users/bob/outbox"), + ); + storage + .store_follower(&remote_actor, &iri(&format!("{ORIGIN}/users/alice"))) + .expect("store follower"); + }, + InboxAuthPolicy::RequireSigned, + ); + let server = Arc::new(server); + let local_actor_id = iri(&format!("{ORIGIN}/users/alice")); + + let outcome = server + .create_note(&local_actor_id, create_note_input()) + .await + .expect("create Note"); + + assert_eq!(outcome.note.content.as_deref(), Some("Hello from Feder.")); + let request = requests.recv().await.expect("receive Create delivery"); + assert!(request.headers.contains_key("signature")); + let activity: Value = serde_json::from_slice(&request.body).expect("valid Create activity"); + assert_eq!(activity["type"], "Create"); + assert_eq!(activity["object"]["id"], outcome.note.id.as_str()); + + let response = build_router_with_state(server) + .oneshot( + axum::http::Request::builder() + .uri("/users/alice/posts/1") + .header("accept", "application/activity+json") + .body(Body::empty()) + .expect("valid object request"), + ) + .await + .expect("object response"); + assert_eq!(response.status(), axum::http::StatusCode::OK); + inbox_server.abort(); +} diff --git a/crates/ref-feder-runtime-server/tests/cases/send.rs b/crates/ref-feder-runtime-server/tests/cases/send.rs new file mode 100644 index 0000000..209bc28 --- /dev/null +++ b/crates/ref-feder-runtime-server/tests/cases/send.rs @@ -0,0 +1,113 @@ +use axum::http::StatusCode; +use feder_vocab::{Follow, Reference}; +use ref_feder_core::key::verify_draft_cavage; +use ref_feder_runtime_server::{ + OutboundAddressPolicy, + send::{ActivitySender, SendError}, +}; + +use crate::common::{actor_key_pair, iri, local_actor, spawn_inbox_server}; + +fn follow() -> Follow { + Follow::new( + iri("https://local.example/activities/follow/1"), + Reference::id(iri("http://127.0.0.1:3000/users/alice")), + Reference::id(iri("https://remote.example/users/bob")), + ) +} + +#[tokio::test] +async fn sends_signed_activity_to_exact_inbox_target() { + let (inbox, mut requests, server) = spawn_inbox_server(StatusCode::ACCEPTED).await; + let inbox = format!("{inbox}?shared=true"); + let sender = + ActivitySender::new(OutboundAddressPolicy::AllowPrivateAddress).expect("construct sender"); + let actor = local_actor(); + let key_pair = actor_key_pair(); + + sender + .send_activity(&actor, &key_pair, &follow(), &iri(&inbox)) + .await + .expect("send Follow"); + + let request = requests.recv().await.expect("receive request"); + assert_eq!(request.uri, "/inbox?shared=true"); + assert_eq!(request.headers["content-type"], "application/activity+json"); + assert_eq!( + request.headers["digest"], + ref_feder_core::key::create_sha256_digest_header(&request.body) + ); + let signature = request.headers["signature"] + .to_str() + .expect("signature header") + .rsplit_once("signature=\"") + .and_then(|(_, signature)| signature.strip_suffix('"')) + .expect("signature parameter"); + let headers = [ + ( + "content-type", + request.headers["content-type"].to_str().unwrap(), + ), + ("date", request.headers["date"].to_str().unwrap()), + ("digest", request.headers["digest"].to_str().unwrap()), + ("host", request.headers["host"].to_str().unwrap()), + ]; + verify_draft_cavage( + key_pair.public_key_pem(), + "POST", + "/inbox?shared=true", + &headers, + signature, + ) + .expect("verify sent request"); + let activity: serde_json::Value = + serde_json::from_slice(&request.body).expect("valid activity"); + assert_eq!(activity["type"], "Follow"); + server.abort(); +} + +#[tokio::test] +async fn reports_unsuccessful_inbox_status() { + let (inbox, mut requests, server) = spawn_inbox_server(StatusCode::INTERNAL_SERVER_ERROR).await; + let sender = + ActivitySender::new(OutboundAddressPolicy::AllowPrivateAddress).expect("construct sender"); + + let result = sender + .send_activity(&local_actor(), &actor_key_pair(), &follow(), &iri(&inbox)) + .await; + + assert!(matches!(result, Err(SendError::UnsuccessfulStatus { .. }))); + requests.recv().await.expect("receive failed request"); + server.abort(); +} + +#[tokio::test] +async fn public_policy_blocks_loopback_inbox() { + let sender = ActivitySender::new(OutboundAddressPolicy::PublicOnly).expect("construct sender"); + let inbox = iri("http://127.0.0.1:3000/inbox"); + + let result = sender + .send_activity(&local_actor(), &actor_key_pair(), &follow(), &inbox) + .await; + + assert!(matches!( + result, + Err(SendError::PrivateInboxAddress { address, .. }) if address.is_loopback() + )); +} + +#[tokio::test] +async fn rejects_missing_or_mismatched_actor_key() { + let sender = + ActivitySender::new(OutboundAddressPolicy::AllowPrivateAddress).expect("construct sender"); + let key_pair = actor_key_pair(); + let mut actor = local_actor(); + actor.public_key = None; + let inbox = iri("https://remote.example/inbox"); + + let missing = sender + .send_activity(&actor, &key_pair, &follow(), &inbox) + .await; + + assert!(matches!(missing, Err(SendError::MissingActorKey(_)))); +} diff --git a/crates/ref-feder-runtime-server/tests/cases/webfinger.rs b/crates/ref-feder-runtime-server/tests/cases/webfinger.rs new file mode 100644 index 0000000..e13002d --- /dev/null +++ b/crates/ref-feder-runtime-server/tests/cases/webfinger.rs @@ -0,0 +1,62 @@ +use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, +}; +use serde_json::Value; +use tower::ServiceExt; + +use crate::common::test_router; + +#[tokio::test] +async fn returns_webfinger_descriptor_for_local_actor() { + let response = test_router() + .oneshot( + Request::builder() + .uri("/.well-known/webfinger?resource=acct:alice@127.0.0.1:3000") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::CONTENT_TYPE).unwrap(), + "application/jrd+json" + ); + let body = to_bytes(response.into_body(), 1024) + .await + .expect("read response body"); + let json: Value = serde_json::from_slice(&body).expect("valid JSON"); + assert_eq!(json["subject"], "acct:alice@127.0.0.1:3000"); + assert_eq!( + json["links"][0]["href"], + "http://127.0.0.1:3000/users/alice" + ); +} + +#[tokio::test] +async fn rejects_missing_malformed_unknown_and_non_authoritative_resources() { + for uri in [ + "/.well-known/webfinger", + "/.well-known/webfinger?resource=https://127.0.0.1/users/alice", + "/.well-known/webfinger?resource=acct:bob@127.0.0.1:3000", + "/.well-known/webfinger?resource=acct:alice@attacker.example", + ] { + let response = test_router() + .oneshot( + Request::builder() + .uri(uri) + .header(header::HOST, "attacker.example") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert!(matches!( + response.status(), + StatusCode::BAD_REQUEST | StatusCode::NOT_FOUND + )); + } +} diff --git a/crates/ref-feder-runtime-server/tests/common/mod.rs b/crates/ref-feder-runtime-server/tests/common/mod.rs new file mode 100644 index 0000000..62d11d0 --- /dev/null +++ b/crates/ref-feder-runtime-server/tests/common/mod.rs @@ -0,0 +1,143 @@ +use std::convert::Infallible; + +use axum::{ + Router, + body::Bytes, + http::{HeaderMap, StatusCode, Uri}, + routing::post, +}; +use feder_vocab::{Actor, CryptographicKey, Endpoints, Iri, Reference}; +use ref_feder_core::{ActorDispatcher, key::ActorKeyPair}; +use ref_feder_runtime_server::{ + FederServer, InboxAuthPolicy, OutboundAddressPolicy, build_router, storage::SqliteStore, +}; +use tokio::{sync::mpsc, task::JoinHandle}; + +pub const IDENTIFIER: &str = "alice"; +pub const ORIGIN: &str = "http://127.0.0.1:3000"; +pub const HANDLE_HOST: &str = "127.0.0.1:3000"; + +const PRIVATE_KEY_PEM: &str = include_str!("../fixtures/rsa-private-key.pem"); +const PUBLIC_KEY_PEM: &str = include_str!("../fixtures/rsa-public-key.pem"); + +pub struct TestActors { + actor: Actor, +} + +pub struct RecordedRequest { + pub headers: HeaderMap, + pub uri: Uri, + pub body: Bytes, +} + +impl ActorDispatcher for TestActors { + type Error = Infallible; + + fn get_actor(&self, identifier: &str) -> Result, Self::Error> { + Ok((identifier == IDENTIFIER).then(|| self.actor.clone())) + } + + fn get_actor_by_id(&self, actor_id: &Iri) -> Result, Self::Error> { + Ok((actor_id == &self.actor.id).then(|| self.actor.clone())) + } +} + +pub fn iri(value: &str) -> Iri { + value.parse().expect("valid test IRI") +} + +pub fn actor_key_pair() -> ActorKeyPair { + ActorKeyPair::from_pem(PRIVATE_KEY_PEM.to_string(), PUBLIC_KEY_PEM.to_string()) + .expect("valid actor key pair fixture") +} + +pub fn local_actor() -> Actor { + let actor_id = format!("{ORIGIN}/users/{IDENTIFIER}"); + let key_pair = actor_key_pair(); + let mut actor = Actor::person( + iri(&actor_id), + iri(&format!("{actor_id}/inbox")), + iri(&format!("{actor_id}/outbox")), + ); + actor.preferred_username = Some(IDENTIFIER.to_string()); + actor.name = Some("Alice".to_string()); + actor.followers = Some(iri(&format!("{actor_id}/followers"))); + actor.endpoints = Some(Endpoints { + shared_inbox: Some(iri(&format!("{ORIGIN}/inbox"))), + }); + actor.set_public_key(Reference::object(CryptographicKey::new( + iri(&format!("{actor_id}#main-key")), + actor.id.clone(), + key_pair.public_key_pem().to_string(), + ))); + actor +} + +pub fn test_router() -> Router { + test_router_with_storage(|_| {}) +} + +pub fn test_router_with_storage(configure: impl FnOnce(&SqliteStore)) -> Router { + test_router_with_storage_and_policy(configure, InboxAuthPolicy::AllowUnsignedInsecureDev) +} + +pub fn test_router_with_policy(inbox_auth_policy: InboxAuthPolicy) -> Router { + test_router_with_storage_and_policy(|_| {}, inbox_auth_policy) +} + +fn test_router_with_storage_and_policy( + configure: impl FnOnce(&SqliteStore), + inbox_auth_policy: InboxAuthPolicy, +) -> Router { + build_router(test_server_with_storage(configure, inbox_auth_policy)) +} + +pub fn test_server_with_storage( + configure: impl FnOnce(&SqliteStore), + inbox_auth_policy: InboxAuthPolicy, +) -> FederServer { + let actor = local_actor(); + let storage = SqliteStore::open_in_memory().expect("open in-memory store"); + storage + .insert_actor_key_pair(&actor.id, &actor_key_pair()) + .expect("store actor key pair"); + configure(&storage); + FederServer::with_outbound_address_policy( + TestActors { actor }, + storage, + HANDLE_HOST, + OutboundAddressPolicy::AllowPrivateAddress, + ) + .expect("construct Feder server") + .with_inbox_auth_policy(inbox_auth_policy) +} + +pub async fn spawn_inbox_server( + response_status: StatusCode, +) -> (String, mpsc::Receiver, JoinHandle<()>) { + let (sender, receiver) = mpsc::channel(2); + let app = Router::new().route( + "/inbox", + post(move |headers: HeaderMap, uri: Uri, body: Bytes| { + let sender = sender.clone(); + async move { + sender + .send(RecordedRequest { headers, uri, body }) + .await + .expect("request receiver remains open"); + response_status + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind inbox server"); + let address = listener.local_addr().expect("inbox server address"); + let task = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("serve inbox endpoint"); + }); + + (format!("http://{address}/inbox"), receiver, task) +} diff --git a/crates/ref-feder-runtime-server/tests/fixtures/rsa-private-key.pem b/crates/ref-feder-runtime-server/tests/fixtures/rsa-private-key.pem new file mode 100644 index 0000000..0355a1e --- /dev/null +++ b/crates/ref-feder-runtime-server/tests/fixtures/rsa-private-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDSXUHF1268trok +ZnAB9gVqnh4tL5gZc3WBSDIeNG/1niVRMVhMZ6kvLwv+WVqoyphMvTajUgeXAHIH +WIrUgJNQ8N7JhoDpqplt7+q+09l0treTRInuc+A6vjidawyMUFS1qxDK73JHmFO7 +5w+rbQneikedkGUIXL3vh7B1iOjz6o6f7g0cL0ykiVG05WhkWedY3iuHOYzL8YG+ +VazI1/7jBptlVs0OMg50y4jRogTwkmKoqUCae5F3kER2F5nw51a8D31l+KcOCNJF +7ZzPDrqaJgDXu/G1JaGSOuIizZS1d5BW6Pm6ftzv9UzOzeKtK4zEsLeBZhi0FrtI +5vKhe/l1AgMBAAECggEAMKIhPNstrYDEH3q0PevR/EBiYxlr/URRX+pgNdXzJVJi +t7bj/kP/29nxWKPxPvEZjTI4UcE64njWo+afL/oqtK1/IBGRt5O6hW1QNL5W+XHt +lmUjy0YsSoBkJ9aSD9VZhCdwii4Z2j3368rDN2NNww5ueJmjle+U9K3GyKF2k78U +2mczUoJ6LEJiAOpKVrrIdLEdb/NBE8b2lwqITHaL2Pidj9cbBfrU16pb1RG6z7Eb +Xv2AtobemO54oz+RnzLk6ApY9v27r5uXE/61WKN9iWFNHncoK/l95W/lktoCJLwM +3cMVZVPtEJdRCpEPbyfXG6pyAmgIjJDUj2lSwnr2kQKBgQDrkrVlZ+5H3qxoc6j7 +K4aWl1T7n4IHu3mGLE6ByLMHhjirq8SlG9tqJ09kpeprT5S+PtQiyVhud8PgejNJ +9eo/pU4ybE9VdAFacRCAgKN+jIFM6TniyLraNUK9+tHX53ca6Aolus8yiEaUQln5 +n8J9wrS18JV8T+9+OOJZPwql5QKBgQDkmvWOXD+wb23Sb4F8aD0WXOIqQacSHkjy +Fllp1FslvhgMGDIfwCAHDIT9osE4aK5Yy+88Fg5MGT0kgU0viguJ+fJheJ3oEwb+ +/wceEO11ts6+vaS9ZqAQO95lM+XoV6PU3uoyWE9uLYkxJd6Siz7BGUzl/TzZbWsw +ZzWD6I/MUQKBgCR1hUuXhUJsTSSxWeLdvqvJ6iYzbq2Br3I7oz7k8AhnFphDMmEX +aaMJSHlcUGahX3T+RljH7r7SHGe+ofd9bu7Ax9R3/ONN2/PCcfphbmxklJJxujrG +NF0XRygeDKIsubtZVFC4k97PRpUlm8VNm41ZOBy8inY97OQNK8MCRcSdAoGAVWXV +yWKIoD5gBjaFZpYCC/KSwjpYURpjIZxbtn8PtZ+3l/0J7HZ3AGsa2y0LhSkFyEIW +kpmiqabcAmETFmk5OkfW1babNnC1MljOrdqg+lJaFUL+4YoOzUGwKJokjpD+sKy9 +TCVVNtFn6KY+6Pt/a98prNjW/Fo1qpVDlo0v+qECgYBNH81yghesDmxGiRTcXrSF +l/eX53n77wqNnk7kay/hJj4uSH5QYkUEGRbwxLeO/X7cZ7X7mrc86QV5AXJbbdS9 +/zhWdXp7A16vqyCpSBJ2QcUreA+bZJ3eyTibBlE7pL8DMP4EDRryg4Zf563iCybO +JxO6BZUqb4N5zLW8t8GfXw== +-----END PRIVATE KEY----- diff --git a/crates/ref-feder-runtime-server/tests/fixtures/rsa-public-key.pem b/crates/ref-feder-runtime-server/tests/fixtures/rsa-public-key.pem new file mode 100644 index 0000000..6dfef20 --- /dev/null +++ b/crates/ref-feder-runtime-server/tests/fixtures/rsa-public-key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0l1BxdduvLa6JGZwAfYF +ap4eLS+YGXN1gUgyHjRv9Z4lUTFYTGepLy8L/llaqMqYTL02o1IHlwByB1iK1ICT +UPDeyYaA6aqZbe/qvtPZdLa3k0SJ7nPgOr44nWsMjFBUtasQyu9yR5hTu+cPq20J +3opHnZBlCFy974ewdYjo8+qOn+4NHC9MpIlRtOVoZFnnWN4rhzmMy/GBvlWsyNf+ +4wabZVbNDjIOdMuI0aIE8JJiqKlAmnuRd5BEdheZ8OdWvA99ZfinDgjSRe2czw66 +miYA17vxtSWhkjriIs2UtXeQVuj5un7c7/VMzs3irSuMxLC3gWYYtBa7SObyoXv5 +dQIDAQAB +-----END PUBLIC KEY----- diff --git a/crates/ref-feder-runtime-server/tests/runtime.rs b/crates/ref-feder-runtime-server/tests/runtime.rs new file mode 100644 index 0000000..b7ba75c --- /dev/null +++ b/crates/ref-feder-runtime-server/tests/runtime.rs @@ -0,0 +1,16 @@ +mod common; + +#[path = "cases/actor.rs"] +mod actor; +#[path = "cases/followers.rs"] +mod followers; +#[path = "cases/inbox.rs"] +mod inbox; +#[path = "cases/object.rs"] +mod object; +#[path = "cases/operation.rs"] +mod operation; +#[path = "cases/send.rs"] +mod send; +#[path = "cases/webfinger.rs"] +mod webfinger; diff --git a/crates/ref-feder-runtime-server/tests/storage.rs b/crates/ref-feder-runtime-server/tests/storage.rs new file mode 100644 index 0000000..2deae40 --- /dev/null +++ b/crates/ref-feder-runtime-server/tests/storage.rs @@ -0,0 +1,160 @@ +use feder_vocab::{Actor, Endpoints, Iri, Note, Reference}; +use rand_core::OsRng; +use ref_feder_core::{ + follow::PendingFollow, + key::ActorKeyPair, + storage::{FollowerDeliveryStore, FollowerDeliveryTarget, NoteStore, ServerStorage}, +}; +use ref_feder_runtime_server::storage::SqliteStore; + +const PRIVATE_KEY_PEM: &str = include_str!("fixtures/rsa-private-key.pem"); +const PUBLIC_KEY_PEM: &str = include_str!("fixtures/rsa-public-key.pem"); + +fn iri(value: &str) -> Iri { + value.parse().expect("valid test IRI") +} + +fn actor(id: &str) -> Actor { + Actor::person( + iri(id), + iri(&format!("{id}/inbox")), + iri(&format!("{id}/outbox")), + ) +} + +fn actor_key_pair() -> ActorKeyPair { + ActorKeyPair::from_pem(PRIVATE_KEY_PEM.to_string(), PUBLIC_KEY_PEM.to_string()) + .expect("valid actor key pair") +} + +#[test] +fn stores_lists_and_removes_follower_delivery_facts() { + let store = SqliteStore::open_in_memory().expect("open store"); + let following = iri("https://local.example/users/alice"); + let mut follower = actor("https://remote.example/users/bob"); + follower.endpoints = Some(Endpoints { + shared_inbox: Some(iri("https://remote.example/inbox")), + }); + + store + .store_follower(&follower, &following) + .expect("store follower"); + + assert_eq!( + store.list_followers(&following).expect("list followers"), + vec![follower.id.clone()] + ); + assert_eq!( + store + .list_follower_delivery_targets(&following) + .expect("list delivery targets"), + vec![FollowerDeliveryTarget { + actor_id: follower.id.clone(), + inbox: follower.inbox.clone(), + shared_inbox: follower + .endpoints + .and_then(|endpoints| endpoints.shared_inbox), + }] + ); + + store + .remove_follower(&follower.id, &following) + .expect("remove follower"); + assert!( + store + .list_followers(&following) + .expect("list followers") + .is_empty() + ); +} + +#[test] +fn stores_and_loads_note() { + let store = SqliteStore::open_in_memory().expect("open store"); + let mut note = Note::new(iri("https://local.example/posts/1")); + note.attributed_to = Some(Reference::id(iri("https://local.example/users/alice"))); + note.content = Some("hello".to_string()); + + store.store_note(¬e).expect("store Note"); + + assert_eq!(store.load_note(¬e.id).expect("load Note"), Some(note)); +} + +#[test] +fn confirms_only_the_expected_pending_follow() { + let store = SqliteStore::open_in_memory().expect("open store"); + let pending = PendingFollow { + local_actor: iri("https://local.example/users/alice"), + remote_actor: actor("https://remote.example/users/bob"), + follow_activity: iri("https://local.example/activities/follow/1"), + }; + store + .store_pending_follow(&pending) + .expect("store pending Follow"); + + let mut wrong = pending.clone(); + wrong.local_actor = iri("https://local.example/users/mallory"); + assert!( + !store + .confirm_pending_follow(&wrong) + .expect("reject mismatch") + ); + assert_eq!( + store + .load_pending_follow(&pending.follow_activity) + .expect("load pending Follow"), + Some(pending.clone()) + ); + + assert!( + store + .confirm_pending_follow(&pending) + .expect("confirm pending Follow") + ); + assert_eq!( + store + .load_pending_follow(&pending.follow_activity) + .expect("load accepted Follow"), + None + ); +} + +#[test] +fn actor_key_pair_roundtrips() { + let store = SqliteStore::open_in_memory().expect("open store"); + let actor_id = iri("https://local.example/users/alice"); + let key_pair = actor_key_pair(); + + store + .insert_actor_key_pair(&actor_id, &key_pair) + .expect("store actor keys"); + + assert_eq!( + store + .load_actor_key_pair(&actor_id) + .expect("load actor keys"), + Some(key_pair) + ); +} + +#[test] +fn provisioning_reuses_existing_actor_key_without_generation() { + let store = SqliteStore::open_in_memory().expect("open store"); + let actor_id = iri("https://local.example/users/alice"); + let key_pair = actor_key_pair(); + store + .insert_actor_key_pair(&actor_id, &key_pair) + .expect("store actor keys"); + + let provisioned = store + .load_or_generate_actor_key_pair(&actor_id, &mut OsRng) + .expect("reuse actor key"); + + assert_eq!(provisioned, key_pair); +} + +#[test] +fn sqlite_store_is_send_and_sync() { + fn assert_send_sync() {} + assert_send_sync::(); +} From 056c2cd063fa42159949c4884e77410fc40f379a Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sun, 2 Aug 2026 17:47:42 +0900 Subject: [PATCH 72/72] Promote the reference architecture to Feder's public crates Replace the previous stateful feder-core implementation with the portable protocol transition functions and capability traits developed in the reference crate. Publish the standard operating system runtime as feder-server and update its dependencies, imports, tests, and documentation to use the final feder-core package. Remove the superseded feder-runtime-server, temporary ref-* crates, and the redundant custom-storage example. Retain the SQLite single-user server as a non-publishable end-to-end example. Restrict release version stamping to publishable workspace packages and update the project documentation to describe the stateless core and server runtime boundaries. Assisted-by: Codex:gpt-5.6-sol --- CONTRIBUTING.md | 19 +- Cargo.lock | 53 +- Cargo.toml | 9 +- README.md | 9 +- crates/feder-core/Cargo.toml | 10 +- .../src/follow.rs | 0 crates/feder-core/src/http_signatures.rs | 378 ------ .../{ref-feder-core => feder-core}/src/key.rs | 0 crates/feder-core/src/lib.rs | 956 +-------------- .../src/note.rs | 0 .../src/storage.rs | 0 .../src/undo.rs | 0 .../tests/common/mod.rs | 0 .../tests/follow.rs | 4 +- .../tests/key.rs | 2 +- .../tests/note.rs | 4 +- .../tests/undo.rs | 2 +- crates/feder-runtime-server/Cargo.toml | 35 - crates/feder-runtime-server/README.md | 65 - crates/feder-runtime-server/src/actor.rs | 261 ---- crates/feder-runtime-server/src/app.rs | 125 -- crates/feder-runtime-server/src/config.rs | 53 - crates/feder-runtime-server/src/error.rs | 41 - crates/feder-runtime-server/src/followers.rs | 66 - crates/feder-runtime-server/src/inbox.rs | 502 -------- crates/feder-runtime-server/src/lib.rs | 33 - .../feder-runtime-server/src/negotiation.rs | 194 --- crates/feder-runtime-server/src/object.rs | 84 -- crates/feder-runtime-server/src/operation.rs | 129 -- crates/feder-runtime-server/src/send.rs | 177 --- .../feder-runtime-server/src/storage/mod.rs | 80 -- .../src/storage/sqlite.rs | 1087 ----------------- crates/feder-runtime-server/src/url.rs | 148 --- crates/feder-runtime-server/src/webfinger.rs | 74 -- .../feder-runtime-server/tests/cases/actor.rs | 130 -- .../feder-runtime-server/tests/cases/app.rs | 82 -- .../tests/cases/followers.rs | 169 --- .../feder-runtime-server/tests/cases/inbox.rs | 877 ------------- .../tests/cases/object.rs | 237 ---- .../tests/cases/operation.rs | 308 ----- .../feder-runtime-server/tests/cases/send.rs | 234 ---- .../tests/cases/webfinger.rs | 94 -- .../feder-runtime-server/tests/common/mod.rs | 179 --- crates/feder-runtime-server/tests/runtime.rs | 33 - .../Cargo.toml | 7 +- .../src/actor.rs | 2 +- .../src/config.rs | 0 .../src/follow.rs | 2 +- .../src/followers.rs | 2 +- .../src/inbox.rs | 6 +- .../src/lib.rs | 11 +- .../src/negotiation.rs | 0 .../src/note.rs | 4 +- .../src/object.rs | 2 +- .../src/send.rs | 4 +- .../src/storage/mod.rs | 2 +- .../src/storage/sqlite.rs | 6 +- .../src/url.rs | 0 .../src/webfinger.rs | 2 +- .../tests/cases/actor.rs | 2 +- .../tests/cases/followers.rs | 2 +- .../tests/cases/inbox.rs | 4 +- .../tests/cases/object.rs | 2 +- .../tests/cases/operation.rs | 6 +- .../tests/cases/send.rs | 8 +- .../tests/cases/webfinger.rs | 0 .../tests/common/mod.rs | 6 +- .../tests/fixtures/rsa-private-key.pem | 0 .../tests/fixtures/rsa-public-key.pem | 0 .../tests/runtime.rs | 0 .../tests/storage.rs | 8 +- crates/ref-feder-core/Cargo.toml | 27 - crates/ref-feder-core/src/lib.rs | 44 - .../tests/fixtures/rsa-other-public-key.pem | 9 - .../tests/fixtures/rsa-private-key.pem | 28 - .../tests/fixtures/rsa-public-key.pem | 9 - .../tests/fixtures/rsa-private-key.pem | 28 - .../tests/fixtures/rsa-public-key.pem | 9 - examples/ref-actor-server/Cargo.toml | 17 - examples/ref-actor-server/README.md | 172 --- examples/ref-actor-server/src/main.rs | 449 ------- examples/single-user-server/Cargo.toml | 5 +- examples/single-user-server/README.md | 2 +- examples/single-user-server/src/main.rs | 8 +- mise.toml | 4 +- 85 files changed, 102 insertions(+), 7740 deletions(-) rename crates/{ref-feder-core => feder-core}/src/follow.rs (100%) delete mode 100644 crates/feder-core/src/http_signatures.rs rename crates/{ref-feder-core => feder-core}/src/key.rs (100%) rename crates/{ref-feder-core => feder-core}/src/note.rs (100%) rename crates/{ref-feder-core => feder-core}/src/storage.rs (100%) rename crates/{ref-feder-core => feder-core}/src/undo.rs (100%) rename crates/{ref-feder-core => feder-core}/tests/common/mod.rs (100%) rename crates/{ref-feder-core => feder-core}/tests/follow.rs (99%) rename crates/{ref-feder-core => feder-core}/tests/key.rs (98%) rename crates/{ref-feder-core => feder-core}/tests/note.rs (99%) rename crates/{ref-feder-core => feder-core}/tests/undo.rs (97%) delete mode 100644 crates/feder-runtime-server/Cargo.toml delete mode 100644 crates/feder-runtime-server/README.md delete mode 100644 crates/feder-runtime-server/src/actor.rs delete mode 100644 crates/feder-runtime-server/src/app.rs delete mode 100644 crates/feder-runtime-server/src/config.rs delete mode 100644 crates/feder-runtime-server/src/error.rs delete mode 100644 crates/feder-runtime-server/src/followers.rs delete mode 100644 crates/feder-runtime-server/src/inbox.rs delete mode 100644 crates/feder-runtime-server/src/lib.rs delete mode 100644 crates/feder-runtime-server/src/negotiation.rs delete mode 100644 crates/feder-runtime-server/src/object.rs delete mode 100644 crates/feder-runtime-server/src/operation.rs delete mode 100644 crates/feder-runtime-server/src/send.rs delete mode 100644 crates/feder-runtime-server/src/storage/mod.rs delete mode 100644 crates/feder-runtime-server/src/storage/sqlite.rs delete mode 100644 crates/feder-runtime-server/src/url.rs delete mode 100644 crates/feder-runtime-server/src/webfinger.rs delete mode 100644 crates/feder-runtime-server/tests/cases/actor.rs delete mode 100644 crates/feder-runtime-server/tests/cases/app.rs delete mode 100644 crates/feder-runtime-server/tests/cases/followers.rs delete mode 100644 crates/feder-runtime-server/tests/cases/inbox.rs delete mode 100644 crates/feder-runtime-server/tests/cases/object.rs delete mode 100644 crates/feder-runtime-server/tests/cases/operation.rs delete mode 100644 crates/feder-runtime-server/tests/cases/send.rs delete mode 100644 crates/feder-runtime-server/tests/cases/webfinger.rs delete mode 100644 crates/feder-runtime-server/tests/common/mod.rs delete mode 100644 crates/feder-runtime-server/tests/runtime.rs rename crates/{ref-feder-runtime-server => feder-server}/Cargo.toml (79%) rename crates/{ref-feder-runtime-server => feder-server}/src/actor.rs (99%) rename crates/{ref-feder-runtime-server => feder-server}/src/config.rs (100%) rename crates/{ref-feder-runtime-server => feder-server}/src/follow.rs (97%) rename crates/{ref-feder-runtime-server => feder-server}/src/followers.rs (97%) rename crates/{ref-feder-runtime-server => feder-server}/src/inbox.rs (99%) rename crates/{ref-feder-runtime-server => feder-server}/src/lib.rs (92%) rename crates/{ref-feder-runtime-server => feder-server}/src/negotiation.rs (100%) rename crates/{ref-feder-runtime-server => feder-server}/src/note.rs (99%) rename crates/{ref-feder-runtime-server => feder-server}/src/object.rs (96%) rename crates/{ref-feder-runtime-server => feder-server}/src/send.rs (99%) rename crates/{ref-feder-runtime-server => feder-server}/src/storage/mod.rs (97%) rename crates/{ref-feder-runtime-server => feder-server}/src/storage/sqlite.rs (99%) rename crates/{ref-feder-runtime-server => feder-server}/src/url.rs (100%) rename crates/{ref-feder-runtime-server => feder-server}/src/webfinger.rs (98%) rename crates/{ref-feder-runtime-server => feder-server}/tests/cases/actor.rs (98%) rename crates/{ref-feder-runtime-server => feder-server}/tests/cases/followers.rs (98%) rename crates/{ref-feder-runtime-server => feder-server}/tests/cases/inbox.rs (98%) rename crates/{ref-feder-runtime-server => feder-server}/tests/cases/object.rs (98%) rename crates/{ref-feder-runtime-server => feder-server}/tests/cases/operation.rs (97%) rename crates/{ref-feder-runtime-server => feder-server}/tests/cases/send.rs (96%) rename crates/{ref-feder-runtime-server => feder-server}/tests/cases/webfinger.rs (100%) rename crates/{ref-feder-runtime-server => feder-server}/tests/common/mod.rs (98%) rename crates/{feder-runtime-server => feder-server}/tests/fixtures/rsa-private-key.pem (100%) rename crates/{feder-runtime-server => feder-server}/tests/fixtures/rsa-public-key.pem (100%) rename crates/{ref-feder-runtime-server => feder-server}/tests/runtime.rs (100%) rename crates/{ref-feder-runtime-server => feder-server}/tests/storage.rs (98%) delete mode 100644 crates/ref-feder-core/Cargo.toml delete mode 100644 crates/ref-feder-core/src/lib.rs delete mode 100644 crates/ref-feder-core/tests/fixtures/rsa-other-public-key.pem delete mode 100644 crates/ref-feder-core/tests/fixtures/rsa-private-key.pem delete mode 100644 crates/ref-feder-core/tests/fixtures/rsa-public-key.pem delete mode 100644 crates/ref-feder-runtime-server/tests/fixtures/rsa-private-key.pem delete mode 100644 crates/ref-feder-runtime-server/tests/fixtures/rsa-public-key.pem delete mode 100644 examples/ref-actor-server/Cargo.toml delete mode 100644 examples/ref-actor-server/README.md delete mode 100644 examples/ref-actor-server/src/main.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 42248a0..a4dcc56 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,20 +75,23 @@ execution. The intended crate roles are: - `feder-vocab`: Type-safe representations of Activity Vocabulary objects, such as actors, notes, and activities. - - `feder-core`: The portable ActivityPub protocol engine, responsible for - protocol decisions and state transitions. - - Runtime crates: Platform-specific execution layers for networking, storage, - clocks, timers, async runtimes, and operating system or hardware - integration. + - `feder-core`: Portable ActivityPub protocol decisions and capability traits. + It derives transient outcomes from facts supplied by its caller and does + not retain protocol state. + - `feder-server`: The standard operating system runtime for HTTP networking, + SQLite storage, actor resolution, request verification, and activity + delivery. + - Future runtime crates: Platform-specific implementations for other async + runtimes, operating systems, or hardware environments. When contributing to `feder-core`, avoid adding direct dependencies on HTTP clients or servers, databases, filesystems, async runtimes, system clocks, or platform-specific crates. Runtime crates may use those dependencies when appropriate, but those choices should not leak into the portable core. -Core behaviour should generally be tested by feeding an input into the core and -asserting the returned actions. Core tests should not require real networking, -storage, or async execution. +Core behaviour should generally be tested by feeding stored facts and protocol +input into a core function and asserting the returned transient outcome. Core +tests should not require real networking, storage, or async execution. ### Git pre-commit hook diff --git a/Cargo.lock b/Cargo.lock index c781ab5..bc02422 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -298,13 +298,12 @@ version = "0.1.0" dependencies = [ "base64", "feder-vocab", - "rand_chacha", "rsa", "zeroize", ] [[package]] -name = "feder-runtime-server" +name = "feder-server" version = "0.1.0" dependencies = [ "axum", @@ -312,7 +311,6 @@ dependencies = [ "feder-vocab", "httpdate", "ipnet", - "iri-string", "mime", "percent-encoding", "rand_core 0.6.4", @@ -1113,51 +1111,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "ref-actor-server" -version = "0.1.0" -dependencies = [ - "axum", - "feder-vocab", - "ref-feder-core", - "ref-feder-runtime-server", - "tokio", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "ref-feder-core" -version = "0.1.0" -dependencies = [ - "base64", - "feder-vocab", - "rsa", - "zeroize", -] - -[[package]] -name = "ref-feder-runtime-server" -version = "0.1.0" -dependencies = [ - "axum", - "feder-vocab", - "httpdate", - "ipnet", - "mime", - "percent-encoding", - "rand_core 0.6.4", - "ref-feder-core", - "reqwest", - "rusqlite", - "serde", - "serde_json", - "thiserror", - "tokio", - "tower", - "url", -] - [[package]] name = "regex-automata" version = "0.4.14" @@ -1542,10 +1495,10 @@ name = "single-user-server" version = "0.1.0" dependencies = [ "axum", + "feder-core", + "feder-server", "feder-vocab", "rand_core 0.6.4", - "ref-feder-core", - "ref-feder-runtime-server", "tokio", "tracing", "tracing-subscriber", diff --git a/Cargo.toml b/Cargo.toml index 4ae5ddc..33ce3d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,10 +2,7 @@ members = [ "crates/feder-core", "crates/feder-vocab", - "crates/feder-runtime-server", - "crates/ref-feder-core", - "crates/ref-feder-runtime-server", - "examples/ref-actor-server", + "crates/feder-server", "examples/single-user-server", ] resolver = "3" @@ -23,10 +20,8 @@ repository = "https://github.com/fedify-dev/feder" # Use `mise run bump-execute ` instead of editing these by hand. base64 = { version = "0.22.1", default-features = false, features = ["alloc"] } feder-core = { version = "0.1.0", path = "crates/feder-core" } -feder-runtime-server = { version = "0.1.0", path = "crates/feder-runtime-server" } +feder-server = { version = "0.1.0", path = "crates/feder-server" } feder-vocab = { version = "0.1.0", path = "crates/feder-vocab" } -ref-feder-core = { version = "0.1.0", path = "crates/ref-feder-core" } -ref-feder-runtime-server = { version = "0.1.0", path = "crates/ref-feder-runtime-server" } iri-string = { version = "0.7.12", default-features = false, features = ["alloc", "serde"] } rand_chacha = { version = "0.3.1", default-features = false } rand_core = { version = "0.6.4", features = ["getrandom"] } diff --git a/README.md b/README.md index 9ea8f04..8aa2929 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,11 @@ software so different parts can run on machines with very different resources. Approach -------- -Feder separates ActivityPub protocol logic from platform execution. The core -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. +Feder separates ActivityPub protocol decisions from platform execution. +`feder-core` derives transient protocol outcomes from application-provided +facts without retaining state. `feder-server` supplies a standard operating +system runtime with HTTP networking, SQLite storage, actor resolution, request +verification, and activity delivery. 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/Cargo.toml b/crates/feder-core/Cargo.toml index ab5bb5b..4b17412 100644 --- a/crates/feder-core/Cargo.toml +++ b/crates/feder-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "feder-core" -description = "Portable ActivityPub core logic for Feder." +description = "Portable ActivityPub protocol decisions and capability traits for Feder." version.workspace = true edition.workspace = true authors.workspace = true @@ -17,8 +17,10 @@ feder-vocab.workspace = true rsa = { workspace = true, optional = true } zeroize = { workspace = true, optional = true } -[dev-dependencies] -rand_chacha.workspace = true - [lints] workspace = true + +[[test]] +name = "key" +path = "tests/key.rs" +required-features = ["http-signatures"] diff --git a/crates/ref-feder-core/src/follow.rs b/crates/feder-core/src/follow.rs similarity index 100% rename from crates/ref-feder-core/src/follow.rs rename to crates/feder-core/src/follow.rs diff --git a/crates/feder-core/src/http_signatures.rs b/crates/feder-core/src/http_signatures.rs deleted file mode 100644 index d06d56f..0000000 --- a/crates/feder-core/src/http_signatures.rs +++ /dev/null @@ -1,378 +0,0 @@ -//! Draft-Cavage HTTP Signature primitives. - -use alloc::{format, string::String, vec::Vec}; -use core::fmt; - -use base64::{Engine as _, engine::general_purpose::STANDARD}; -use rsa::{ - RsaPrivateKey, RsaPublicKey, - pkcs1v15::{Signature, SigningKey, VerifyingKey}, - pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey, LineEnding}, - rand_core::CryptoRngCore, - sha2::{Digest, Sha256}, - signature::{SignatureEncoding, Signer, Verifier}, -}; -use zeroize::Zeroizing; - -const ACTOR_RSA_BITS: usize = 4096; - -/// A local actor's RSA key pair encoded for persistent storage. -#[derive(Clone, Eq, PartialEq)] -pub struct ActorKeyPair { - private_key_pem: Zeroizing, - public_key_pem: String, -} - -impl ActorKeyPair { - /// Loads a persisted key pair and checks that both keys belong together. - pub fn from_pem(private_key_pem: String, public_key_pem: String) -> Result { - let private_key_pem = Zeroizing::new(private_key_pem); - let private_key = - RsaPrivateKey::from_pkcs8_pem(&private_key_pem).map_err(KeyError::InvalidPrivateKey)?; - let public_key = RsaPublicKey::from_public_key_pem(&public_key_pem) - .map_err(KeyError::InvalidPublicKey)?; - - if RsaPublicKey::from(&private_key) != public_key { - return Err(KeyError::MismatchedKeyPair); - } - - Ok(Self { - private_key_pem, - public_key_pem, - }) - } - - #[must_use] - pub fn private_key_pem(&self) -> &str { - &self.private_key_pem - } - - #[must_use] - pub fn public_key_pem(&self) -> &str { - &self.public_key_pem - } -} - -impl fmt::Debug for ActorKeyPair { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("ActorKeyPair") - .field("private_key_pem", &"[REDACTED]") - .field("public_key_pem", &self.public_key_pem) - .finish() - } -} - -/// Errors produced while generating, encoding, or loading actor keys. -#[derive(Debug)] -pub enum KeyError { - Generation(rsa::Error), - PrivateKeyEncoding(rsa::pkcs8::Error), - PublicKeyEncoding(rsa::pkcs8::spki::Error), - InvalidPrivateKey(rsa::pkcs8::Error), - InvalidPublicKey(rsa::pkcs8::spki::Error), - MismatchedKeyPair, -} - -impl fmt::Display for KeyError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Generation(_) => formatter.write_str("failed to generate RSA actor key"), - Self::PrivateKeyEncoding(_) => formatter.write_str("failed to encode RSA private key"), - Self::PublicKeyEncoding(_) => formatter.write_str("failed to encode RSA public key"), - Self::InvalidPrivateKey(_) => formatter.write_str("invalid RSA private key PEM"), - Self::InvalidPublicKey(_) => formatter.write_str("invalid RSA public key PEM"), - Self::MismatchedKeyPair => formatter.write_str("RSA actor keys do not match"), - } - } -} - -impl core::error::Error for KeyError {} - -/// Generates a 4096-bit RSA actor key pair for draft-Cavage HTTP signatures. -/// The caller must supply a cryptographically secure random number generator for the target runtime. -pub fn generate_actor_key_pair( - rng: &mut (impl CryptoRngCore + ?Sized), -) -> Result { - let private_key = RsaPrivateKey::new(rng, ACTOR_RSA_BITS).map_err(KeyError::Generation)?; - let public_key = RsaPublicKey::from(&private_key); - let private_key_pem = private_key - .to_pkcs8_pem(LineEnding::LF) - .map_err(KeyError::PrivateKeyEncoding)?; - let public_key_pem = public_key - .to_public_key_pem(LineEnding::LF) - .map_err(KeyError::PublicKeyEncoding)?; - - Ok(ActorKeyPair { - private_key_pem, - public_key_pem, - }) -} - -/// Creates an RFC 3230 SHA-256 digest header. -#[must_use] -pub fn create_sha256_digest_header(body: &[u8]) -> String { - let digest = Sha256::digest(body); - format!("SHA-256={}", STANDARD.encode(digest)) -} - -/// Signs a prepared HTTP request using the draft-Cavage header format. -/// -/// Header names must be supplied in the order in which they should appear in -/// the signature's `headers` parameter. -pub fn sign_draft_cavage( - key_pair: &ActorKeyPair, - key_id: &str, - method: &str, - request_target: &str, - headers: &[(&str, &str)], -) -> Result { - let signature_base = draft_cavage_signature_base(method, request_target, headers); - let private_key = RsaPrivateKey::from_pkcs8_pem(key_pair.private_key_pem()) - .map_err(HttpSignatureError::InvalidPrivateKey)?; - let signing_key = SigningKey::::new(private_key); - let signature = signing_key.sign(signature_base.as_bytes()).to_bytes(); - let signed_headers = headers - .iter() - .map(|(name, _)| name.to_ascii_lowercase()) - .collect::>() - .join(" "); - - Ok(format!( - "keyId=\"{key_id}\",algorithm=\"rsa-sha256\",headers=\"(request-target) {signed_headers}\",signature=\"{}\"", - STANDARD.encode(signature) - )) -} - -/// Verifies a draft-Cavage RSA-SHA256 signature over a prepared request. -/// -/// `headers` must contain the signed HTTP headers in their declared order, -/// excluding the `(request-target)` pseudo-header. -pub fn verify_draft_cavage( - public_key_pem: &str, - method: &str, - request_target: &str, - headers: &[(&str, &str)], - signature: &str, -) -> Result<(), HttpSignatureVerificationError> { - let signature_base = draft_cavage_signature_base(method, request_target, headers); - let public_key = RsaPublicKey::from_public_key_pem(public_key_pem) - .map_err(HttpSignatureVerificationError::InvalidPublicKey)?; - let signature = STANDARD - .decode(signature) - .map_err(HttpSignatureVerificationError::InvalidSignatureEncoding)?; - let signature = Signature::try_from(signature.as_slice()) - .map_err(HttpSignatureVerificationError::InvalidSignature)?; - let verifying_key = VerifyingKey::::new(public_key); - - verifying_key - .verify(signature_base.as_bytes(), &signature) - .map_err(HttpSignatureVerificationError::Verification) -} - -fn draft_cavage_signature_base( - method: &str, - request_target: &str, - headers: &[(&str, &str)], -) -> String { - let mut lines = Vec::with_capacity(headers.len() + 1); - lines.push(format!( - "(request-target): {} {request_target}", - method.to_ascii_lowercase() - )); - lines.extend( - headers - .iter() - .map(|(name, value)| format!("{}: {}", name.to_ascii_lowercase(), value.trim())), - ); - lines.join("\n") -} - -/// Errors produced while creating an HTTP signature. -#[derive(Debug)] -pub enum HttpSignatureError { - InvalidPrivateKey(rsa::pkcs8::Error), -} - -impl fmt::Display for HttpSignatureError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidPrivateKey(_) => formatter.write_str("invalid RSA private key PEM"), - } - } -} - -impl core::error::Error for HttpSignatureError {} - -/// Errors produced while verifying an HTTP signature. -#[derive(Debug)] -pub enum HttpSignatureVerificationError { - InvalidPublicKey(rsa::pkcs8::spki::Error), - InvalidSignatureEncoding(base64::DecodeError), - InvalidSignature(rsa::signature::Error), - Verification(rsa::signature::Error), -} - -impl fmt::Display for HttpSignatureVerificationError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InvalidPublicKey(_) => formatter.write_str("invalid RSA public key PEM"), - Self::InvalidSignatureEncoding(_) => { - formatter.write_str("invalid base64 signature encoding") - } - Self::InvalidSignature(_) => formatter.write_str("invalid RSA signature"), - Self::Verification(_) => formatter.write_str("HTTP signature verification failed"), - } - } -} - -impl core::error::Error for HttpSignatureVerificationError {} - -#[cfg(test)] -mod tests { - use alloc::string::ToString; - - use rand_chacha::ChaCha20Rng; - use rsa::rand_core::SeedableRng; - use rsa::traits::PublicKeyParts; - use rsa::{ - pkcs1v15::{Signature, VerifyingKey}, - signature::Verifier, - }; - - use super::*; - - const PRIVATE_KEY_PEM: &str = include_str!("../tests/fixtures/rsa-private-key.pem"); - const PUBLIC_KEY_PEM: &str = include_str!("../tests/fixtures/rsa-public-key.pem"); - const OTHER_PUBLIC_KEY_PEM: &str = include_str!("../tests/fixtures/rsa-other-public-key.pem"); - - #[test] - fn generated_actor_key_pair_uses_4096_bit_rsa() { - let mut rng = test_rng(1); - let pair = generate_actor_key_pair(&mut rng).expect("generate actor key pair"); - let private_key = RsaPrivateKey::from_pkcs8_pem(pair.private_key_pem()) - .expect("parse generated private key"); - let public_key = RsaPublicKey::from_public_key_pem(pair.public_key_pem()) - .expect("parse generated public key"); - - assert_eq!(private_key.n().bits(), ACTOR_RSA_BITS); - assert_eq!(public_key.n().bits(), ACTOR_RSA_BITS); - assert_eq!(RsaPublicKey::from(&private_key), public_key); - } - - #[test] - fn persisted_actor_key_pair_rejects_mismatched_keys() { - let result = ActorKeyPair::from_pem( - PRIVATE_KEY_PEM.to_string(), - OTHER_PUBLIC_KEY_PEM.to_string(), - ); - - assert!(matches!(result, Err(KeyError::MismatchedKeyPair))); - } - - #[test] - fn actor_key_pair_debug_output_redacts_private_key() { - let pair = ActorKeyPair::from_pem(PRIVATE_KEY_PEM.to_string(), PUBLIC_KEY_PEM.to_string()) - .expect("load actor key pair fixture"); - let debug = alloc::format!("{pair:?}"); - - assert!(debug.contains("[REDACTED]")); - assert!(!debug.contains(pair.private_key_pem())); - } - - #[test] - fn sha256_digest_matches_known_vector() { - assert_eq!( - create_sha256_digest_header(b"Hello, world!"), - "SHA-256=MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=" - ); - } - - #[test] - fn draft_cavage_signature_preserves_header_order() { - let pair = ActorKeyPair::from_pem(PRIVATE_KEY_PEM.to_string(), PUBLIC_KEY_PEM.to_string()) - .expect("load actor key pair fixture"); - let headers = [ - ("accept", "text/plain"), - ("content-type", "text/plain; charset=utf-8"), - ("date", "Tue, 05 Mar 2024 07:49:44 GMT"), - ( - "digest", - "SHA-256=MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=", - ), - ("host", "example.com"), - ]; - - let signature_header = - sign_draft_cavage(&pair, "https://example.com/key", "POST", "/", &headers) - .expect("sign request"); - - assert!(signature_header.starts_with( - "keyId=\"https://example.com/key\",algorithm=\"rsa-sha256\",headers=\"(request-target) accept content-type date digest host\",signature=\"" - )); - let signature_prefix = signature_header_prefix(&headers); - let signature = signature_header - .strip_prefix(&signature_prefix) - .and_then(|value| value.strip_suffix('"')) - .expect("signature parameter"); - let signature = STANDARD.decode(signature).expect("base64 signature"); - let signature = Signature::try_from(signature.as_slice()).expect("RSA signature"); - let public_key = - RsaPublicKey::from_public_key_pem(pair.public_key_pem()).expect("parse public key"); - let verifying_key = VerifyingKey::::new(public_key); - let signature_base = draft_cavage_signature_base("POST", "/", &headers); - - verifying_key - .verify(signature_base.as_bytes(), &signature) - .expect("verify signature"); - } - - #[test] - fn draft_cavage_signature_verifies_and_rejects_changed_headers() { - let pair = ActorKeyPair::from_pem(PRIVATE_KEY_PEM.to_string(), PUBLIC_KEY_PEM.to_string()) - .expect("load actor key pair fixture"); - let headers = [ - ("date", "Tue, 05 Mar 2024 07:49:44 GMT"), - ( - "digest", - "SHA-256=MV9b23bQeMQ7isAGTkoBZGErH853yGk0W/yUx1iU7dM=", - ), - ("host", "example.com"), - ]; - let signature_header = - sign_draft_cavage(&pair, "https://example.com/key", "POST", "/inbox", &headers) - .expect("sign request"); - let signature = signature_header - .rsplit_once("signature=\"") - .and_then(|(_, signature)| signature.strip_suffix('"')) - .expect("signature parameter"); - - verify_draft_cavage(pair.public_key_pem(), "POST", "/inbox", &headers, signature) - .expect("verify request"); - assert!( - verify_draft_cavage( - pair.public_key_pem(), - "POST", - "/other-inbox", - &headers, - signature, - ) - .is_err() - ); - } - - fn signature_header_prefix(headers: &[(&str, &str)]) -> String { - let signed_headers = headers - .iter() - .map(|(name, _)| name.to_ascii_lowercase()) - .collect::>() - .join(" "); - format!( - "keyId=\"https://example.com/key\",algorithm=\"rsa-sha256\",headers=\"(request-target) {signed_headers}\",signature=\"" - ) - } - - fn test_rng(seed: u8) -> ChaCha20Rng { - ChaCha20Rng::from_seed([seed; 32]) - } -} diff --git a/crates/ref-feder-core/src/key.rs b/crates/feder-core/src/key.rs similarity index 100% rename from crates/ref-feder-core/src/key.rs rename to crates/feder-core/src/key.rs diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 18f0d15..219e0ba 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -13,955 +13,29 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! Portable ActivityPub core logic for Feder. +//! Portable ActivityPub protocol decisions and capability traits for Feder. +//! +//! This crate is independent of networking, persistence, operating-system +//! services, and async executors. Runtimes supply stored facts and execute the +//! transient outcomes returned by its protocol functions. #![no_std] extern crate alloc; -use alloc::{string::String, vec::Vec}; - pub use feder_vocab as vocab; +use feder_vocab::{Actor, Iri}; +pub mod follow; #[cfg(feature = "http-signatures")] -pub mod http_signatures; - -pub const PUBLIC_COLLECTION: &str = "https://www.w3.org/ns/activitystreams#Public"; - -/// Portable core state and decision logic. -#[derive(Debug)] -pub struct FederCore { - state: FederState, -} - -impl FederCore { - #[must_use] - 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::ReceivedUndoFollow(input) => { - let actions = self.state.record_undo_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. -// FIXME: Massive heap growth detected. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct FederState { - local_actor: vocab::Actor, - followers: Vec, - objects: Vec, - activities: Vec, -} - -impl FederState { - #[must_use] - pub fn new(config: FederConfig) -> Self { - Self { - local_actor: config.local_actor, - followers: 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] - 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 Vec::new(); - }; - - if following != &self.local_actor.id { - return Vec::new(); - } - - let Some(follower) = reference_id(&follow.actor).cloned() else { - return Vec::new(); - }; - - 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 inbox = match &follow.actor { - vocab::Reference::Object(actor) => Some(actor.inbox.clone()), - vocab::Reference::Id(_) => None, - }; - - 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), - recipients: Recipients::Inbox(inbox), - })); - } - - actions - } - - fn record_undo_follow(&mut self, input: ReceivedUndoFollow) -> Vec { - let undo = input.undo; - let Some(undo_actor) = reference_id(&undo.actor) else { - return Vec::new(); - }; - let vocab::Reference::Object(follow) = undo.object else { - return Vec::new(); - }; - let Some(follower) = reference_id(&follow.actor) else { - return Vec::new(); - }; - let Some(following) = reference_id(&follow.object) else { - return Vec::new(); - }; - - if undo_actor != follower || following != &self.local_actor.id { - return Vec::new(); - } - - let relation = Follower { - follower: follower.clone(), - following: following.clone(), - }; - self.followers.retain(|existing| existing != &relation); - - Vec::from([Action::RemoveFollower(RemoveFollower { - follower: follower.clone(), - following: following.clone(), - })]) - } - - 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.to = input.to; - note.cc = input.cc; - note.content = Some(input.content); - note.media_type = input.media_type; - note.published = input.published; - note.url = input.url; - - let mut create = vocab::Create::new( - input.create_id, - actor, - vocab::Reference::object(note.clone()), - ); - create.to = note.to.clone(); - create.cc = note.cc.clone(); - - let recipients = note_recipients(&self.local_actor, ¬e); - - let object = Object::Note(note); - self.objects.push(object.clone()); - self.activities.push(Activity::CreateNote(create.clone())); - - let mut actions = Vec::new(); - - actions.push(Action::StoreObject(StoreObject { object })); - - for recipient in recipients { - actions.push(Action::SendActivity(SendActivity { - activity: Activity::CreateNote(create.clone()), - recipients: recipient, - })); - } - - actions - } -} - -fn note_recipients(local_actor: &vocab::Actor, note: &vocab::Note) -> Vec { - let mut recipients = Vec::new(); - - for address in note.to.iter().chain(note.cc.iter()) { - let recipient = if address.as_str() == PUBLIC_COLLECTION { - // Public describes visibility. It cannot receive an activity. - continue; - } else if local_actor.followers.as_ref() == Some(address) { - Recipients::Followers(local_actor.id.clone()) - } else if address == &local_actor.id { - continue; - } else { - Recipients::Actor(address.clone()) - }; - - if !recipients.contains(&recipient) { - recipients.push(recipient); - } - } - recipients -} - -fn reference_id(reference: &vocab::Reference) -> Option<&vocab::Iri> -where - T: HasId, -{ - match reference { - vocab::Reference::Id(id) => Some(id), - vocab::Reference::Object(object) => Some(object.id()), - } -} - -trait HasId { - fn id(&self) -> &vocab::Iri; -} - -impl HasId for vocab::Actor { - fn id(&self) -> &vocab::Iri { - &self.id - } -} - -/// Something entering the portable core from a runtime. -#[derive(Clone, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum Input { - ReceivedFollow(ReceivedFollow), - ReceivedUndoFollow(ReceivedUndoFollow), - UserCreateNote(UserCreateNote), -} - -/// 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-provided data for handling a received Undo of a Follow. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ReceivedUndoFollow { - pub undo: vocab::Undo, -} - -/// 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 UserCreateNote { - pub note_id: vocab::Iri, - pub create_id: vocab::Iri, - pub actor: vocab::Reference, - pub to: vocab::References, - pub cc: vocab::References, - pub content: String, - pub media_type: Option, - pub published: Option, - pub url: Option, -} - -impl Input { - pub fn received_follow(follow: vocab::Follow, accept_id: vocab::Iri) -> Self { - Self::ReceivedFollow(ReceivedFollow { follow, accept_id }) - } - - pub fn received_undo_follow(undo: vocab::Undo) -> Self { - Self::ReceivedUndoFollow(ReceivedUndoFollow { undo }) - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Follower { - pub follower: vocab::Iri, - pub following: vocab::Iri, -} - -/// Something the runtime should perform after core handling. -#[derive(Clone, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum Action { - StoreFollower(StoreFollower), - RemoveFollower(RemoveFollower), - StoreObject(StoreObject), - SendActivity(SendActivity), -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct StoreFollower { - pub follower: vocab::Reference, - pub following: vocab::Reference, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct RemoveFollower { - /// The remote actor ending the follower relation. - pub follower: vocab::Iri, - /// The local actor that was followed. - pub following: vocab::Iri, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct StoreObject { - pub object: Object, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct SendActivity { - pub activity: Activity, - pub recipients: Recipients, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum Recipients { - /// Deliver directly to this inbox. - Inbox(vocab::Iri), - /// Deliver to the current followers of this local actor. - Followers(vocab::Iri), - Actor(vocab::Iri), -} - -#[derive(Clone, Debug, Eq, PartialEq)] -#[non_exhaustive] -pub enum Activity { - Accept(vocab::Accept), - CreateNote(vocab::Create), - Follow(vocab::Follow), -} - -#[derive(Clone, Debug, Eq, PartialEq)] -#[non_exhaustive] -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 { - let mut local_actor = actor("https://example.com/users/alice"); - local_actor.followers = Some(iri("https://example.com/users/alice/followers")); - FederCore::new(FederConfig::new(local_actor)) - } - - fn received_follow(follow: vocab::Follow, id: &str) -> Input { - Input::ReceivedFollow(ReceivedFollow { - follow, - accept_id: iri(id), - }) - } - - fn received_undo_follow(follow: vocab::Follow, actor_id: &str) -> Input { - Input::received_undo_follow(vocab::Undo::new( - iri("https://remote.example/activities/undo/1"), - vocab::Reference::id(iri(actor_id)), - vocab::Reference::object(follow), - )) - } - - #[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().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(), 2); - assert_eq!( - core.state().followers(), - &[Follower { - follower: iri("https://remote.example/users/bob"), - following: iri("https://example.com/users/alice"), - }] - ); - 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")), - }) - ); - let Action::SendActivity(send) = &result.actions[1] else { - panic!("expected SendActivity action"); - }; - assert_eq!( - send.recipients, - Recipients::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_refreshes_the_stored_follower_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(), 2); - assert_eq!(second_result.actions.len(), 2); - assert_eq!( - second_result.actions[0], - Action::StoreFollower(StoreFollower { - follower: vocab::Reference::object({ - let mut actor = actor("https://remote.example/users/bob"); - actor.inbox = iri("https://remote.example/inboxes/bob"); - actor - }), - following: vocab::Reference::id(iri("https://example.com/users/alice")), - }) - ); - - let Action::SendActivity(send) = &second_result.actions[1] else { - panic!("expected SendActivity action"); - }; - assert_eq!( - send.recipients, - Recipients::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"), - }] - ); - } - - #[test] - fn received_follow_with_actor_id_records_follower_without_accept_delivery() { - 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"), - }] - ); - } - - #[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()); - } - - #[test] - fn received_undo_follow_removes_follower() { - 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.clone(), - "https://example.com/activities/accept/1", - )); - - let result = core.handle(received_undo_follow( - follow, - "https://remote.example/users/bob", - )); - - assert_eq!( - result.actions, - Vec::from([Action::RemoveFollower(RemoveFollower { - follower: iri("https://remote.example/users/bob"), - following: iri("https://example.com/users/alice"), - })]) - ); - assert!(core.state().followers().is_empty()); - } - - #[test] - fn received_undo_follow_rejects_actor_that_does_not_own_follow() { - 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.clone(), - "https://example.com/activities/accept/1", - )); - - let result = core.handle(received_undo_follow( - follow, - "https://remote.example/users/mallory", - )); - - assert!(result.is_empty()); - assert_eq!(core.state().followers().len(), 1); - } - - #[test] - fn received_undo_follow_emits_idempotent_removal_action() { - 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 mut core = core(); - - let result = core.handle(received_undo_follow( - follow, - "https://remote.example/users/bob", - )); - - assert_eq!( - result.actions, - Vec::from([Action::RemoveFollower(RemoveFollower { - follower: iri("https://remote.example/users/bob"), - following: iri("https://example.com/users/alice"), - })]) - ); - } - - #[test] - fn user_create_note_records_object_and_emits_followers_delivery() { - 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")), - to: vocab::References::one(iri("https://www.w3.org/ns/activitystreams#Public")), - cc: vocab::References::one(iri("https://example.com/users/alice/followers")), - content: "Hello from Feder.".to_string(), - media_type: Some("text/html".to_string()), - published: Some("2026-06-10T00:00:00Z".to_string()), - url: Some(iri("https://example.com/@alice/1")), - }; - - let mut core = core(); - let result = core.handle(Input::UserCreateNote(input)); - - assert_eq!(result.actions.len(), 2); - 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.to, - vocab::References::one(iri("https://www.w3.org/ns/activitystreams#Public")) - ); - assert_eq!( - note.cc, - vocab::References::one(iri("https://example.com/users/alice/followers")) - ); - assert_eq!(note.media_type.as_deref(), Some("text/html")); - assert_eq!(note.published, Some("2026-06-10T00:00:00Z".to_string())); - assert_eq!(note.url, Some(iri("https://example.com/@alice/1"))); - - 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")) - ); - assert_eq!(create.to, note.to); - assert_eq!(create.cc, note.cc); - } - Activity::Accept(_) | Activity::Follow(_) => { - panic!("expected Create activity") - } - } - - assert_eq!( - result.actions[0], - Action::StoreObject(StoreObject { - object: Object::Note(note.clone()), - }) - ); - let Action::SendActivity(send) = &result.actions[1] else { - panic!("expected followers delivery action"); - }; - assert_eq!( - send.recipients, - Recipients::Followers(iri("https://example.com/users/alice")) - ); - let Activity::CreateNote(create) = &send.activity else { - panic!("expected Create activity"); - }; - assert_eq!(create.id, iri("https://example.com/activities/create/1")); - 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 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(), 2); - assert!(matches!(follow_result.actions[0], Action::StoreFollower(_))); - let Action::SendActivity(accept_delivery) = &follow_result.actions[1] else { - panic!("expected Accept delivery action"); - }; - assert_eq!( - accept_delivery.recipients, - Recipients::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")), - to: vocab::References::new(), - cc: vocab::References::one(iri("https://example.com/users/alice/followers")), - content: "Hello from Feder.".to_string(), - media_type: None, - published: Some("2026-06-10T00:00:00Z".to_string()), - url: None, - })); - - 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 followers delivery action"); - }; - assert_eq!( - create_delivery.recipients, - Recipients::Followers(iri("https://example.com/users/alice")) - ); - assert!(matches!(create_delivery.activity, Activity::CreateNote(_))); - - assert_eq!(core.state().followers().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), - to: vocab::References::new(), - cc: vocab::References::new(), - content: "Hello from Feder.".to_string(), - media_type: None, - published: None, - url: None, - }; - - let mut core = core(); - let result = core.handle(Input::UserCreateNote(input)); - - assert_eq!(result.actions.len(), 1); - assert!(matches!(result.actions[0], Action::StoreObject(_))); - - 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_emits_one_direct_delivery_for_duplicate_actor_addresses() { - let bob = iri("https://remote.example/users/bob"); - 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")), - to: vocab::References::one(bob.clone()), - cc: vocab::References::one(bob.clone()), - content: "Hello Bob.".to_string(), - media_type: None, - published: None, - url: None, - }; - - let mut core = core(); - let result = core.handle(Input::UserCreateNote(input)); - - assert_eq!(result.actions.len(), 2); - assert!(matches!(result.actions[0], Action::StoreObject(_))); - let Action::SendActivity(send) = &result.actions[1] else { - panic!("expected direct delivery action"); - }; - assert_eq!(send.recipients, Recipients::Actor(bob)); - } - - #[test] - fn user_create_note_does_not_deliver_to_public_collection() { - 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")), - to: vocab::References::one(iri(PUBLIC_COLLECTION)), - cc: vocab::References::new(), - content: "Hello everyone.".to_string(), - media_type: None, - published: None, - url: None, - }; - - let mut core = core(); - let result = core.handle(Input::UserCreateNote(input)); - - assert_eq!(result.actions.len(), 1); - assert!(matches!(result.actions[0], Action::StoreObject(_))); - } - - #[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")), - to: vocab::References::new(), - cc: vocab::References::new(), - content: "Hello from elsewhere.".to_string(), - media_type: None, - published: Some("2026-06-10T00:00:00Z".to_string()), - url: None, - }; - - let mut core = core(); - let result = core.handle(Input::UserCreateNote(input)); +pub mod key; +pub mod note; +pub mod storage; +pub mod undo; - assert!(result.is_empty()); - assert!(core.state().objects().is_empty()); - assert!(core.state().activities().is_empty()); - } +pub trait ActorDispatcher { + type Error; - #[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")), - })])); + fn get_actor(&self, identifier: &str) -> Result, Self::Error>; - assert_eq!(result.actions.len(), 1); - } + fn get_actor_by_id(&self, actor_id: &Iri) -> Result, Self::Error>; } diff --git a/crates/ref-feder-core/src/note.rs b/crates/feder-core/src/note.rs similarity index 100% rename from crates/ref-feder-core/src/note.rs rename to crates/feder-core/src/note.rs diff --git a/crates/ref-feder-core/src/storage.rs b/crates/feder-core/src/storage.rs similarity index 100% rename from crates/ref-feder-core/src/storage.rs rename to crates/feder-core/src/storage.rs diff --git a/crates/ref-feder-core/src/undo.rs b/crates/feder-core/src/undo.rs similarity index 100% rename from crates/ref-feder-core/src/undo.rs rename to crates/feder-core/src/undo.rs diff --git a/crates/ref-feder-core/tests/common/mod.rs b/crates/feder-core/tests/common/mod.rs similarity index 100% rename from crates/ref-feder-core/tests/common/mod.rs rename to crates/feder-core/tests/common/mod.rs diff --git a/crates/ref-feder-core/tests/follow.rs b/crates/feder-core/tests/follow.rs similarity index 99% rename from crates/ref-feder-core/tests/follow.rs rename to crates/feder-core/tests/follow.rs index 5d06763..cb73453 100644 --- a/crates/ref-feder-core/tests/follow.rs +++ b/crates/feder-core/tests/follow.rs @@ -1,10 +1,10 @@ mod common; -use feder_vocab::{Accept, Follow, Reference}; -use ref_feder_core::follow::{ +use feder_core::follow::{ AcceptFollowError, FollowError, PendingFollow, create_follow, receive_accept_follow, receive_follow, }; +use feder_vocab::{Accept, Follow, Reference}; use common::{actor, iri}; diff --git a/crates/ref-feder-core/tests/key.rs b/crates/feder-core/tests/key.rs similarity index 98% rename from crates/ref-feder-core/tests/key.rs rename to crates/feder-core/tests/key.rs index cd79aa0..e0d94df 100644 --- a/crates/ref-feder-core/tests/key.rs +++ b/crates/feder-core/tests/key.rs @@ -1,4 +1,4 @@ -use ref_feder_core::key::{ +use feder_core::key::{ ActorKeyPair, KeyError, create_sha256_digest_header, sign_draft_cavage, verify_draft_cavage, }; diff --git a/crates/ref-feder-core/tests/note.rs b/crates/feder-core/tests/note.rs similarity index 99% rename from crates/ref-feder-core/tests/note.rs rename to crates/feder-core/tests/note.rs index ca10a2a..ed891c6 100644 --- a/crates/ref-feder-core/tests/note.rs +++ b/crates/feder-core/tests/note.rs @@ -1,9 +1,9 @@ mod common; -use feder_vocab::{Note, Reference, References}; -use ref_feder_core::note::{ +use feder_core::note::{ CreateNoteInput, NoteRecipient, PUBLIC_COLLECTION, create_note, is_public_note, }; +use feder_vocab::{Note, Reference, References}; use common::{actor, iri}; diff --git a/crates/ref-feder-core/tests/undo.rs b/crates/feder-core/tests/undo.rs similarity index 97% rename from crates/ref-feder-core/tests/undo.rs rename to crates/feder-core/tests/undo.rs index 8ed5f5a..82dca5c 100644 --- a/crates/ref-feder-core/tests/undo.rs +++ b/crates/feder-core/tests/undo.rs @@ -1,7 +1,7 @@ mod common; +use feder_core::undo::{UndoFollowError, receive_undo_follow}; use feder_vocab::{Follow, Reference, Undo}; -use ref_feder_core::undo::{UndoFollowError, receive_undo_follow}; use common::{actor, iri}; diff --git a/crates/feder-runtime-server/Cargo.toml b/crates/feder-runtime-server/Cargo.toml deleted file mode 100644 index 06a4aaf..0000000 --- a/crates/feder-runtime-server/Cargo.toml +++ /dev/null @@ -1,35 +0,0 @@ -[package] -name = "feder-runtime-server" -description = "Runnable server runtime for Feder on standard operating systems." -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -homepage.workspace = true -repository.workspace = true - -[dependencies] -feder-core = { workspace = true, features = ["http-signatures"] } -feder-vocab.workspace = true -iri-string.workspace = true -axum = "0.8" -httpdate = "1" -ipnet = "2.11.0" -mime = "0.3" -serde.workspace = true -thiserror = "2" -serde_json.workspace = true -percent-encoding = "2.3.2" -rand_core.workspace = true -reqwest = { version = "0.13.1", default-features = false, features = ["rustls"] } -rusqlite = { version = "0.40.1", features = ["bundled"] } -tokio = { version = "1", features = ["net"] } -url = "2" - -[dev-dependencies] -serde_json.workspace = true -tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "sync"] } -tower.workspace = true - -[lints] -workspace = true diff --git a/crates/feder-runtime-server/README.md b/crates/feder-runtime-server/README.md deleted file mode 100644 index a1da47c..0000000 --- a/crates/feder-runtime-server/README.md +++ /dev/null @@ -1,65 +0,0 @@ -Feder Runtime Server -==================== - -Reusable Axum/Tokio server integration for Feder. - -This crate builds an Axum router from caller-provided runtime configuration. -It provides a health check endpoint, WebFinger discovery, and a local actor -route with its followers collection. The caller chooses concrete bind -addresses, actor IRIs, usernames, and handle hosts. - -ActivityPub inbox handling for Follow and embedded Undo(Follow) activities is -included. The runtime can use in-memory storage for tests and examples, or -file-backed SQLite storage for persisted follower state. Outgoing -`SendActivity` actions are sent synchronously to recipient inboxes as -ActivityPub JSON signed with the actor's draft-Cavage RSA key. Incoming inbox -requests can require verification with the same signature scheme. - - -Platform support ----------------- - -This runtime currently targets Linux for development and deployment. Other -platforms may compile, but they are not currently supported. - -On Unix targets, file-backed SQLite databases are created with owner-only -permissions, and existing database files are restricted to owner-only -permissions when opened. This protects the actor signing keys stored in the -database. Equivalent Windows ACL hardening is not currently implemented. - - -Example -------- - -~~~~ rust -use feder_runtime_server::{InboxAuthPolicy, RuntimeConfig, StorageConfig, build_router}; - -let config = RuntimeConfig { - bind: "127.0.0.1:3000".parse().expect("valid bind address"), - actor_id: "http://127.0.0.1:3000/users/alice" - .parse() - .expect("valid actor IRI"), - inbox: "http://127.0.0.1:3000/users/alice/inbox" - .parse() - .expect("valid inbox IRI"), - outbox: "http://127.0.0.1:3000/users/alice/outbox" - .parse() - .expect("valid outbox IRI"), - username: "alice".to_string(), - handle_host: "127.0.0.1:3000".to_string(), - inbox_auth_policy: InboxAuthPolicy::AllowUnsignedInsecureDev, - storage: StorageConfig::InMemory, -}; - -let app = build_router(config).expect("build router"); -~~~~ - - -Demo ----- - -A runnable single-user demo lives in `examples/single-user-server`: - -~~~~ sh -RUST_LOG=info cargo run -p single-user-server -~~~~ diff --git a/crates/feder-runtime-server/src/actor.rs b/crates/feder-runtime-server/src/actor.rs deleted file mode 100644 index c74fa56..0000000 --- a/crates/feder-runtime-server/src/actor.rs +++ /dev/null @@ -1,261 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use axum::{ - Json, - extract::{Path, State}, - http::{HeaderMap, StatusCode, header}, - response::{IntoResponse, Response}, -}; -use feder_vocab::{Actor, ActorType, CryptographicKey, Endpoints, Iri, Reference}; -use reqwest::{ - Client, StatusCode as HttpStatusCode, Url, - header::{ACCEPT, CONTENT_TYPE}, -}; -use serde::Deserialize; - -use crate::{app::AppState, config::OutboundAddressPolicy, negotiation::accepts_activitypub, url}; - -const MAX_ACTOR_BODY_SIZE: usize = 1_048_576; -const ACTIVITYPUB_ACCEPT: &str = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""; - -pub async fn actor( - State(app_state): State, - Path(username): Path, - headers: HeaderMap, -) -> Result { - if username != app_state.username { - return Err(StatusCode::NOT_FOUND); - } - if !accepts_activitypub(&headers) { - return Ok(([(header::VARY, "Accept")], StatusCode::NOT_ACCEPTABLE).into_response()); - } - let local_actor = app_state.local_actor.clone(); - - Ok(( - [ - (header::CONTENT_TYPE, "application/activity+json"), - (header::VARY, "Accept"), - ], - Json(local_actor), - ) - .into_response()) -} - -/// Resolves remote ActivityPub actors for runtime protocol handling. -#[derive(Clone, Debug)] -pub struct ActorResolver { - client: Client, - address_policy: OutboundAddressPolicy, -} - -impl ActorResolver { - pub fn new(address_policy: OutboundAddressPolicy) -> Result { - let client = url::build_client(address_policy).map_err(ActorResolveError::BuildClient)?; - Ok(Self { - client, - address_policy, - }) - } - - pub async fn resolve_reference( - &self, - reference: &mut Reference, - ) -> Result<(), ActorResolveError> { - let Reference::Id(actor_id) = reference else { - return Ok(()); - }; - let actor_id = actor_id.clone(); - let actor = self.resolve(&actor_id).await?; - *reference = Reference::object(actor); - Ok(()) - } - - pub async fn resolve(&self, actor_id: &Iri) -> Result { - let body = self.fetch_document(actor_id).await?; - let document: ActorDocument = - serde_json::from_slice(&body).map_err(ActorResolveError::Deserialize)?; - let actor = document.into_actor(); - if actor.id != *actor_id { - return Err(ActorResolveError::ActorIdMismatch { - requested: actor_id.to_string(), - returned: actor.id.to_string(), - }); - } - - Ok(actor) - } - - pub(crate) async fn resolve_key( - &self, - key_id: &Iri, - ) -> Result { - let body = self.fetch_document(key_id).await?; - if let Ok(key) = serde_json::from_slice::(&body) - && key.id == *key_id - { - return Ok(key); - } - if let Ok(document) = serde_json::from_slice::(&body) - && let Some(Reference::Object(key)) = document.public_key - && key.id == *key_id - { - return Ok(*key); - } - - Err(ActorResolveError::KeyNotFound(key_id.to_string())) - } - - async fn fetch_document(&self, resource_id: &Iri) -> Result, ActorResolveError> { - let url = Url::parse(resource_id.as_str()) - .map_err(|_| ActorResolveError::InvalidResourceId(resource_id.to_string()))?; - if !matches!(url.scheme(), "http" | "https") - || !url.username().is_empty() - || url.password().is_some() - || url.host().is_none() - { - return Err(ActorResolveError::InvalidResourceId( - resource_id.to_string(), - )); - } - url::validate_literal_host(&url, self.address_policy).map_err(|address| { - ActorResolveError::PrivateResourceAddress { - resource: resource_id.to_string(), - address, - } - })?; - - let mut response = self - .client - .get(url) - .header(ACCEPT, ACTIVITYPUB_ACCEPT) - .send() - .await - .map_err(ActorResolveError::Request)?; - if !response.status().is_success() { - return Err(ActorResolveError::UnsuccessfulStatus { - resource: resource_id.to_string(), - status: response.status(), - }); - } - let content_type = response - .headers() - .get(CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or(""); - if !is_activitypub_content_type(content_type) { - return Err(ActorResolveError::UnsupportedContentType( - content_type.to_string(), - )); - } - if response - .content_length() - .is_some_and(|length| length > MAX_ACTOR_BODY_SIZE as u64) - { - return Err(ActorResolveError::ResponseTooLarge); - } - - let mut body = Vec::new(); - while let Some(chunk) = response.chunk().await.map_err(ActorResolveError::Request)? { - if body.len().saturating_add(chunk.len()) > MAX_ACTOR_BODY_SIZE { - return Err(ActorResolveError::ResponseTooLarge); - } - body.extend_from_slice(&chunk); - } - - Ok(body) - } -} - -fn is_activitypub_content_type(content_type: &str) -> bool { - let media_type = content_type - .split_once(';') - .map_or(content_type, |(media_type, _)| media_type) - .trim(); - media_type.eq_ignore_ascii_case("application/activity+json") - || media_type.eq_ignore_ascii_case("application/ld+json") -} - -#[derive(Deserialize)] -struct ActorDocument { - #[serde(rename = "type")] - kind: ActorType, - id: Iri, - inbox: Iri, - outbox: Iri, - followers: Option, - #[serde(rename = "preferredUsername")] - preferred_username: Option, - name: Option, - endpoints: Option, - #[serde(rename = "publicKey")] - public_key: Option>, -} - -impl ActorDocument { - fn into_actor(self) -> Actor { - Actor { - context: None, - kind: self.kind, - id: self.id, - inbox: self.inbox, - outbox: self.outbox, - followers: self.followers, - preferred_username: self.preferred_username, - name: self.name, - endpoints: self.endpoints, - public_key: self.public_key, - } - } -} - -#[derive(Debug, thiserror::Error)] -pub enum ActorResolveError { - #[error("failed to build actor resolution HTTP client")] - BuildClient(#[source] reqwest::Error), - - #[error("invalid remote ActivityPub resource ID: {0}")] - InvalidResourceId(String), - - #[error("remote ActivityPub resource {resource} uses non-public address {address}")] - PrivateResourceAddress { - resource: String, - address: std::net::IpAddr, - }, - - #[error("failed to fetch remote ActivityPub resource")] - Request(#[source] reqwest::Error), - - #[error("fetching remote ActivityPub resource {resource} returned {status}")] - UnsuccessfulStatus { - resource: String, - status: HttpStatusCode, - }, - - #[error("remote actor response has unsupported content type: {0}")] - UnsupportedContentType(String), - - #[error("remote actor response exceeds size limit")] - ResponseTooLarge, - - #[error("failed to deserialize remote actor")] - Deserialize(#[source] serde_json::Error), - - #[error("remote actor ID mismatch: requested {requested}, returned {returned}")] - ActorIdMismatch { requested: String, returned: String }, - - #[error("remote ActivityPub document does not contain key {0}")] - KeyNotFound(String), -} diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs deleted file mode 100644 index db6b22c..0000000 --- a/crates/feder-runtime-server/src/app.rs +++ /dev/null @@ -1,125 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use std::sync::{Arc, Mutex}; - -use crate::Error; -use crate::actor::ActorResolver; -use crate::config::{InboxAuthPolicy, RuntimeConfig, StorageConfig}; -use crate::followers::followers; -use crate::object::get_object; -use crate::send::ActivitySender; -use crate::storage::{RuntimeStore, SqliteStore}; -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, - http_signatures::{ActorKeyPair, generate_actor_key_pair}, -}; -use feder_vocab::{Actor, CryptographicKey, Reference}; -use iri_string::types::IriFragmentStr; -use rand_core::OsRng; - -#[derive(Clone)] -pub struct AppState { - pub core: Arc>, - pub store: Arc>, - pub actor_key_pair: Arc, - pub actor_resolver: ActorResolver, - pub activity_sender: ActivitySender, - pub local_actor: Actor, - pub username: String, - pub handle_host: String, - pub inbox_auth_policy: InboxAuthPolicy, -} - -impl AppState { - pub fn from_config(config: RuntimeConfig) -> Result { - let mut actor = Actor::person(config.actor_id, config.inbox, config.outbox); - actor.preferred_username = Some(config.username.clone()); - actor.name = Some(config.username.clone()); - actor.followers = Some( - format!("{}/followers", actor.id.as_str().trim_end_matches('/')) - .parse() - .expect("appending a followers path preserves a valid actor IRI"), - ); - - let mut store = match &config.storage { - StorageConfig::InMemory => SqliteStore::open_in_memory()?, - StorageConfig::Sqlite { path } => SqliteStore::open(path)?, - }; - let actor_key_pair = match store.load_actor_key_pair(&actor.id)? { - Some(key_pair) => key_pair, - None => { - let key_pair = generate_actor_key_pair(&mut OsRng)?; - store.insert_actor_key_pair(&actor.id, &key_pair)?; - key_pair - } - }; - let mut key_id = actor.id.clone(); - key_id.set_fragment(Some( - IriFragmentStr::new("main-key").expect("main-key is a valid IRI fragment"), - )); - actor.set_public_key(Reference::object(CryptographicKey::new( - key_id.clone(), - actor.id.clone(), - actor_key_pair.public_key_pem().to_string(), - ))); - let core = FederCore::new(FederConfig::new(actor.clone())); - let actor_key_pair = Arc::new(actor_key_pair); - let actor_resolver = ActorResolver::new(config.outbound_address_policy)?; - let activity_sender = ActivitySender::new( - actor_key_pair.clone(), - key_id.to_string(), - config.outbound_address_policy, - )?; - - Ok(Self { - core: Arc::new(Mutex::new(core)), - store: Arc::new(Mutex::new(store)), - actor_key_pair, - actor_resolver, - activity_sender, - local_actor: actor, - username: config.username, - handle_host: config.handle_host, - inbox_auth_policy: config.inbox_auth_policy, - }) - } -} - -pub fn build_router(config: RuntimeConfig) -> Result { - let state = AppState::from_config(config)?; - - Ok(router_with_state(state)) -} - -pub fn router_with_state(state: AppState) -> Router { - Router::new() - .route("/healthz", get(healthz)) - .route("/.well-known/webfinger", get(webfinger)) - .route("/users/{username}", get(actor)) - .route("/users/{username}/followers", get(followers)) - .route("/users/{username}/posts/{id}", get(get_object)) - .route("/users/{username}/inbox", post(inbox)) - .layer(DefaultBodyLimit::max(1_048_576)) - .with_state(state) -} - -async fn healthz() -> StatusCode { - StatusCode::NO_CONTENT -} diff --git a/crates/feder-runtime-server/src/config.rs b/crates/feder-runtime-server/src/config.rs deleted file mode 100644 index 8ee8725..0000000 --- a/crates/feder-runtime-server/src/config.rs +++ /dev/null @@ -1,53 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use std::{net::SocketAddr, path::PathBuf}; - -use feder_vocab::Iri; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum InboxAuthPolicy { - RequireSigned, - AllowUnsignedInsecureDev, -} - -/// Controls which network addresses may receive outgoing activities. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub enum OutboundAddressPolicy { - /// Allows only publicly routable destination addresses. - #[default] - PublicOnly, - - /// Allows private and special-use destinations. This disables SSRF protection. - AllowPrivateAddress, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum StorageConfig { - InMemory, - Sqlite { path: PathBuf }, -} - -pub struct RuntimeConfig { - pub bind: SocketAddr, - pub actor_id: Iri, - pub inbox: Iri, - pub outbox: Iri, - pub username: String, - pub handle_host: String, - pub inbox_auth_policy: InboxAuthPolicy, - pub outbound_address_policy: OutboundAddressPolicy, - pub storage: StorageConfig, -} diff --git a/crates/feder-runtime-server/src/error.rs b/crates/feder-runtime-server/src/error.rs deleted file mode 100644 index b0f8ec3..0000000 --- a/crates/feder-runtime-server/src/error.rs +++ /dev/null @@ -1,41 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("runtime core state is unavailable")] - CoreStateUnavailable, - - #[error("runtime storage state is unavailable")] - StorageStateUnavailable, - - #[error("failed to bind server socket")] - Bind(#[source] std::io::Error), - - #[error("server failed")] - Serve(#[source] std::io::Error), - - #[error("storage failed")] - Storage(#[from] crate::storage::StoreError), - - #[error("actor key generation failed")] - ActorKeyGeneration(#[from] feder_core::http_signatures::KeyError), - - #[error("activity sending failed")] - ActivitySender(#[from] crate::send::SendError), - - #[error("actor resolver setup failed")] - ActorResolver(#[from] crate::actor::ActorResolveError), -} diff --git a/crates/feder-runtime-server/src/followers.rs b/crates/feder-runtime-server/src/followers.rs deleted file mode 100644 index b42ac7e..0000000 --- a/crates/feder-runtime-server/src/followers.rs +++ /dev/null @@ -1,66 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use axum::{ - Json, - extract::{Path, State}, - http::{HeaderMap, StatusCode, header}, - response::{IntoResponse, Response}, -}; -use feder_vocab::OrderedCollection; - -use crate::{app::AppState, negotiation::accepts_activitypub, storage::RuntimeStore}; - -/// Return the local actor's followers as a one-shot ordered collection. -pub async fn followers( - State(app_state): State, - Path(username): Path, - headers: HeaderMap, -) -> Result { - if username != app_state.username { - return Err(StatusCode::NOT_FOUND); - } - if !accepts_activitypub(&headers) { - return Ok(([(header::VARY, "Accept")], StatusCode::NOT_ACCEPTABLE).into_response()); - } - - let followers = app_state - .store - .lock() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .list_followers(&app_state.local_actor.id) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let total_items = - u64::try_from(followers.len()).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let ordered_items = followers - .into_iter() - .map(|follower| follower.follower) - .collect(); - let collection_id = app_state - .local_actor - .followers - .clone() - .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; - let collection = OrderedCollection::new(collection_id, total_items, ordered_items); - - Ok(( - [ - (header::CONTENT_TYPE, "application/activity+json"), - (header::VARY, "Accept"), - ], - Json(collection), - ) - .into_response()) -} diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs deleted file mode 100644 index 47fcc07..0000000 --- a/crates/feder-runtime-server/src/inbox.rs +++ /dev/null @@ -1,502 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use std::{ - collections::{BTreeMap, HashSet}, - time::{Duration, SystemTime}, -}; - -use axum::{ - body::Bytes, - extract::{Path, State}, - http::{ - HeaderMap, Method, StatusCode, Uri, - header::{CONTENT_TYPE, HOST}, - uri::Authority, - }, - response::{IntoResponse, Response}, -}; - -use feder_core::{ - Input, - http_signatures::{create_sha256_digest_header, verify_draft_cavage}, -}; -use feder_vocab::{Actor, Follow, Iri, Reference, Undo}; -use mime::Mime; -use serde_json::{Value, from_slice, from_value}; - -use crate::config::InboxAuthPolicy; -use crate::send::SendError; -use crate::{Error, app::AppState}; - -const MAX_SIGNATURE_AGE: Duration = Duration::from_secs(65 * 60); -const MAX_CLOCK_SKEW: Duration = Duration::from_secs(60 * 60); -const ACTIVITYPUB_CONTENT_TYPES: &[&str] = &["application/activity+json", "application/ld+json"]; - -pub struct InboxRequest { - pub username: String, - pub headers: HeaderMap, - pub method: Method, - pub uri: Uri, - pub body: Bytes, -} - -fn accept_id_for_follow( - local_actor_id: &feder_vocab::Iri, - follow_id: &feder_vocab::Iri, -) -> Result { - let encoded_follow_id = percent_encoding::utf8_percent_encode( - follow_id.as_str(), - percent_encoding::NON_ALPHANUMERIC, - ); - - format!("{local_actor_id}#accepts/{encoded_follow_id}") - .parse() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) -} - -async fn verify_inbox_request( - app_state: &AppState, - req: &InboxRequest, - activity_actor_id: Option<&Iri>, -) -> Result, StatusCode> { - match app_state.inbox_auth_policy { - InboxAuthPolicy::AllowUnsignedInsecureDev => Ok(None), - InboxAuthPolicy::RequireSigned => { - let activity_actor_id = activity_actor_id.ok_or(StatusCode::UNAUTHORIZED)?; - verify_signed_request(app_state, req, activity_actor_id) - .await - .map(Some) - } - } -} - -async fn verify_signed_request( - app_state: &AppState, - req: &InboxRequest, - activity_actor_id: &Iri, -) -> Result { - let signature_header = req - .headers - .get("signature") - .and_then(|value| value.to_str().ok()) - .ok_or(StatusCode::UNAUTHORIZED)?; - let signature = parse_signature_header(signature_header).ok_or(StatusCode::UNAUTHORIZED)?; - if signature.algorithm != "rsa-sha256" - || signature.signed_headers.first().map(String::as_str) != Some("(request-target)") - { - return Err(StatusCode::UNAUTHORIZED); - } - - let mut seen_headers = HashSet::new(); - let mut signed_headers = Vec::new(); - for name in signature.signed_headers.iter().skip(1) { - if name.starts_with('(') || !seen_headers.insert(name.as_str()) { - return Err(StatusCode::UNAUTHORIZED); - } - let values = req.headers.get_all(name).iter().collect::>(); - let [value] = values.as_slice() else { - return Err(StatusCode::UNAUTHORIZED); - }; - let value = value.to_str().map_err(|_| StatusCode::UNAUTHORIZED)?; - signed_headers.push((name.as_str(), value)); - } - if !["host", "date", "digest"] - .iter() - .all(|required| seen_headers.contains(required)) - { - return Err(StatusCode::UNAUTHORIZED); - } - - verify_request_host( - &req.headers, - &app_state.handle_host, - app_state.local_actor.inbox.scheme_str(), - )?; - verify_request_date(&req.headers)?; - verify_request_digest(&req.headers, &req.body)?; - - let key_id: Iri = signature - .key_id - .parse() - .map_err(|_| StatusCode::UNAUTHORIZED)?; - let public_key = app_state - .actor_resolver - .resolve_key(&key_id) - .await - .map_err(|_| StatusCode::BAD_GATEWAY)?; - if public_key.owner != *activity_actor_id { - return Err(StatusCode::UNAUTHORIZED); - } - - let request_target = req - .uri - .path_and_query() - .map_or(req.uri.path(), |value| value.as_str()); - verify_draft_cavage( - &public_key.public_key_pem, - req.method.as_str(), - request_target, - &signed_headers, - &signature.signature, - ) - .map_err(|_| StatusCode::UNAUTHORIZED)?; - - let actor = app_state - .actor_resolver - .resolve(activity_actor_id) - .await - .map_err(|_| StatusCode::BAD_GATEWAY)?; - let actor_owns_key = match actor.public_key.as_ref() { - Some(Reference::Id(advertised_key_id)) => advertised_key_id == &public_key.id, - Some(Reference::Object(advertised_key)) => { - advertised_key.id == public_key.id - && advertised_key.owner == actor.id - && advertised_key.public_key_pem == public_key.public_key_pem - } - None => false, - }; - if !actor_owns_key { - return Err(StatusCode::UNAUTHORIZED); - } - - Ok(actor) -} - -fn verify_request_host( - headers: &HeaderMap, - expected_host: &str, - inbox_scheme: &str, -) -> Result<(), StatusCode> { - let signed_host = headers - .get(HOST) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.parse::().ok()) - .filter(|authority| !authority.as_str().contains('@')) - .ok_or(StatusCode::UNAUTHORIZED)?; - let expected_host = expected_host - .parse::() - .ok() - .filter(|authority| !authority.as_str().contains('@')) - .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; - let default_port = if inbox_scheme.eq_ignore_ascii_case("http") { - Some(80) - } else if inbox_scheme.eq_ignore_ascii_case("https") { - Some(443) - } else { - None - }; - let signed_port = effective_port(&signed_host, default_port).ok_or(StatusCode::UNAUTHORIZED)?; - let expected_port = - effective_port(&expected_host, default_port).ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; - - if signed_host - .host() - .eq_ignore_ascii_case(expected_host.host()) - && signed_port == expected_port - { - Ok(()) - } else { - Err(StatusCode::UNAUTHORIZED) - } -} - -fn effective_port(authority: &Authority, default_port: Option) -> Option> { - let suffix = authority.as_str().get(authority.host().len()..)?; - if suffix.is_empty() { - Some(default_port) - } else if suffix.starts_with(':') { - authority.port_u16().map(Some) - } else { - None - } -} - -fn activity_actor_id(value: &Value) -> Option { - let actor = value.get("actor")?; - let actor_id = actor - .as_str() - .or_else(|| actor.get("id").and_then(Value::as_str))?; - actor_id.parse().ok() -} - -fn actor_reference_id(reference: &Reference) -> &Iri { - match reference { - Reference::Id(actor_id) => actor_id, - Reference::Object(actor) => &actor.id, - } -} - -fn verify_request_date(headers: &HeaderMap) -> Result<(), StatusCode> { - let date = headers - .get("date") - .and_then(|value| value.to_str().ok()) - .ok_or(StatusCode::UNAUTHORIZED) - .and_then(|value| httpdate::parse_http_date(value).map_err(|_| StatusCode::UNAUTHORIZED))?; - let now = SystemTime::now(); - if now - .duration_since(date) - .is_ok_and(|age| age > MAX_SIGNATURE_AGE) - || date - .duration_since(now) - .is_ok_and(|skew| skew > MAX_CLOCK_SKEW) - { - return Err(StatusCode::UNAUTHORIZED); - } - - Ok(()) -} - -fn verify_request_digest(headers: &HeaderMap, body: &[u8]) -> Result<(), StatusCode> { - let digest = headers - .get("digest") - .and_then(|value| value.to_str().ok()) - .ok_or(StatusCode::UNAUTHORIZED)?; - let expected = create_sha256_digest_header(body); - let matches = digest.split(',').any(|entry| { - entry - .trim() - .split_once('=') - .is_some_and(|(algorithm, value)| { - algorithm.eq_ignore_ascii_case("sha-256") - && expected - .split_once('=') - .is_some_and(|(_, expected)| value == expected) - }) - }); - - if matches { - Ok(()) - } else { - Err(StatusCode::UNAUTHORIZED) - } -} - -struct ParsedSignature { - key_id: String, - algorithm: String, - signed_headers: Vec, - signature: String, -} - -fn parse_signature_header(header: &str) -> Option { - let mut parameters = BTreeMap::new(); - let mut remaining = header; - while !remaining.trim_start().is_empty() { - remaining = remaining.trim_start(); - let equals = remaining.find('=')?; - let name = remaining[..equals].trim(); - if name.is_empty() - || !name - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) - { - return None; - } - remaining = &remaining[equals + 1..]; - let (value, rest) = parse_quoted_parameter(remaining.trim_start())?; - if parameters - .insert(name.to_ascii_lowercase(), value) - .is_some() - { - return None; - } - remaining = rest.trim_start(); - if remaining.is_empty() { - break; - } - remaining = remaining.strip_prefix(',')?; - } - - let key_id = parameters.remove("keyid")?; - let algorithm = parameters.remove("algorithm")?; - let signed_headers = parameters - .remove("headers")? - .split_ascii_whitespace() - .map(str::to_ascii_lowercase) - .collect::>(); - let signature = parameters.remove("signature")?; - if key_id.is_empty() || signed_headers.is_empty() || signature.is_empty() { - return None; - } - - Some(ParsedSignature { - key_id, - algorithm: algorithm.to_ascii_lowercase(), - signed_headers, - signature, - }) -} - -fn parse_quoted_parameter(input: &str) -> Option<(String, &str)> { - let input = input.strip_prefix('"')?; - let mut value = String::new(); - let mut escaped = false; - for (index, character) in input.char_indices() { - if escaped { - value.push(character); - escaped = false; - } else if character == '\\' { - escaped = true; - } else if character == '"' { - return Some((value, &input[index + character.len_utf8()..])); - } else if character.is_control() { - return None; - } else { - value.push(character); - } - } - - None -} - -pub async fn inbox( - State(app_state): State, - Path(username): Path, - headers: HeaderMap, - method: Method, - uri: Uri, - body: Bytes, -) -> Result { - if username != app_state.username { - return Err(StatusCode::NOT_FOUND); - } - let content_type = headers - .get(CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.parse::().ok()); - - if !content_type - .is_some_and(|media_type| ACTIVITYPUB_CONTENT_TYPES.contains(&media_type.essence_str())) - { - return Err(StatusCode::UNSUPPORTED_MEDIA_TYPE); - } - - let req = InboxRequest { - username, - headers, - method, - uri, - body, - }; - - let value: Value = from_slice(&req.body).map_err(|_| StatusCode::BAD_REQUEST)?; - let activity_actor_id = activity_actor_id(&value); - let verified_actor = verify_inbox_request(&app_state, &req, activity_actor_id.as_ref()).await?; - - let activity_type = value.get("type").and_then(|value| value.as_str()); - - let input = match activity_type { - Some("Follow") => { - let mut follow: Follow = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; - if actor_reference_id(&follow.object) != &app_state.local_actor.id { - return Ok(StatusCode::ACCEPTED.into_response()); - } - if let Some(actor) = verified_actor { - if actor_reference_id(&follow.actor) != &actor.id { - return Err(StatusCode::UNAUTHORIZED); - } - follow.actor = Reference::object(actor); - } else { - app_state - .actor_resolver - .resolve_reference(&mut follow.actor) - .await - .map_err(|_| StatusCode::BAD_GATEWAY)?; - } - let accept_id = accept_id_for_follow(&app_state.local_actor.id, &follow.id)?; - Input::received_follow(follow, accept_id) - } - Some("Undo") => { - if value - .get("object") - .and_then(|object| object.get("type")) - .and_then(Value::as_str) - != Some("Follow") - { - return Ok(StatusCode::ACCEPTED.into_response()); - } - let undo: Undo = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; - let Reference::Object(follow) = &undo.object else { - return Ok(StatusCode::ACCEPTED.into_response()); - }; - let undo_actor_id = actor_reference_id(&undo.actor); - if undo_actor_id != actor_reference_id(&follow.actor) { - return Err(StatusCode::UNAUTHORIZED); - } - if verified_actor - .as_ref() - .is_some_and(|actor| undo_actor_id != &actor.id) - { - return Err(StatusCode::UNAUTHORIZED); - } - if actor_reference_id(&follow.object) != &app_state.local_actor.id { - return Ok(StatusCode::ACCEPTED.into_response()); - } - Input::received_undo_follow(undo) - } - // Unsupported activity types will be ignored. - _ => return Ok(StatusCode::ACCEPTED.into_response()), - }; - - app_state - .handle_input(input) - .await - .map_err(|error| match error { - Error::ActivitySender( - SendError::PrivateInboxAddress { .. } - | SendError::Request(_) - | SendError::UnsuccessfulStatus { .. }, - ) => StatusCode::BAD_GATEWAY, - _ => StatusCode::INTERNAL_SERVER_ERROR, - })?; - - Ok(StatusCode::ACCEPTED.into_response()) -} - -#[cfg(test)] -mod tests { - use super::*; - use axum::http::HeaderValue; - - fn headers_with_host(host: &'static str) -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert(HOST, HeaderValue::from_static(host)); - headers - } - - #[test] - fn request_host_accepts_case_and_default_port_equivalence() { - assert_eq!( - verify_request_host( - &headers_with_host("EXAMPLE.COM:443"), - "example.com", - "https" - ), - Ok(()) - ); - } - - #[test] - fn request_host_rejects_wrong_or_invalid_authorities() { - for host in ["other.example", "example.com:8443", "example.com:99999"] { - assert_eq!( - verify_request_host(&headers_with_host(host), "example.com", "https"), - Err(StatusCode::UNAUTHORIZED), - "Host: {host}" - ); - } - } -} diff --git a/crates/feder-runtime-server/src/lib.rs b/crates/feder-runtime-server/src/lib.rs deleted file mode 100644 index 9fa5339..0000000 --- a/crates/feder-runtime-server/src/lib.rs +++ /dev/null @@ -1,33 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -pub mod actor; -pub mod app; -pub mod config; -pub mod error; -pub mod followers; -pub mod inbox; -mod negotiation; -pub mod object; -mod operation; -pub mod send; -pub mod storage; -mod url; -pub mod webfinger; - -pub use actor::{ActorResolveError, ActorResolver}; -pub use app::{AppState, build_router}; -pub use config::{InboxAuthPolicy, OutboundAddressPolicy, RuntimeConfig, StorageConfig}; -pub use error::Error; diff --git a/crates/feder-runtime-server/src/negotiation.rs b/crates/feder-runtime-server/src/negotiation.rs deleted file mode 100644 index 0ab2177..0000000 --- a/crates/feder-runtime-server/src/negotiation.rs +++ /dev/null @@ -1,194 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use axum::http::{HeaderMap, header::ACCEPT}; -use mime::Mime; - -const ACTIVITYPUB_MEDIA_TYPES: &[&str] = &[ - "application/activity+json", - "application/ld+json", - "application/json", -]; -const HTML_MEDIA_TYPES: &[&str] = &["text/html", "application/xhtml+xml"]; - -struct MediaRange { - media_type: Mime, - quality: u16, - order: usize, -} - -#[derive(Clone, Copy)] -struct Preference { - quality: u16, - order: usize, -} - -pub(crate) fn accepts_activitypub(headers: &HeaderMap) -> bool { - let ranges = headers - .get_all(ACCEPT) - .iter() - .filter_map(|value| value.to_str().ok()) - .flat_map(|value| value.split(',')) - .filter_map(|value| value.trim().parse::().ok()) - .enumerate() - .filter_map(|(order, media_range)| { - let quality = media_range - .get_param("q") - .map_or(Some(1000), |value| parse_quality(value.as_str()))?; - (quality > 0).then_some(MediaRange { - media_type: media_range, - quality, - order, - }) - }) - .collect::>(); - - let activitypub = preferred(ACTIVITYPUB_MEDIA_TYPES, &ranges); - let html = preferred(HTML_MEDIA_TYPES, &ranges); - - match (activitypub, html) { - (Some(activitypub), Some(html)) => prefers(activitypub, html), - (Some(_), None) => true, - _ => false, - } -} - -fn preferred(media_types: &[&str], ranges: &[MediaRange]) -> Option { - ranges - .iter() - .filter(|range| media_types.contains(&range.media_type.essence_str())) - .map(|range| Preference { - quality: range.quality, - order: range.order, - }) - .reduce(|current, candidate| { - if prefers(candidate, current) { - candidate - } else { - current - } - }) -} - -fn prefers(left: Preference, right: Preference) -> bool { - left.quality > right.quality || (left.quality == right.quality && left.order < right.order) -} - -fn parse_quality(value: &str) -> Option { - let (whole, fraction) = value.split_once('.').unwrap_or((value, "")); - if fraction.len() > 3 || !fraction.bytes().all(|byte| byte.is_ascii_digit()) { - return None; - } - - match whole { - "0" => { - let padding = 3 - fraction.len(); - let fraction = fraction.parse::().unwrap_or(0); - Some(fraction * 10_u16.pow(u32::try_from(padding).ok()?)) - } - "1" if fraction.bytes().all(|byte| byte == b'0') => Some(1000), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use axum::http::HeaderValue; - - fn headers(accept: Option<&str>) -> HeaderMap { - let mut headers = HeaderMap::new(); - if let Some(accept) = accept { - headers.insert(ACCEPT, HeaderValue::from_str(accept).expect("valid header")); - } - headers - } - - #[test] - fn accepts_explicit_activitypub_media_types() { - for accept in [ - Some("application/activity+json"), - Some("application/ld+json"), - Some("application/json"), - ] { - assert!(accepts_activitypub(&headers(accept)), "Accept: {accept:?}"); - } - } - - #[test] - fn rejects_implicit_html_or_unsupported_media_types() { - for accept in [ - "", - "*/*", - "application/*", - "text/html", - "application/xhtml+xml", - "image/png", - "application/activity+json;q=0", - ] { - assert!( - !accepts_activitypub(&headers(Some(accept))), - "Accept: {accept}" - ); - } - assert!(!accepts_activitypub(&headers(None))); - } - - #[test] - fn respects_quality_and_order() { - assert_eq!(parse_quality("0.9"), Some(900)); - assert_eq!(parse_quality("0.08"), Some(80)); - assert_eq!(parse_quality("1.000"), Some(1000)); - assert!(!accepts_activitypub(&headers(Some( - "application/activity+json;q=0.5, text/html;q=0.8" - )))); - assert!(accepts_activitypub(&headers(Some( - "application/activity+json;q=0.9, text/html;q=0.8" - )))); - assert!(!accepts_activitypub(&headers(Some( - "text/html, application/activity+json" - )))); - assert!(accepts_activitypub(&headers(Some( - "application/activity+json, text/html" - )))); - assert!(!accepts_activitypub(&headers(Some("text/html, */*")))); - assert!(!accepts_activitypub(&headers(Some( - "application/activity+json;q=0, application/ld+json;q=0, application/json;q=0, */*;q=1" - )))); - } - - #[test] - fn ignores_unsupported_types_when_comparing_supported_representations() { - assert!(!accepts_activitypub(&headers(Some( - "image/png, application/activity+json;q=0.5, text/html;q=0.8" - )))); - assert!(accepts_activitypub(&headers(Some( - "image/png, application/activity+json;q=0.8, text/html;q=0.5" - )))); - assert!(accepts_activitypub(&headers(Some( - "image/png, application/activity+json;q=0.5" - )))); - } - - #[test] - fn compares_the_best_type_in_each_supported_representation() { - assert!(!accepts_activitypub(&headers(Some( - "text/html;q=0.2, application/xhtml+xml;q=0.9, application/activity+json;q=0.8" - )))); - assert!(accepts_activitypub(&headers(Some( - "text/html;q=0.8, application/activity+json;q=0.2, application/ld+json;q=0.9" - )))); - } -} diff --git a/crates/feder-runtime-server/src/object.rs b/crates/feder-runtime-server/src/object.rs deleted file mode 100644 index 0a282b5..0000000 --- a/crates/feder-runtime-server/src/object.rs +++ /dev/null @@ -1,84 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use axum::{ - Json, - extract::{Path, State}, - http::{HeaderMap, StatusCode, header}, - response::{IntoResponse, Response}, -}; -use feder_core::{Object, PUBLIC_COLLECTION}; -use feder_vocab::Iri; - -use crate::{app::AppState, negotiation::accepts_activitypub, storage::RuntimeStore}; - -/// Return a persisted local ActivityPub object. -pub async fn get_object( - State(app_state): State, - Path((username, post_id)): Path<(String, String)>, - headers: HeaderMap, -) -> Result { - if username != app_state.username { - return Err(StatusCode::NOT_FOUND); - } - - let object_id = note_id(&app_state.local_actor.id, &post_id)?; - let object = app_state - .store - .lock() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .load_object(&object_id) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .ok_or(StatusCode::NOT_FOUND)?; - let Object::Note(note) = object else { - return Err(StatusCode::NOT_FOUND); - }; - - if !note - .to - .iter() - .chain(note.cc.iter()) - .any(|recipient| recipient.as_str() == PUBLIC_COLLECTION) - { - return Err(StatusCode::NOT_FOUND); - } - if !accepts_activitypub(&headers) { - return Ok(([(header::VARY, "Accept")], StatusCode::NOT_ACCEPTABLE).into_response()); - } - - Ok(( - [ - (header::CONTENT_TYPE, "application/activity+json"), - (header::VARY, "Accept"), - ], - Json(note), - ) - .into_response()) -} - -fn note_id(actor_id: &Iri, post_id: &str) -> Result { - let mut url = - url::Url::parse(actor_id.as_str()).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - url.set_query(None); - url.set_fragment(None); - url.path_segments_mut() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? - .pop_if_empty() - .push("posts") - .push(post_id); - url.as_str() - .parse() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) -} diff --git a/crates/feder-runtime-server/src/operation.rs b/crates/feder-runtime-server/src/operation.rs deleted file mode 100644 index 59c6471..0000000 --- a/crates/feder-runtime-server/src/operation.rs +++ /dev/null @@ -1,129 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use std::collections::HashSet; - -use feder_core::{Action, HandleResult, Input, Recipients, SendActivity, UserCreateNote}; - -use crate::{Error, actor::ActorResolveError, app::AppState, storage::RuntimeStore}; - -impl AppState { - /// Create, persist, and deliver a Note initiated by the local application. - /// - /// Persistence occurs before delivery. Recipient resolution and delivery - /// continue independently after individual failures. If any attempt fails, - /// this returns an error while the created Note remains available from the - /// runtime store and successful deliveries remain completed. - pub async fn create_note(&self, input: UserCreateNote) -> Result { - self.handle_input(Input::UserCreateNote(input)).await - } - - pub(crate) async fn handle_input(&self, input: Input) -> Result { - let result = { - let mut core = self.core.lock().map_err(|_| Error::CoreStateUnavailable)?; - core.handle(input) - }; - { - let mut store = self - .store - .lock() - .map_err(|_| Error::StorageStateUnavailable)?; - store.persist_actions(&result.actions)?; - }; - let (deliveries, actor_resolve_error) = - self.resolve_outbound_deliveries(&result.actions).await?; - - self.activity_sender.send_actions(&deliveries).await?; - if let Some(error) = actor_resolve_error { - return Err(error.into()); - } - - Ok(result) - } - - async fn resolve_outbound_deliveries( - &self, - actions: &[Action], - ) -> Result<(Vec, Option), Error> { - let mut resolved = Vec::new(); - let mut first_actor_resolve_error = None; - let mut covered_actor_ids = HashSet::new(); - let mut seen_inboxes = HashSet::new(); - - // Expand followers first so direct recipients already covered by - // follower delivery are not also sent to their personal inbox. - for action in actions { - let Action::SendActivity(send) = action else { - continue; - }; - let Recipients::Followers(actor_id) = &send.recipients else { - continue; - }; - let recipients = { - let store = self - .store - .lock() - .map_err(|_| Error::StorageStateUnavailable)?; - store.list_follower_recipients(actor_id)? - }; - for recipient in recipients { - covered_actor_ids.insert(recipient.actor_id); - let inbox = recipient.shared_inbox.unwrap_or(recipient.inbox); - if seen_inboxes.insert(inbox.clone()) { - resolved.push(SendActivity { - activity: send.activity.clone(), - recipients: Recipients::Inbox(inbox), - }); - } - } - } - - for action in actions { - let Action::SendActivity(send) = action else { - continue; - }; - match &send.recipients { - Recipients::Inbox(inbox) => { - if seen_inboxes.insert(inbox.clone()) { - resolved.push(send.clone()); - } - } - Recipients::Followers(_) => {} - Recipients::Actor(actor_id) => { - if covered_actor_ids.contains(actor_id) { - continue; - } - match self.actor_resolver.resolve(actor_id).await { - Ok(actor) => { - covered_actor_ids.insert(actor_id.clone()); - if seen_inboxes.insert(actor.inbox.clone()) { - resolved.push(SendActivity { - activity: send.activity.clone(), - recipients: Recipients::Inbox(actor.inbox), - }); - } - } - Err(error) if first_actor_resolve_error.is_none() => { - first_actor_resolve_error = Some(error); - } - Err(_) => {} - } - } - } - } - - Ok((resolved, first_actor_resolve_error)) - } -} diff --git a/crates/feder-runtime-server/src/send.rs b/crates/feder-runtime-server/src/send.rs deleted file mode 100644 index 851598b..0000000 --- a/crates/feder-runtime-server/src/send.rs +++ /dev/null @@ -1,177 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use std::{sync::Arc, time::SystemTime}; - -use crate::{config::OutboundAddressPolicy, url}; -use feder_core::{ - Activity, Recipients, SendActivity, - http_signatures::{ - ActorKeyPair, HttpSignatureError, create_sha256_digest_header, sign_draft_cavage, - }, -}; -use reqwest::{ - Client, StatusCode, Url, - header::{CONTENT_TYPE, DATE, HOST}, -}; - -/// Sends core `SendActivity` actions as signed ActivityPub HTTP requests. -#[derive(Clone, Debug)] -pub struct ActivitySender { - client: Client, - key_pair: Arc, - key_id: String, - address_policy: OutboundAddressPolicy, -} - -impl ActivitySender { - /// Creates an activity sender for one actor identity. - pub fn new( - key_pair: Arc, - key_id: String, - address_policy: OutboundAddressPolicy, - ) -> Result { - let client = url::build_client(address_policy).map_err(SendError::BuildClient)?; - - Ok(Self { - client, - key_pair, - key_id, - address_policy, - }) - } - - /// Attempts every send action and returns the first error encountered. - pub async fn send_actions(&self, actions: &[SendActivity]) -> Result<(), SendError> { - let mut first_error = None; - - for action in actions { - if let Err(error) = self.send(action).await - && first_error.is_none() - { - first_error = Some(error); - } - } - - first_error.map_or(Ok(()), Err) - } - - async fn send(&self, send: &SendActivity) -> Result<(), SendError> { - let body = match &send.activity { - Activity::Accept(activity) => serde_json::to_vec(activity), - Activity::CreateNote(activity) => serde_json::to_vec(activity), - Activity::Follow(activity) => serde_json::to_vec(activity), - _ => return Err(SendError::UnsupportedActivity), - } - .map_err(SendError::Serialize)?; - let Recipients::Inbox(inbox) = &send.recipients else { - return Err(SendError::UnresolvedRecipients); - }; - let url = - Url::parse(inbox.as_str()).map_err(|_| SendError::InvalidInbox(inbox.to_string()))?; - if !matches!(url.scheme(), "http" | "https") { - return Err(SendError::InvalidInbox(inbox.to_string())); - } - crate::url::validate_literal_host(&url, self.address_policy).map_err(|address| { - SendError::PrivateInboxAddress { - inbox: inbox.to_string(), - address, - } - })?; - let mut host = url - .host() - .ok_or_else(|| SendError::InvalidInbox(inbox.to_string()))? - .to_string(); - if let Some(port) = url.port() { - host = format!("{host}:{port}"); - } - let date = httpdate::fmt_http_date(SystemTime::now()); - let digest = create_sha256_digest_header(&body); - let headers = [ - ("content-type", "application/activity+json"), - ("date", date.as_str()), - ("digest", digest.as_str()), - ("host", host.as_str()), - ]; - let mut request_target = url.path().to_string(); - if let Some(query) = url.query() { - request_target.push('?'); - request_target.push_str(query); - } - let signature = sign_draft_cavage( - &self.key_pair, - &self.key_id, - "POST", - &request_target, - &headers, - ) - .map_err(SendError::Sign)?; - - let response = self - .client - .post(url) - .header(CONTENT_TYPE, "application/activity+json") - .header(DATE, date) - .header("Digest", digest) - .header(HOST, host) - .header("Signature", signature) - .body(body) - .send() - .await - .map_err(SendError::Request)?; - - if !response.status().is_success() { - return Err(SendError::UnsuccessfulStatus { - inbox: inbox.to_string(), - status: response.status(), - }); - } - - Ok(()) - } -} - -#[derive(Debug, thiserror::Error)] -pub enum SendError { - #[error("failed to build HTTP client")] - BuildClient(#[source] reqwest::Error), - - #[error("failed to serialize activity")] - Serialize(#[source] serde_json::Error), - - #[error("invalid recipient inbox: {0}")] - InvalidInbox(String), - - #[error("recipient inbox {inbox} resolves to non-public address {address}")] - PrivateInboxAddress { - inbox: String, - address: std::net::IpAddr, - }, - - #[error("failed to sign activity request")] - Sign(#[source] HttpSignatureError), - - #[error("failed to send activity")] - Request(#[source] reqwest::Error), - - #[error("sending activity to {inbox} returned {status}")] - UnsuccessfulStatus { inbox: String, status: StatusCode }, - - #[error("activity type is not supported for sending")] - UnsupportedActivity, - - #[error("activity recipients must resolve to an inbox before sending")] - UnresolvedRecipients, -} diff --git a/crates/feder-runtime-server/src/storage/mod.rs b/crates/feder-runtime-server/src/storage/mod.rs deleted file mode 100644 index b3757c6..0000000 --- a/crates/feder-runtime-server/src/storage/mod.rs +++ /dev/null @@ -1,80 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -pub mod sqlite; - -use feder_core::{ - Action, Object, - http_signatures::{ActorKeyPair, KeyError}, -}; -use feder_vocab::Iri; -pub use sqlite::SqliteStore; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct StoredFollower { - pub follower: Iri, - pub following: Iri, - pub inbox: Option, - pub shared_inbox: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct StoredRecipient { - pub actor_id: Iri, - pub inbox: Iri, - pub shared_inbox: Option, -} - -#[derive(Debug, thiserror::Error)] -pub enum StoreError { - #[error("I/O error")] - Io(#[from] std::io::Error), - - #[error("sqlite error")] - Sqlite(#[from] rusqlite::Error), - - #[error("json error")] - Json(#[from] serde_json::Error), - - #[error("invalid IRI: {0}")] - InvalidIri(String), - - #[error("unsupported runtime object type")] - UnsupportedObjectType, - - #[error("unsupported stored object type: {0}")] - UnsupportedStoredObjectType(String), - - #[error(transparent)] - ActorKey(#[from] KeyError), -} - -pub trait RuntimeStore { - fn persist_actions(&mut self, actions: &[Action]) -> Result<(), StoreError>; - - fn list_followers(&self, actor_id: &Iri) -> Result, StoreError>; - - fn list_follower_recipients(&self, actor_id: &Iri) -> Result, StoreError>; - - fn load_object(&self, object_id: &Iri) -> Result, StoreError>; - - fn insert_actor_key_pair( - &mut self, - actor_id: &Iri, - key_pair: &ActorKeyPair, - ) -> Result<(), StoreError>; - - fn load_actor_key_pair(&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 deleted file mode 100644 index 682e4db..0000000 --- a/crates/feder-runtime-server/src/storage/sqlite.rs +++ /dev/null @@ -1,1087 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use std::path::Path; - -#[cfg(unix)] -use std::{ - fs::OpenOptions, - os::unix::fs::{OpenOptionsExt, PermissionsExt}, -}; - -use feder_core::{Action, Object, http_signatures::ActorKeyPair}; -use feder_vocab::{Actor, Iri, Note, Reference}; -use rusqlite::{Connection, OptionalExtension, params}; - -use crate::storage::{RuntimeStore, StoreError, StoredFollower, StoredRecipient}; - -pub struct SqliteStore { - conn: Connection, -} - -impl SqliteStore { - pub fn open(path: &Path) -> Result { - #[cfg(unix)] - let database_file = prepare_database_file(path)?; - - let store = Self { - conn: Connection::open(path)?, - }; - - #[cfg(unix)] - drop(database_file); - - store.init()?; - - Ok(store) - } - - pub fn open_in_memory() -> Result { - let store = Self { - conn: Connection::open_in_memory()?, - }; - - store.init()?; - - Ok(store) - } - - pub fn init(&self) -> Result<(), StoreError> { - self.conn.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS followers ( - follower_actor_id TEXT NOT NULL, - following_actor_id TEXT NOT NULL, - inbox_url TEXT, - shared_inbox_url TEXT, - PRIMARY KEY (follower_actor_id, following_actor_id) - ); - CREATE INDEX IF NOT EXISTS idx_followers_following_actor_id - ON followers (following_actor_id); - CREATE TABLE IF NOT EXISTS keys ( - actor_id TEXT PRIMARY KEY NOT NULL, - private_key_pem TEXT NOT NULL, - public_key_pem TEXT NOT NULL - ); - CREATE TABLE IF NOT EXISTS objects ( - object_id TEXT PRIMARY KEY NOT NULL, - object_type TEXT NOT NULL, - object_json TEXT NOT NULL - ); - "#, - )?; - - Ok(()) - } -} - -#[cfg(unix)] -fn prepare_database_file(path: &Path) -> Result { - let file = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .mode(0o600) - .open(path)?; - - let mut permissions = file.metadata()?.permissions(); - permissions.set_mode(0o600); - file.set_permissions(permissions)?; - - Ok(file) -} - -impl RuntimeStore for SqliteStore { - fn persist_actions(&mut self, actions: &[Action]) -> Result<(), StoreError> { - let tx = self.conn.transaction()?; - - 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); - let refresh_actor = matches!(&action.follower, Reference::Object(_)); - - 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 = CASE - WHEN ?5 THEN excluded.inbox_url - ELSE followers.inbox_url - END, - shared_inbox_url = CASE - WHEN ?5 THEN excluded.shared_inbox_url - ELSE followers.shared_inbox_url - END - "#, - params![ - follower.as_str(), - following.as_str(), - inbox.map(|inbox| inbox.as_str()), - shared_inbox.map(|shared_inbox| shared_inbox.as_str()), - refresh_actor, - ], - )?; - } - Action::RemoveFollower(action) => { - tx.execute( - r#" - DELETE FROM followers - WHERE follower_actor_id = ?1 AND following_actor_id = ?2 - "#, - params![action.follower.as_str(), action.following.as_str()], - )?; - } - Action::StoreObject(action) => { - let (object_id, object_type, object_json) = encode_object(&action.object)?; - tx.execute( - r#" - INSERT INTO objects (object_id, object_type, object_json) - VALUES (?1, ?2, ?3) - ON CONFLICT(object_id) DO UPDATE SET - object_type = excluded.object_type, - object_json = excluded.object_json - "#, - params![object_id.as_str(), object_type, object_json], - )?; - } - _ => {} - } - } - - tx.commit()?; - - Ok(()) - } - - fn list_followers(&self, actor_id: &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 following_actor_id = ?1 - ORDER BY follower_actor_id, following_actor_id - "#, - )?; - let rows = stmt.query_map([actor_id.as_str()], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, Option>(3)?, - )) - })?; - - rows.map(|row| { - let (follower, following, inbox, shared_inbox) = row?; - Ok(StoredFollower { - follower: parse_iri(follower)?, - following: parse_iri(following)?, - inbox: parse_optional_iri(inbox)?, - shared_inbox: parse_optional_iri(shared_inbox)?, - }) - }) - .collect() - } - - fn list_follower_recipients(&self, actor_id: &Iri) -> Result, StoreError> { - let mut stmt = self.conn.prepare( - r#" - SELECT follower_actor_id, inbox_url, shared_inbox_url - FROM followers - WHERE following_actor_id = ?1 - AND inbox_url IS NOT NULL - ORDER BY follower_actor_id - "#, - )?; - let rows = stmt.query_map([actor_id.as_str()], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, Option>(2)?, - )) - })?; - - rows.map(|row| { - let (actor_id, inbox, shared_inbox) = row?; - Ok(StoredRecipient { - actor_id: parse_iri(actor_id)?, - inbox: parse_iri(inbox)?, - shared_inbox: parse_optional_iri(shared_inbox)?, - }) - }) - .collect() - } - - fn load_object(&self, object_id: &Iri) -> Result, StoreError> { - let stored = self - .conn - .query_row( - r#" - SELECT object_type, object_json - FROM objects - WHERE object_id = ?1 - "#, - [object_id.as_str()], - |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), - ) - .optional()?; - - stored - .map(|(object_type, object_json)| decode_object(&object_type, &object_json)) - .transpose() - } - - fn insert_actor_key_pair( - &mut self, - actor_id: &Iri, - key_pair: &ActorKeyPair, - ) -> Result<(), StoreError> { - self.conn.execute( - r#" - INSERT INTO keys (actor_id, private_key_pem, public_key_pem) - VALUES (?1, ?2, ?3) - "#, - params![ - actor_id.as_str(), - key_pair.private_key_pem(), - key_pair.public_key_pem(), - ], - )?; - - Ok(()) - } - - fn load_actor_key_pair(&self, actor_id: &Iri) -> Result, StoreError> { - let encoded_keys = self - .conn - .query_row( - r#" - SELECT private_key_pem, public_key_pem - FROM keys - WHERE actor_id = ?1 - "#, - [actor_id.as_str()], - |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), - ) - .optional()?; - - encoded_keys - .map(|(private_key_pem, public_key_pem)| { - ActorKeyPair::from_pem(private_key_pem, public_key_pem) - }) - .transpose() - .map_err(StoreError::from) - } -} - -fn encode_object(object: &Object) -> Result<(&Iri, &'static str, String), StoreError> { - match object { - Object::Note(note) => Ok((¬e.id, "Note", serde_json::to_string(note)?)), - _ => Err(StoreError::UnsupportedObjectType), - } -} - -fn decode_object(object_type: &str, object_json: &str) -> Result { - match object_type { - "Note" => Ok(Object::Note(serde_json::from_str::(object_json)?)), - object_type => Err(StoreError::UnsupportedStoredObjectType( - object_type.to_string(), - )), - } -} - -fn actor_reference_id(reference: &Reference) -> &Iri { - match reference { - Reference::Id(id) => id, - Reference::Object(actor) => &actor.id, - } -} - -fn actor_reference_inbox(reference: &Reference) -> Option<&Iri> { - match reference { - Reference::Id(_) => None, - Reference::Object(actor) => Some(&actor.inbox), - } -} - -fn actor_reference_shared_inbox(reference: &Reference) -> Option<&Iri> { - match reference { - Reference::Id(_) => None, - Reference::Object(actor) => actor - .endpoints - .as_ref() - .and_then(|endpoints| endpoints.shared_inbox.as_ref()), - } -} - -fn parse_iri(value: String) -> Result { - value - .parse() - .map_err(|_| StoreError::InvalidIri(value.to_owned())) -} - -fn parse_optional_iri(value: Option) -> Result, StoreError> { - value.map(parse_iri).transpose() -} - -#[cfg(test)] -mod tests { - use feder_core::{Action, Object, RemoveFollower, StoreFollower, StoreObject}; - - use super::*; - - const PRIVATE_KEY_PEM: &str = include_str!("../../tests/fixtures/rsa-private-key.pem"); - const PUBLIC_KEY_PEM: &str = include_str!("../../tests/fixtures/rsa-public-key.pem"); - - fn iri(value: &str) -> Iri { - value.parse().expect("valid test IRI") - } - - 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 store_note_action(content: &str) -> Action { - let mut note = Note::new(iri("https://example.com/users/alice/posts/1")); - note.attributed_to = Some(Reference::id(iri("https://example.com/users/alice"))); - note.content = Some(content.to_string()); - - Action::StoreObject(StoreObject { - object: Object::Note(note), - }) - } - - fn actor(id: &str) -> Actor { - Actor::person( - iri(id), - iri(&format!("{id}/inbox")), - iri(&format!("{id}/outbox")), - ) - } - - fn actor_key_pair() -> ActorKeyPair { - ActorKeyPair::from_pem(PRIVATE_KEY_PEM.to_string(), PUBLIC_KEY_PEM.to_string()) - .expect("valid actor key pair fixture") - } - - #[test] - fn open_in_memory_initializes_followers_table() { - let store = SqliteStore::open_in_memory().expect("open in-memory store"); - - let table_count: i64 = store - .conn - .query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'followers'", - [], - |row| row.get(0), - ) - .expect("query followers table"); - - assert_eq!(table_count, 1); - - 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") - }; - - 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 index_count: i64 = store - .conn - .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!(index_count, 1); - } - - #[test] - fn open_in_memory_initializes_keys_table() { - let store = SqliteStore::open_in_memory().expect("open in-memory store"); - - let columns: Vec = { - let mut stmt = store - .conn - .prepare("PRAGMA table_info(keys)") - .expect("prepare keys table info query"); - stmt.query_map([], |row| row.get("name")) - .expect("query keys table info") - .collect::>() - .expect("collect keys table columns") - }; - - assert_eq!( - columns, - vec![ - "actor_id".to_string(), - "private_key_pem".to_string(), - "public_key_pem".to_string(), - ] - ); - } - - #[test] - fn open_in_memory_initializes_objects_table() { - let store = SqliteStore::open_in_memory().expect("open in-memory store"); - - let columns: Vec = { - let mut stmt = store - .conn - .prepare("PRAGMA table_info(objects)") - .expect("prepare objects table info query"); - stmt.query_map([], |row| row.get("name")) - .expect("query objects table info") - .collect::>() - .expect("collect objects table columns") - }; - - assert_eq!( - columns, - vec![ - "object_id".to_string(), - "object_type".to_string(), - "object_json".to_string(), - ] - ); - } - - #[test] - fn persist_actions_stores_and_loads_note() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - let action = store_note_action("Hello from Feder."); - - store - .persist_actions(core::slice::from_ref(&action)) - .expect("persist note action"); - let object = store - .load_object(&iri("https://example.com/users/alice/posts/1")) - .expect("load note") - .expect("stored note"); - - let Action::StoreObject(expected) = action else { - panic!("expected store object action"); - }; - assert_eq!(object, expected.object); - } - - #[test] - fn persist_actions_replaces_object_with_same_id() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - - store - .persist_actions(&[store_note_action("Original")]) - .expect("persist original note"); - store - .persist_actions(&[store_note_action("Updated")]) - .expect("replace note"); - - let object = store - .load_object(&iri("https://example.com/users/alice/posts/1")) - .expect("load note") - .expect("stored note"); - let Object::Note(note) = object else { - panic!("expected stored note"); - }; - assert_eq!(note.content.as_deref(), Some("Updated")); - } - - #[test] - fn load_object_returns_none_for_unknown_id() { - let store = SqliteStore::open_in_memory().expect("open in-memory store"); - - let object = store - .load_object(&iri("https://example.com/users/alice/posts/unknown")) - .expect("load unknown object"); - - assert!(object.is_none()); - } - - #[test] - fn stored_note_persists_across_store_reopen() { - let path = std::env::temp_dir().join(format!( - "feder-object-test-{}-{}.sqlite3", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system time after unix epoch") - .as_nanos() - )); - - { - let mut store = SqliteStore::open(&path).expect("open SQLite store"); - store - .persist_actions(&[store_note_action("Persistent note")]) - .expect("persist note action"); - } - - let store = SqliteStore::open(&path).expect("reopen SQLite store"); - let object = store - .load_object(&iri("https://example.com/users/alice/posts/1")) - .expect("load persisted note") - .expect("persisted note"); - let Object::Note(note) = object else { - panic!("expected stored note"); - }; - assert_eq!(note.content.as_deref(), Some("Persistent note")); - - drop(store); - let _ = std::fs::remove_file(path); - } - - #[cfg(unix)] - #[test] - fn open_creates_database_with_owner_only_permissions() { - use std::os::unix::fs::PermissionsExt; - - let temp_dir = std::env::temp_dir().join(format!( - "feder-database-permissions-test-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system time after unix epoch") - .as_nanos() - )); - std::fs::create_dir(&temp_dir).expect("create temporary directory"); - let path = temp_dir.join("store.sqlite3"); - - let store = SqliteStore::open(&path).expect("open SQLite store"); - - let mode = std::fs::metadata(&path) - .expect("read database metadata") - .permissions() - .mode() - & 0o777; - assert_eq!(mode, 0o600); - - drop(store); - std::fs::remove_dir_all(temp_dir).expect("remove temporary directory"); - } - - #[cfg(unix)] - #[test] - fn open_restricts_existing_database_permissions() { - use std::os::unix::fs::PermissionsExt; - - let temp_dir = std::env::temp_dir().join(format!( - "feder-existing-database-permissions-test-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system time after unix epoch") - .as_nanos() - )); - std::fs::create_dir(&temp_dir).expect("create temporary directory"); - let path = temp_dir.join("store.sqlite3"); - std::fs::write(&path, []).expect("create permissive database file"); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)) - .expect("make database file permissive"); - - let store = SqliteStore::open(&path).expect("open SQLite store"); - - let mode = std::fs::metadata(&path) - .expect("read database metadata") - .permissions() - .mode() - & 0o777; - assert_eq!(mode, 0o600); - - drop(store); - std::fs::remove_dir_all(temp_dir).expect("remove temporary directory"); - } - - #[test] - fn actor_key_pair_roundtrips_for_actor() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - let actor_id = iri("https://example.com/users/alice"); - let expected = actor_key_pair(); - - store - .insert_actor_key_pair(&actor_id, &expected) - .expect("insert actor key pair"); - let actual = store - .load_actor_key_pair(&actor_id) - .expect("load actor key pair") - .expect("stored actor key pair"); - - assert_eq!(actual, expected); - } - - #[test] - fn actor_key_pair_persists_across_store_reopen() { - let path = std::env::temp_dir().join(format!( - "feder-actor-key-test-{}-{}.sqlite3", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system time after unix epoch") - .as_nanos() - )); - let actor_id = iri("https://example.com/users/alice"); - let expected = actor_key_pair(); - - { - let mut store = SqliteStore::open(&path).expect("open SQLite store"); - store - .insert_actor_key_pair(&actor_id, &expected) - .expect("insert actor key pair"); - } - - let store = SqliteStore::open(&path).expect("reopen SQLite store"); - let actual = store - .load_actor_key_pair(&actor_id) - .expect("load actor key pair") - .expect("persisted actor key pair"); - - assert_eq!(actual, expected); - - drop(store); - let _ = std::fs::remove_file(path); - } - - #[test] - fn load_actor_key_pair_returns_none_for_unknown_actor() { - let store = SqliteStore::open_in_memory().expect("open in-memory store"); - - let key_pair = store - .load_actor_key_pair(&iri("https://example.com/users/unknown")) - .expect("load actor key pair"); - - assert!(key_pair.is_none()); - } - - #[test] - fn load_actor_key_pair_rejects_invalid_stored_keys() { - let store = SqliteStore::open_in_memory().expect("open in-memory store"); - store - .conn - .execute( - r#" - INSERT INTO keys (actor_id, private_key_pem, public_key_pem) - VALUES (?1, ?2, ?3) - "#, - params![ - "https://example.com/users/alice", - "not a private key", - PUBLIC_KEY_PEM, - ], - ) - .expect("insert invalid actor key pair"); - - let result = store.load_actor_key_pair(&iri("https://example.com/users/alice")); - - assert!(matches!(result, Err(StoreError::ActorKey(_)))); - } - - #[test] - fn insert_actor_key_pair_refuses_to_replace_existing_key() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - let actor_id = iri("https://example.com/users/alice"); - let key_pair = actor_key_pair(); - - store - .insert_actor_key_pair(&actor_id, &key_pair) - .expect("insert actor key pair"); - let result = store.insert_actor_key_pair(&actor_id, &key_pair); - - assert!(matches!(result, Err(StoreError::Sqlite(_)))); - } - - #[test] - fn persist_actions_stores_follower() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - - store - .persist_actions(&[store_follower_action()]) - .expect("persist follower action"); - - 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!(follower, "https://remote.example/users/bob"); - assert_eq!(following, "https://example.com/users/alice"); - } - - #[test] - fn persist_actions_removes_follower() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - store - .persist_actions(&[store_follower_action()]) - .expect("persist follower action"); - - store - .persist_actions(&[Action::RemoveFollower(RemoveFollower { - follower: iri("https://remote.example/users/bob"), - following: iri("https://example.com/users/alice"), - })]) - .expect("persist follower removal action"); - - let follower_count: i64 = store - .conn - .query_row("SELECT COUNT(*) FROM followers", [], |row| row.get(0)) - .expect("query follower count"); - assert_eq!(follower_count, 0); - } - - #[test] - 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 - .persist_actions(&[action]) - .expect("persist follower action"); - - let inbox: Option = store - .conn - .query_row("SELECT inbox_url FROM followers", [], |row| row.get(0)) - .expect("query stored follower inbox"); - - assert_eq!( - inbox.as_deref(), - Some("https://remote.example/users/bob/inbox") - ); - } - - #[test] - fn persist_actions_stores_embedded_follower_shared_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 - .persist_actions(&[action]) - .expect("persist follower action"); - - let shared_inbox: Option = store - .conn - .query_row("SELECT shared_inbox_url FROM followers", [], |row| { - row.get(0) - }) - .expect("query stored follower shared inbox"); - - assert_eq!( - shared_inbox.as_deref(), - Some("https://remote.example/inbox") - ); - } - - #[test] - fn persist_actions_ignores_duplicate_follower() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - let action = store_follower_action(); - - store - .persist_actions(core::slice::from_ref(&action)) - .expect("persist follower action first time"); - store - .persist_actions(&[action]) - .expect("persist follower action second time"); - - let follower_count: i64 = store - .conn - .query_row("SELECT COUNT(*) FROM followers", [], |row| row.get(0)) - .expect("query follower count"); - - assert_eq!(follower_count, 1); - } - - #[test] - fn persist_actions_updates_follower_inbox_from_repeated_follow() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - - store - .persist_actions(&[store_follower_action()]) - .expect("persist ID-only follower action"); - let mut follower = actor("https://remote.example/users/bob"); - follower.inbox = iri("https://remote.example/users/bob/updated-inbox"); - store - .persist_actions(&[Action::StoreFollower(StoreFollower { - follower: Reference::object(follower), - following: Reference::id(iri("https://example.com/users/alice")), - })]) - .expect("persist repeated follower action"); - - let recipients = store - .list_follower_recipients(&iri("https://example.com/users/alice")) - .expect("list follower recipients"); - - 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, - }] - ); - } - - #[test] - fn persist_actions_clears_removed_shared_inbox_from_embedded_actor() { - 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")), - }); - store - .persist_actions(&[Action::StoreFollower(StoreFollower { - follower: Reference::object(follower), - following: Reference::id(iri("https://example.com/users/alice")), - })]) - .expect("persist follower with shared inbox"); - - let mut updated_follower = actor("https://remote.example/users/bob"); - updated_follower.inbox = iri("https://remote.example/users/bob/updated-inbox"); - store - .persist_actions(&[Action::StoreFollower(StoreFollower { - follower: Reference::object(updated_follower), - following: Reference::id(iri("https://example.com/users/alice")), - })]) - .expect("persist follower without shared inbox"); - - let recipients = store - .list_follower_recipients(&iri("https://example.com/users/alice")) - .expect("list follower recipients"); - - 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, - }] - ); - } - - #[test] - fn persist_actions_preserves_inboxes_from_id_only_repeated_follow() { - 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")), - }); - store - .persist_actions(&[Action::StoreFollower(StoreFollower { - follower: Reference::object(follower), - following: Reference::id(iri("https://example.com/users/alice")), - })]) - .expect("persist embedded follower"); - store - .persist_actions(&[store_follower_action()]) - .expect("persist ID-only repeated follower"); - - let recipients = store - .list_follower_recipients(&iri("https://example.com/users/alice")) - .expect("list follower recipients"); - - assert_eq!( - recipients, - vec![StoredRecipient { - actor_id: iri("https://remote.example/users/bob"), - inbox: iri("https://remote.example/users/bob/inbox"), - shared_inbox: Some(iri("https://remote.example/inbox")), - }] - ); - } - - #[test] - fn list_followers_returns_stored_followers() { - let mut store = SqliteStore::open_in_memory().expect("open in-memory store"); - - store - .persist_actions(&[store_follower_action()]) - .expect("persist follower action"); - - let followers = store - .list_followers(&iri("https://example.com/users/alice")) - .expect("list stored followers"); - - assert_eq!( - followers, - vec![StoredFollower { - follower: iri("https://remote.example/users/bob"), - following: iri("https://example.com/users/alice"), - inbox: None, - shared_inbox: None, - }] - ); - } - - #[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 - .persist_actions(&[action]) - .expect("persist follower action"); - - let followers = store - .list_followers(&iri("https://example.com/users/alice")) - .expect("list stored followers"); - - assert_eq!( - followers, - vec![StoredFollower { - follower: iri("https://remote.example/users/bob"), - following: iri("https://example.com/users/alice"), - inbox: Some(iri("https://remote.example/users/bob/inbox")), - shared_inbox: Some(iri("https://remote.example/inbox")), - }] - ); - } - - #[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 - .persist_actions(&[bob_follows_alice, carol_follows_eve]) - .expect("persist follower actions"); - - let followers = store - .list_followers(&iri("https://example.com/users/alice")) - .expect("list stored followers"); - - assert_eq!( - followers, - vec![StoredFollower { - follower: iri("https://remote.example/users/bob"), - following: iri("https://example.com/users/alice"), - inbox: None, - shared_inbox: None, - }] - ); - } - - #[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 - .persist_actions(&[follower_with_inbox, follower_without_inbox]) - .expect("persist follower actions"); - - let recipients = store - .list_follower_recipients(&iri("https://example.com/users/alice")) - .expect("list follower recipients"); - - assert_eq!( - recipients, - vec![StoredRecipient { - actor_id: iri("https://remote.example/users/bob"), - inbox: iri("https://remote.example/users/bob/inbox"), - shared_inbox: Some(iri("https://remote.example/inbox")), - }] - ); - } - - #[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 - .persist_actions(&[bob_follows_alice, carol_follows_eve]) - .expect("persist follower actions"); - - let recipients = store - .list_follower_recipients(&iri("https://example.com/users/alice")) - .expect("list follower recipients"); - - assert_eq!( - recipients, - vec![StoredRecipient { - actor_id: iri("https://remote.example/users/bob"), - inbox: iri("https://remote.example/users/bob/inbox"), - shared_inbox: None, - }] - ); - } -} diff --git a/crates/feder-runtime-server/src/url.rs b/crates/feder-runtime-server/src/url.rs deleted file mode 100644 index f16c90a..0000000 --- a/crates/feder-runtime-server/src/url.rs +++ /dev/null @@ -1,148 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use std::{ - io, - net::{IpAddr, SocketAddr}, - sync::LazyLock, - time::Duration, -}; - -use ipnet::IpNet; -use reqwest::{ - Client, Url, - dns::{Addrs, Name, Resolve, Resolving}, - redirect::Policy, -}; -use url::Host; - -use crate::config::OutboundAddressPolicy; - -const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); -const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); - -const NON_PUBLIC_NETWORK_CIDRS: &[&str] = &[ - "0.0.0.0/8", - "10.0.0.0/8", - "100.64.0.0/10", - "127.0.0.0/8", - "169.254.0.0/16", - "172.16.0.0/12", - "192.0.0.0/24", - "192.0.2.0/24", - "192.88.99.0/24", - "192.168.0.0/16", - "198.18.0.0/15", - "198.51.100.0/24", - "203.0.113.0/24", - "224.0.0.0/4", - "240.0.0.0/4", - "::/128", - "::1/128", - "64:ff9b::/96", - "64:ff9b:1::/48", - "100::/64", - "100:0:0:1::/64", - "2001::/23", - "2001:db8::/32", - "2002::/16", - "3fff::/20", - "5f00::/16", - "fc00::/7", - "fe80::/10", - "ff00::/8", -]; - -static NON_PUBLIC_NETWORKS: LazyLock> = LazyLock::new(|| { - NON_PUBLIC_NETWORK_CIDRS - .iter() - .map(|cidr| cidr.parse().expect("hardcoded network CIDR is valid")) - .collect() -}); - -pub(crate) fn build_client(policy: OutboundAddressPolicy) -> Result { - Client::builder() - .dns_resolver(PublicDnsResolver { policy }) - .redirect(Policy::none()) - .no_proxy() - .connect_timeout(CONNECT_TIMEOUT) - .timeout(REQUEST_TIMEOUT) - .build() -} - -pub(crate) fn validate_literal_host( - url: &Url, - policy: OutboundAddressPolicy, -) -> Result<(), IpAddr> { - if policy == OutboundAddressPolicy::AllowPrivateAddress { - return Ok(()); - } - - match url.host() { - Some(Host::Ipv4(address)) => validate_public_address(address.into()), - Some(Host::Ipv6(address)) => validate_public_address(address.into()), - Some(Host::Domain(_)) | None => Ok(()), - } -} - -#[derive(Clone, Copy, Debug)] -struct PublicDnsResolver { - policy: OutboundAddressPolicy, -} - -impl Resolve for PublicDnsResolver { - fn resolve(&self, name: Name) -> Resolving { - let host = name.as_str().to_string(); - let policy = self.policy; - - Box::pin(async move { - let addresses = tokio::net::lookup_host((host.as_str(), 0)) - .await? - .collect::>(); - if addresses.is_empty() { - return Err(io::Error::new( - io::ErrorKind::NotFound, - format!("{host} resolved to no addresses"), - ) - .into()); - } - if policy == OutboundAddressPolicy::PublicOnly { - for address in &addresses { - validate_public_address(address.ip()).map_err(|blocked| { - io::Error::new( - io::ErrorKind::PermissionDenied, - format!("{host} resolved to non-public address {blocked}"), - ) - })?; - } - } - - Ok(Box::new(addresses.into_iter()) as Addrs) - }) - } -} - -fn validate_public_address(address: IpAddr) -> Result<(), IpAddr> { - if let IpAddr::V6(address) = address - && let Some(mapped) = address.to_ipv4_mapped() - { - return validate_public_address(mapped.into()); - } - let is_public = !NON_PUBLIC_NETWORKS - .iter() - .any(|network| network.contains(&address)); - - if is_public { Ok(()) } else { Err(address) } -} diff --git a/crates/feder-runtime-server/src/webfinger.rs b/crates/feder-runtime-server/src/webfinger.rs deleted file mode 100644 index c84f242..0000000 --- a/crates/feder-runtime-server/src/webfinger.rs +++ /dev/null @@ -1,74 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use axum::{ - Json, - extract::{Query, State}, - http::{StatusCode, header}, - response::{IntoResponse, Response}, -}; -use serde::{Deserialize, Serialize}; - -use crate::app::AppState; - -#[derive(Deserialize)] -pub struct WebFingerQuery { - resource: Option, -} - -#[derive(Serialize)] -pub struct WebFingerResponse { - subject: String, - aliases: Vec, - links: Vec, -} - -#[derive(Serialize)] -pub struct WebFingerLink { - rel: &'static str, - #[serde(rename = "type")] - media_type: &'static str, - href: String, -} - -pub async fn webfinger( - State(state): State, - Query(query): Query, -) -> Result { - let Some(resource) = query.resource else { - return Err(StatusCode::BAD_REQUEST); - }; - - let expected = format!("acct:{}@{}", state.username, state.handle_host); - if resource != expected { - return Err(StatusCode::NOT_FOUND); - } - - let actor_id = state.local_actor.id.to_string(); - - Ok(( - [(header::CONTENT_TYPE, "application/jrd+json")], - Json(WebFingerResponse { - subject: resource, - aliases: vec![actor_id.clone()], - links: vec![WebFingerLink { - rel: "self", - media_type: "application/activity+json", - href: actor_id, - }], - }), - ) - .into_response()) -} diff --git a/crates/feder-runtime-server/tests/cases/actor.rs b/crates/feder-runtime-server/tests/cases/actor.rs deleted file mode 100644 index e2ed22d..0000000 --- a/crates/feder-runtime-server/tests/cases/actor.rs +++ /dev/null @@ -1,130 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use axum::{ - body::{Body, to_bytes}, - http::{Request, StatusCode, header}, -}; -use serde_json::Value; -use tower::ServiceExt; - -use crate::common::{test_config, test_router}; - -#[tokio::test] -async fn returns_local_actor() { - let app = test_router(test_config()).expect("build router"); - - let response = app - .oneshot( - Request::builder() - .uri("/users/alice") - .header(header::ACCEPT, "application/activity+json") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response.headers().get(header::CONTENT_TYPE).unwrap(), - "application/activity+json" - ); - assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); - - let body = to_bytes(response.into_body(), 2048) - .await - .expect("read response body"); - let json: Value = serde_json::from_slice(&body).expect("valid json"); - - assert_eq!( - json["@context"], - serde_json::json!([ - "https://www.w3.org/ns/activitystreams", - "https://w3id.org/security/v1" - ]) - ); - assert_eq!(json["type"], "Person"); - assert_eq!(json["id"], "http://127.0.0.1:3000/users/alice"); - assert_eq!(json["inbox"], "http://127.0.0.1:3000/users/alice/inbox"); - assert_eq!(json["outbox"], "http://127.0.0.1:3000/users/alice/outbox"); - assert_eq!( - json["followers"], - "http://127.0.0.1:3000/users/alice/followers" - ); - assert_eq!(json["preferredUsername"], "alice"); - assert_eq!(json["name"], "alice"); - assert_eq!( - json["publicKey"], - serde_json::json!({ - "id": "http://127.0.0.1:3000/users/alice#main-key", - "type": "CryptographicKey", - "owner": "http://127.0.0.1:3000/users/alice", - "publicKeyPem": include_str!("../fixtures/rsa-public-key.pem"), - }) - ); -} - -#[tokio::test] -async fn rejects_actor_request_when_html_is_preferred() { - let response = test_router(test_config()) - .expect("build router") - .oneshot( - Request::builder() - .uri("/users/alice") - .header(header::ACCEPT, "text/html, application/activity+json;q=0.8") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); - assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); -} - -#[tokio::test] -async fn rejects_actor_request_without_activitypub_accept() { - let response = test_router(test_config()) - .expect("build router") - .oneshot( - Request::builder() - .uri("/users/alice") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); - assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); -} - -#[tokio::test] -async fn rejects_unknown_actor() { - let app = test_router(test_config()).expect("build router"); - - let response = app - .oneshot( - Request::builder() - .uri("/users/bob") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::NOT_FOUND); -} diff --git a/crates/feder-runtime-server/tests/cases/app.rs b/crates/feder-runtime-server/tests/cases/app.rs deleted file mode 100644 index 6fd8552..0000000 --- a/crates/feder-runtime-server/tests/cases/app.rs +++ /dev/null @@ -1,82 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use axum::{ - body::Body, - http::{Request, StatusCode}, -}; -use feder_runtime_server::{AppState, config::StorageConfig, storage::RuntimeStore}; -use feder_vocab::Reference; -use tower::ServiceExt; - -use crate::common::{temporary_database_path, test_config, test_router}; - -#[tokio::test] -async fn returns_health_check() { - let app = test_router(test_config()).expect("build router"); - - let response = app - .oneshot( - Request::builder() - .uri("/healthz") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::NO_CONTENT); -} - -#[test] -fn startup_generates_then_reuses_persisted_actor_key_pair() { - let path = temporary_database_path("feder-startup-key-test"); - let mut config = test_config(); - config.storage = StorageConfig::Sqlite { path: path.clone() }; - let first = AppState::from_config(config).expect("build first app state"); - let expected_public_key = first.actor_key_pair.public_key_pem().to_string(); - let Reference::Object(published_key) = first - .local_actor - .public_key - .as_ref() - .expect("actor publishes public key") - else { - panic!("actor public key should be embedded"); - }; - assert_eq!( - published_key.id.as_str(), - "http://127.0.0.1:3000/users/alice#main-key" - ); - assert_eq!(published_key.owner, first.local_actor.id); - assert_eq!(published_key.public_key_pem, expected_public_key); - let stored = first - .store - .lock() - .expect("lock store") - .load_actor_key_pair(&first.local_actor.id) - .expect("load actor key pair") - .expect("stored actor key pair"); - assert_eq!(stored, *first.actor_key_pair); - drop(first); - - let mut config = test_config(); - config.storage = StorageConfig::Sqlite { path: path.clone() }; - let second = AppState::from_config(config).expect("reopen app state"); - - assert_eq!(second.actor_key_pair.public_key_pem(), expected_public_key); - - drop(second); - let _ = std::fs::remove_file(path); -} diff --git a/crates/feder-runtime-server/tests/cases/followers.rs b/crates/feder-runtime-server/tests/cases/followers.rs deleted file mode 100644 index bfeb6cb..0000000 --- a/crates/feder-runtime-server/tests/cases/followers.rs +++ /dev/null @@ -1,169 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use axum::{ - body::{Body, to_bytes}, - http::{Request, StatusCode, header}, -}; -use feder_core::{Action, RemoveFollower, StoreFollower}; -use feder_runtime_server::{app::router_with_state, storage::RuntimeStore}; -use feder_vocab::{Iri, Reference}; -use serde_json::Value; -use tower::ServiceExt; - -use crate::common::{test_app_state, test_config, test_router}; - -fn iri(value: &str) -> Iri { - value.parse().expect("valid test IRI") -} - -async fn get_followers(app: axum::Router, username: &str) -> axum::response::Response { - app.oneshot( - Request::builder() - .uri(format!("/users/{username}/followers")) - .header(header::ACCEPT, "application/activity+json") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response") -} - -async fn response_json(response: axum::response::Response) -> Value { - let body = to_bytes(response.into_body(), 4096) - .await - .expect("read response body"); - serde_json::from_slice(&body).expect("valid JSON") -} - -fn store_follower(follower: &str) -> Action { - Action::StoreFollower(StoreFollower { - follower: Reference::id(iri(follower)), - following: Reference::id(iri("http://127.0.0.1:3000/users/alice")), - }) -} - -#[tokio::test] -async fn returns_empty_followers_collection_with_activitypub_headers() { - let response = get_followers(test_router(test_config()).expect("build router"), "alice").await; - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response.headers().get(header::CONTENT_TYPE).unwrap(), - "application/activity+json" - ); - assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); - - let json = response_json(response).await; - assert_eq!(json["@context"], "https://www.w3.org/ns/activitystreams"); - assert_eq!(json["type"], "OrderedCollection"); - assert_eq!(json["id"], "http://127.0.0.1:3000/users/alice/followers"); - assert_eq!(json["totalItems"], 0); - assert_eq!(json["orderedItems"], serde_json::json!([])); -} - -#[tokio::test] -async fn returns_stored_followers_and_count() { - let state = test_app_state(test_config()).expect("build app state"); - state - .store - .lock() - .expect("store lock") - .persist_actions(&[ - store_follower("https://remote.example/users/carol"), - store_follower("https://remote.example/users/bob"), - ]) - .expect("persist followers"); - - let response = get_followers(router_with_state(state), "alice").await; - assert_eq!(response.status(), StatusCode::OK); - - let json = response_json(response).await; - assert_eq!(json["totalItems"], 2); - assert_eq!( - json["orderedItems"], - serde_json::json!([ - "https://remote.example/users/bob", - "https://remote.example/users/carol" - ]) - ); -} - -#[tokio::test] -async fn reflects_follower_removal() { - let state = test_app_state(test_config()).expect("build app state"); - { - let mut store = state.store.lock().expect("store lock"); - store - .persist_actions(&[store_follower("https://remote.example/users/bob")]) - .expect("persist follower"); - store - .persist_actions(&[Action::RemoveFollower(RemoveFollower { - follower: iri("https://remote.example/users/bob"), - following: state.local_actor.id.clone(), - })]) - .expect("remove follower"); - } - - let response = get_followers(router_with_state(state), "alice").await; - assert_eq!(response.status(), StatusCode::OK); - - let json = response_json(response).await; - assert_eq!(json["totalItems"], 0); - assert_eq!(json["orderedItems"], serde_json::json!([])); -} - -#[tokio::test] -async fn rejects_unknown_username() { - let response = - get_followers(test_router(test_config()).expect("build router"), "unknown").await; - - assert_eq!(response.status(), StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn rejects_followers_request_when_html_is_preferred() { - let response = test_router(test_config()) - .expect("build router") - .oneshot( - Request::builder() - .uri("/users/alice/followers") - .header(header::ACCEPT, "text/html, application/activity+json;q=0.8") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); - assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); -} - -#[tokio::test] -async fn rejects_followers_request_without_activitypub_accept() { - let response = test_router(test_config()) - .expect("build router") - .oneshot( - Request::builder() - .uri("/users/alice/followers") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); - assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); -} diff --git a/crates/feder-runtime-server/tests/cases/inbox.rs b/crates/feder-runtime-server/tests/cases/inbox.rs deleted file mode 100644 index 4192167..0000000 --- a/crates/feder-runtime-server/tests/cases/inbox.rs +++ /dev/null @@ -1,877 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use axum::{ - Json, Router, - body::{Body, Bytes}, - http::{HeaderMap, Request, StatusCode, Uri, header::CONTENT_TYPE}, - routing::{get, post}, -}; -use feder_core::http_signatures::{create_sha256_digest_header, sign_draft_cavage}; -use feder_runtime_server::{ - app::router_with_state, - config::{InboxAuthPolicy, StorageConfig}, - storage::RuntimeStore, -}; -use serde_json::json; -use tower::ServiceExt; - -use crate::common::{ - RecordedRequest, fixture_actor_key_pair, spawn_inbox_server, temporary_database_path, - test_app_state, test_config, test_router, -}; - -fn follow_body() -> Vec { - follow_body_for_inbox("https://remote.example/users/bob/inbox") -} - -fn follow_body_for_inbox(inbox: &str) -> Vec { - serde_json::to_vec(&json!({ - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Follow", - "id": "https://remote.example/activities/follow-1", - "actor": { - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Person", - "id": "https://remote.example/users/bob", - "inbox": inbox, - "outbox": "https://remote.example/users/bob/outbox" - }, - "object": "http://127.0.0.1:3000/users/alice" - })) - .expect("serialize follow") -} - -fn id_only_follow_body(actor_id: &str) -> Vec { - serde_json::to_vec(&json!({ - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Follow", - "id": format!("{actor_id}/follows/1"), - "actor": actor_id, - "object": "http://127.0.0.1:3000/users/alice" - })) - .expect("serialize ID-only follow") -} - -fn undo_follow_body(actor_id: &str, follow_actor_id: &str) -> Vec { - serde_json::to_vec(&json!({ - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Undo", - "id": format!("{actor_id}/undo/1"), - "actor": actor_id, - "object": { - "type": "Follow", - "id": format!("{actor_id}/follows/1"), - "actor": follow_actor_id, - "object": "http://127.0.0.1:3000/users/alice" - } - })) - .expect("serialize Undo Follow") -} - -async fn spawn_actor_server() -> ( - String, - tokio::sync::mpsc::Receiver, - tokio::task::JoinHandle<()>, -) { - let (actor_id, _key_id, receiver, task) = spawn_actor_server_inner(false).await; - (actor_id, receiver, task) -} - -async fn spawn_actor_server_with_separate_key() -> ( - String, - String, - tokio::sync::mpsc::Receiver, - tokio::task::JoinHandle<()>, -) { - spawn_actor_server_inner(true).await -} - -async fn spawn_actor_server_inner( - separate_key: bool, -) -> ( - String, - String, - tokio::sync::mpsc::Receiver, - tokio::task::JoinHandle<()>, -) { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind actor server"); - let address = listener.local_addr().expect("actor server address"); - let actor_id = format!("http://{address}/users/bob"); - let key_id = if separate_key { - format!("http://{address}/keys/1") - } else { - format!("{actor_id}#main-key") - }; - let inbox = format!("http://{address}/inbox"); - let public_key = json!({ - "id": key_id, - "owner": actor_id, - "publicKeyPem": fixture_actor_key_pair() - .expect("load actor key fixture") - .public_key_pem() - }); - let actor = json!({ - "@context": [ - "https://www.w3.org/ns/activitystreams", - { "toot": "http://joinmastodon.org/ns#" } - ], - "type": "Person", - "id": actor_id, - "inbox": inbox, - "outbox": format!("http://{address}/users/bob/outbox"), - "preferredUsername": "bob", - "endpoints": { "sharedInbox": inbox }, - "publicKey": if separate_key { json!(key_id) } else { public_key.clone() } - }); - let (sender, receiver) = tokio::sync::mpsc::channel(1); - let app = Router::new() - .route( - "/users/bob", - get(move || { - let actor = actor.clone(); - async move { ([(CONTENT_TYPE, "application/activity+json")], Json(actor)) } - }), - ) - .route( - "/keys/1", - get(move || { - let public_key = public_key.clone(); - async move { - ( - [(CONTENT_TYPE, "application/activity+json")], - Json(public_key), - ) - } - }), - ) - .route( - "/inbox", - post(move |headers: HeaderMap, uri: Uri, body: Bytes| { - let sender = sender.clone(); - async move { - sender - .send(RecordedRequest { headers, uri, body }) - .await - .expect("request receiver remains open"); - StatusCode::ACCEPTED - } - }), - ); - let task = tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("serve actor endpoint"); - }); - - (actor_id, key_id, receiver, task) -} - -async fn post_inbox( - app: Router, - uri: &str, - content_type: &str, - body: impl Into, -) -> axum::response::Response { - app.oneshot( - Request::builder() - .method("POST") - .uri(uri) - .header(CONTENT_TYPE, content_type) - .body(body.into()) - .expect("valid request"), - ) - .await - .expect("response") -} - -async fn post_signed_inbox( - app: Router, - uri: &str, - key_id: &str, - signed_body: &[u8], - delivered_body: impl Into, -) -> axum::response::Response { - post_signed_inbox_with_host( - app, - uri, - key_id, - signed_body, - delivered_body, - "127.0.0.1:3000", - ) - .await -} - -async fn post_signed_inbox_with_host( - app: Router, - uri: &str, - key_id: &str, - signed_body: &[u8], - delivered_body: impl Into, - host: &str, -) -> axum::response::Response { - let date = httpdate::fmt_http_date(std::time::SystemTime::now()); - let digest = create_sha256_digest_header(signed_body); - let headers = [ - ("content-type", "application/activity+json"), - ("date", date.as_str()), - ("digest", digest.as_str()), - ("host", host), - ]; - let signature = sign_draft_cavage( - &fixture_actor_key_pair().expect("load actor key fixture"), - key_id, - "POST", - uri, - &headers, - ) - .expect("sign inbox request"); - - app.oneshot( - Request::builder() - .method("POST") - .uri(uri) - .header(CONTENT_TYPE, "application/activity+json") - .header("date", date) - .header("digest", digest) - .header("host", host) - .header("signature", signature) - .body(delivered_body.into()) - .expect("valid request"), - ) - .await - .expect("response") -} - -#[tokio::test] -async fn valid_follow_reaches_core() { - let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; - let state = test_app_state(test_config()).expect("build app state"); - let response = post_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - "application/activity+json", - follow_body_for_inbox(&inbox), - ) - .await; - - assert_eq!(response.status(), StatusCode::ACCEPTED); - - { - let core = state.core.lock().expect("core lock"); - assert_eq!(core.state().followers().len(), 1); - assert_eq!( - core.state().followers()[0].follower.as_str(), - "https://remote.example/users/bob" - ); - assert_eq!( - core.state().followers()[0].following.as_str(), - "http://127.0.0.1:3000/users/alice" - ); - } - let followers = state - .store - .lock() - .expect("store lock") - .list_followers(&state.local_actor.id) - .expect("list followers"); - assert_eq!(followers[0].inbox.as_ref().unwrap().as_str(), inbox); - - let request = requests.recv().await.expect("receive Accept request"); - assert_eq!( - request.headers.get(CONTENT_TYPE).unwrap(), - "application/activity+json" - ); - let activity: serde_json::Value = - serde_json::from_slice(&request.body).expect("valid sent activity"); - assert_eq!(activity["type"], "Accept"); - assert_eq!(activity["actor"], "http://127.0.0.1:3000/users/alice"); - assert_eq!( - activity["object"]["id"], - "https://remote.example/activities/follow-1" - ); - inbox_server.abort(); -} - -#[tokio::test] -async fn resolves_id_only_follower_and_sends_accept() { - let (actor_id, mut requests, actor_server) = spawn_actor_server().await; - let state = test_app_state(test_config()).expect("build app state"); - let response = post_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - "application/activity+json", - id_only_follow_body(&actor_id), - ) - .await; - - assert_eq!(response.status(), StatusCode::ACCEPTED); - - let followers = state - .store - .lock() - .expect("store lock") - .list_followers(&state.local_actor.id) - .expect("list followers"); - assert_eq!(followers.len(), 1); - assert_eq!(followers[0].follower.as_str(), actor_id); - assert_eq!( - followers[0] - .inbox - .as_ref() - .expect("resolved inbox") - .as_str(), - format!("{}/inbox", actor_id.trim_end_matches("/users/bob")) - ); - - let request = requests.recv().await.expect("receive Accept request"); - assert!(request.headers.contains_key("signature")); - let activity: serde_json::Value = - serde_json::from_slice(&request.body).expect("valid sent activity"); - assert_eq!(activity["type"], "Accept"); - assert_eq!(activity["object"]["actor"]["id"], actor_id); - actor_server.abort(); -} - -#[tokio::test] -async fn verifies_signed_id_only_follow() { - let (actor_id, mut requests, actor_server) = spawn_actor_server().await; - let mut config = test_config(); - config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; - let state = test_app_state(config).expect("build app state"); - let body = id_only_follow_body(&actor_id); - let response = post_signed_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - &format!("{actor_id}#main-key"), - &body, - body.clone(), - ) - .await; - - assert_eq!(response.status(), StatusCode::ACCEPTED); - assert_eq!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .len(), - 1 - ); - requests.recv().await.expect("receive Accept request"); - actor_server.abort(); -} - -#[tokio::test] -async fn signed_follow_rejects_host_for_another_authority() { - let (actor_id, _requests, actor_server) = spawn_actor_server().await; - let mut config = test_config(); - config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; - let state = test_app_state(config).expect("build app state"); - let body = id_only_follow_body(&actor_id); - let response = post_signed_inbox_with_host( - router_with_state(state.clone()), - "/users/alice/inbox", - &format!("{actor_id}#main-key"), - &body, - body.clone(), - "other.example", - ) - .await; - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - assert!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); - actor_server.abort(); -} - -#[tokio::test] -async fn verifies_signed_follow_with_independent_key_id() { - let (actor_id, key_id, mut requests, actor_server) = - spawn_actor_server_with_separate_key().await; - let mut config = test_config(); - config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; - let state = test_app_state(config).expect("build app state"); - let body = id_only_follow_body(&actor_id); - let response = post_signed_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - &key_id, - &body, - body.clone(), - ) - .await; - - assert_eq!(response.status(), StatusCode::ACCEPTED); - assert_eq!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .len(), - 1 - ); - requests.recv().await.expect("receive Accept request"); - actor_server.abort(); -} - -#[tokio::test] -async fn signed_undo_follow_removes_persisted_follower() { - let (actor_id, mut requests, actor_server) = spawn_actor_server().await; - let mut config = test_config(); - config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; - let state = test_app_state(config).expect("build app state"); - let key_id = format!("{actor_id}#main-key"); - let follow_body = id_only_follow_body(&actor_id); - let follow_response = post_signed_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - &key_id, - &follow_body, - follow_body.clone(), - ) - .await; - assert_eq!(follow_response.status(), StatusCode::ACCEPTED); - requests.recv().await.expect("receive Accept request"); - - let undo_body = undo_follow_body(&actor_id, &actor_id); - let undo_response = post_signed_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - &key_id, - &undo_body, - undo_body.clone(), - ) - .await; - - assert_eq!(undo_response.status(), StatusCode::ACCEPTED); - assert!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); - assert!( - state - .store - .lock() - .expect("store lock") - .list_followers(&state.local_actor.id) - .expect("list followers") - .is_empty() - ); - actor_server.abort(); -} - -#[tokio::test] -async fn signed_undo_follow_rejects_actor_that_does_not_own_follow() { - let (actor_id, mut requests, actor_server) = spawn_actor_server().await; - let mut config = test_config(); - config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; - let state = test_app_state(config).expect("build app state"); - let key_id = format!("{actor_id}#main-key"); - let follow_body = id_only_follow_body(&actor_id); - let follow_response = post_signed_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - &key_id, - &follow_body, - follow_body.clone(), - ) - .await; - assert_eq!(follow_response.status(), StatusCode::ACCEPTED); - requests.recv().await.expect("receive Accept request"); - - let undo_body = undo_follow_body(&actor_id, "https://remote.example/users/mallory"); - let undo_response = post_signed_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - &key_id, - &undo_body, - undo_body.clone(), - ) - .await; - - assert_eq!(undo_response.status(), StatusCode::UNAUTHORIZED); - assert_eq!( - state - .store - .lock() - .expect("store lock") - .list_followers(&state.local_actor.id) - .expect("list followers") - .len(), - 1 - ); - actor_server.abort(); -} - -#[tokio::test] -async fn signed_follow_rejects_tampered_body() { - let (actor_id, _requests, actor_server) = spawn_actor_server().await; - let mut config = test_config(); - config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; - let state = test_app_state(config).expect("build app state"); - let signed_body = id_only_follow_body(&actor_id); - let delivered_body = id_only_follow_body("https://attacker.example/users/mallory"); - let response = post_signed_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - &format!("{actor_id}#main-key"), - &signed_body, - delivered_body, - ) - .await; - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - assert!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); - actor_server.abort(); -} - -#[tokio::test] -async fn signed_follow_rejects_actor_different_from_key_owner() { - let (actor_id, _requests, actor_server) = spawn_actor_server().await; - let mut config = test_config(); - config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; - let state = test_app_state(config).expect("build app state"); - let body = id_only_follow_body("https://attacker.example/users/mallory"); - let response = post_signed_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - &format!("{actor_id}#main-key"), - &body, - body.clone(), - ) - .await; - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - assert!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); - actor_server.abort(); -} - -#[tokio::test] -async fn send_failure_returns_bad_gateway_after_core_handling() { - let (inbox, mut requests, inbox_server) = - spawn_inbox_server(StatusCode::INTERNAL_SERVER_ERROR).await; - let state = test_app_state(test_config()).expect("build app state"); - let response = post_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - "application/activity+json", - follow_body_for_inbox(&inbox), - ) - .await; - - assert_eq!(response.status(), StatusCode::BAD_GATEWAY); - assert_eq!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .len(), - 1 - ); - requests.recv().await.expect("receive failed request"); - inbox_server.abort(); -} - -#[tokio::test] -async fn require_signed_rejects_unsigned_follow_before_core() { - let mut config = test_config(); - config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; - let state = test_app_state(config).expect("build app state"); - let response = post_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - "application/activity+json", - follow_body(), - ) - .await; - - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - assert!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); -} - -#[tokio::test] -async fn rejects_unknown_inbox_actor() { - let state = test_app_state(test_config()).expect("build app state"); - let response = post_inbox( - router_with_state(state.clone()), - "/users/bob/inbox", - "application/activity+json", - follow_body(), - ) - .await; - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - assert!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); -} - -#[tokio::test] -async fn rejects_unsupported_content_type() { - for content_type in [ - "application/json", - "application/activity+jsonp", - "not a media type", - ] { - let state = test_app_state(test_config()).expect("build app state"); - let response = post_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - content_type, - follow_body(), - ) - .await; - - assert_eq!( - response.status(), - StatusCode::UNSUPPORTED_MEDIA_TYPE, - "Content-Type: {content_type}" - ); - assert!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); - } -} - -#[tokio::test] -async fn accepts_case_insensitive_content_type_with_parameters() { - for content_type in [ - "Application/Activity+JSON; Charset=UTF-8", - "Application/LD+JSON; Profile=\"https://www.w3.org/ns/activitystreams\"", - ] { - let state = test_app_state(test_config()).expect("build app state"); - let response = post_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - content_type, - "{not json", - ) - .await; - - assert_eq!( - response.status(), - StatusCode::BAD_REQUEST, - "Content-Type: {content_type}" - ); - } -} - -#[tokio::test] -async fn rejects_malformed_json() { - let state = test_app_state(test_config()).expect("build app state"); - let response = post_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - "application/activity+json", - "{not json", - ) - .await; - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); -} - -#[tokio::test] -async fn ignores_unsupported_activity_without_mutating_core() { - let state = test_app_state(test_config()).expect("build app state"); - let body = serde_json::to_vec(&json!({ - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Create", - "id": "https://remote.example/activities/create-1", - "actor": "https://remote.example/users/bob", - "object": { - "type": "Note", - "id": "https://remote.example/notes/1" - } - })) - .expect("serialize create"); - let response = post_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - "application/activity+json", - body, - ) - .await; - - assert_eq!(response.status(), StatusCode::ACCEPTED); - assert!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); -} - -#[tokio::test] -async fn ignores_undo_of_unsupported_activity_without_mutating_core() { - let state = test_app_state(test_config()).expect("build app state"); - let body = serde_json::to_vec(&json!({ - "@context": "https://www.w3.org/ns/activitystreams", - "type": "Undo", - "id": "https://remote.example/activities/undo-like-1", - "actor": "https://remote.example/users/bob", - "object": { - "type": "Like", - "id": "https://remote.example/activities/like-1", - "actor": "https://remote.example/users/bob", - "object": "http://127.0.0.1:3000/users/alice/notes/1" - } - })) - .expect("serialize Undo Like"); - let response = post_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - "application/activity+json", - body, - ) - .await; - - assert_eq!(response.status(), StatusCode::ACCEPTED); - assert!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); -} - -#[tokio::test] -async fn rejects_oversized_inbox_body() { - let response = post_inbox( - test_router(test_config()).expect("build router"), - "/users/alice/inbox", - "application/activity+json", - vec![b' '; 1_048_577], - ) - .await; - - assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); -} - -#[tokio::test] -async fn sqlite_storage_persists_followers_across_app_state_reopen() { - let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; - let path = temporary_database_path("feder-runtime-server-test"); - let mut config = test_config(); - config.storage = StorageConfig::Sqlite { path: path.clone() }; - let state = test_app_state(config).expect("build app state"); - let response = post_inbox( - router_with_state(state.clone()), - "/users/alice/inbox", - "application/activity+json", - follow_body_for_inbox(&inbox), - ) - .await; - - assert_eq!(response.status(), StatusCode::ACCEPTED); - requests.recv().await.expect("receive Accept request"); - inbox_server.abort(); - drop(state); - - let mut config = test_config(); - config.storage = StorageConfig::Sqlite { path: path.clone() }; - let state = test_app_state(config).expect("reopen app state"); - let followers = state - .store - .lock() - .expect("store lock") - .list_followers( - &"http://127.0.0.1:3000/users/alice" - .parse() - .expect("valid IRI"), - ) - .expect("list followers"); - - assert_eq!(followers.len(), 1); - assert_eq!( - followers[0].follower.as_str(), - "https://remote.example/users/bob" - ); - - drop(state); - let _ = std::fs::remove_file(path); -} diff --git a/crates/feder-runtime-server/tests/cases/object.rs b/crates/feder-runtime-server/tests/cases/object.rs deleted file mode 100644 index 55729d3..0000000 --- a/crates/feder-runtime-server/tests/cases/object.rs +++ /dev/null @@ -1,237 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use axum::{ - Router, - body::{Body, to_bytes}, - http::{Request, StatusCode, header}, -}; -use feder_core::{Action, Object, PUBLIC_COLLECTION, StoreObject}; -use feder_runtime_server::{app::router_with_state, config::StorageConfig, storage::RuntimeStore}; -use feder_vocab::{Iri, Note, Reference, References}; -use serde_json::Value; -use tower::ServiceExt; - -use crate::common::{temporary_database_path, test_app_state, test_config, test_router}; - -fn iri(value: &str) -> Iri { - value.parse().expect("valid test IRI") -} - -fn stored_note() -> Note { - let mut note = Note::new(iri("http://127.0.0.1:3000/users/alice/posts/1")); - note.attributed_to = Some(Reference::id(iri("http://127.0.0.1:3000/users/alice"))); - note.to = References::one(iri(PUBLIC_COLLECTION)); - note.cc = References::one(iri("http://127.0.0.1:3000/users/alice/followers")); - note.content = Some("Hello from Feder.".to_string()); - note.media_type = Some("text/html".to_string()); - note.published = Some("2026-07-21T00:00:00Z".to_string()); - note.url = Some(note.id.clone()); - note -} - -fn router_with_stored_note(note: Note) -> Router { - let state = test_app_state(test_config()).expect("build app state"); - state - .store - .lock() - .expect("store lock") - .persist_actions(&[Action::StoreObject(StoreObject { - object: Object::Note(note), - })]) - .expect("persist note"); - router_with_state(state) -} - -fn router_with_note() -> Router { - router_with_stored_note(stored_note()) -} - -async fn get_object(app: Router, uri: &str, accept: Option<&str>) -> axum::response::Response { - let mut request = Request::builder().uri(uri); - if let Some(accept) = accept { - request = request.header(header::ACCEPT, accept); - } - - app.oneshot(request.body(Body::empty()).expect("valid request")) - .await - .expect("response") -} - -#[tokio::test] -async fn returns_stored_note_with_activitypub_headers() { - let response = get_object( - router_with_note(), - "/users/alice/posts/1", - Some("application/activity+json"), - ) - .await; - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response.headers().get(header::CONTENT_TYPE).unwrap(), - "application/activity+json" - ); - assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); - - let body = to_bytes(response.into_body(), 4096) - .await - .expect("read response body"); - let json: Value = serde_json::from_slice(&body).expect("valid JSON"); - assert_eq!(json["type"], "Note"); - assert_eq!(json["id"], "http://127.0.0.1:3000/users/alice/posts/1"); - assert_eq!(json["content"], "Hello from Feder."); - assert_eq!(json["mediaType"], "text/html"); -} - -#[tokio::test] -async fn returns_note_when_public_is_in_cc() { - let mut note = stored_note(); - note.to = References::one(iri("https://remote.example/users/bob")); - note.cc = References::one(iri(PUBLIC_COLLECTION)); - - let response = get_object( - router_with_stored_note(note), - "/users/alice/posts/1", - Some("application/activity+json"), - ) - .await; - - assert_eq!(response.status(), StatusCode::OK); -} - -#[tokio::test] -async fn returns_not_found_for_direct_note() { - let mut note = stored_note(); - note.to = References::one(iri("https://remote.example/users/bob")); - note.cc = References::new(); - - let response = get_object( - router_with_stored_note(note), - "/users/alice/posts/1", - Some("application/activity+json"), - ) - .await; - - assert_eq!(response.status(), StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn returns_not_found_for_followers_only_note() { - let mut note = stored_note(); - note.to = References::one(iri("http://127.0.0.1:3000/users/alice/followers")); - note.cc = References::new(); - - let response = get_object( - router_with_stored_note(note), - "/users/alice/posts/1", - Some("application/activity+json"), - ) - .await; - - assert_eq!(response.status(), StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn returns_not_found_for_note_without_audience() { - let mut note = stored_note(); - note.to = References::new(); - note.cc = References::new(); - - let response = get_object( - router_with_stored_note(note), - "/users/alice/posts/1", - Some("application/activity+json"), - ) - .await; - - assert_eq!(response.status(), StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn rejects_note_request_when_html_is_preferred() { - let response = get_object( - router_with_note(), - "/users/alice/posts/1", - Some("text/html, application/activity+json;q=0.8"), - ) - .await; - - assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); - assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); -} - -#[tokio::test] -async fn rejects_note_request_without_activitypub_accept() { - let response = get_object(router_with_note(), "/users/alice/posts/1", None).await; - - assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE); - assert_eq!(response.headers().get(header::VARY).unwrap(), "Accept"); -} - -#[tokio::test] -async fn returns_not_found_for_unknown_note() { - let response = get_object( - router_with_note(), - "/users/alice/posts/unknown", - Some("text/html"), - ) - .await; - - assert_eq!(response.status(), StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn returns_note_after_store_reopen() { - let path = temporary_database_path("feder-object-route-test"); - { - let mut config = test_config(); - config.storage = StorageConfig::Sqlite { path: path.clone() }; - let state = test_app_state(config).expect("build app state"); - state - .store - .lock() - .expect("store lock") - .persist_actions(&[Action::StoreObject(StoreObject { - object: Object::Note(stored_note()), - })]) - .expect("persist note"); - } - - let mut config = test_config(); - config.storage = StorageConfig::Sqlite { path: path.clone() }; - let response = get_object( - test_router(config).expect("reopen router"), - "/users/alice/posts/1", - Some("application/activity+json"), - ) - .await; - - assert_eq!(response.status(), StatusCode::OK); - - let _ = std::fs::remove_file(path); -} - -#[tokio::test] -async fn returns_not_found_for_unknown_username() { - let response = get_object( - router_with_note(), - "/users/bob/posts/1", - Some("application/activity+json"), - ) - .await; - - assert_eq!(response.status(), StatusCode::NOT_FOUND); -} diff --git a/crates/feder-runtime-server/tests/cases/operation.rs b/crates/feder-runtime-server/tests/cases/operation.rs deleted file mode 100644 index ca25836..0000000 --- a/crates/feder-runtime-server/tests/cases/operation.rs +++ /dev/null @@ -1,308 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use axum::{ - Json, Router, - http::{StatusCode, header}, - routing::get, -}; -use feder_core::{Action, Object, Recipients, StoreFollower, UserCreateNote}; -use feder_runtime_server::{ - Error, actor::ActorResolveError, config::StorageConfig, send::SendError, storage::RuntimeStore, -}; -use feder_vocab::{Actor, Endpoints, Iri, Reference}; -use tokio::{sync::mpsc::error::TryRecvError, task::JoinHandle}; - -use crate::common::{spawn_inbox_server, temporary_database_path, test_app_state, test_config}; - -fn iri(value: &str) -> Iri { - value.parse().expect("valid test IRI") -} - -fn create_note_input() -> UserCreateNote { - UserCreateNote { - note_id: iri("http://127.0.0.1:3000/users/alice/posts/1"), - create_id: iri("http://127.0.0.1:3000/users/alice/activities/create/1"), - actor: Reference::id(iri("http://127.0.0.1:3000/users/alice")), - to: feder_vocab::References::one(iri("https://www.w3.org/ns/activitystreams#Public")), - cc: feder_vocab::References::one(iri("http://127.0.0.1:3000/users/alice/followers")), - content: "Hello from Feder.".to_string(), - media_type: Some("text/html".to_string()), - published: Some("2026-07-21T00:00:00Z".to_string()), - url: Some(iri("http://127.0.0.1:3000/@alice/1")), - } -} - -fn store_follower(state: &feder_runtime_server::AppState, remote_actor_id: &str, inbox: &str) { - store_follower_with_shared_inbox(state, remote_actor_id, inbox, None); -} - -fn store_follower_with_shared_inbox( - state: &feder_runtime_server::AppState, - remote_actor_id: &str, - inbox: &str, - shared_inbox: Option<&str>, -) { - let remote_actor_id = iri(remote_actor_id); - let mut remote_actor = Actor::person( - remote_actor_id.clone(), - iri(inbox), - iri(&format!("{remote_actor_id}/outbox")), - ); - remote_actor.endpoints = shared_inbox.map(|shared_inbox| Endpoints { - shared_inbox: Some(iri(shared_inbox)), - }); - state - .store - .lock() - .expect("store lock") - .persist_actions(&[Action::StoreFollower(StoreFollower { - follower: Reference::object(remote_actor), - following: Reference::id(state.local_actor.id.clone()), - })]) - .expect("persist follower"); -} - -async fn spawn_actor_server(inbox: &str) -> (Iri, Iri, JoinHandle<()>) { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind actor server"); - let address = listener.local_addr().expect("actor server address"); - let actor_id = iri(&format!("http://{address}/users/bob")); - let missing_actor_id = iri(&format!("http://{address}/users/missing")); - let actor = Actor::person( - actor_id.clone(), - iri(inbox), - iri(&format!("http://{address}/users/bob/outbox")), - ); - let app = Router::new().route( - "/users/bob", - get(move || { - let actor = actor.clone(); - async move { - ( - [(header::CONTENT_TYPE, "application/activity+json")], - Json(actor), - ) - } - }), - ); - let task = tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("serve actor endpoint"); - }); - - (actor_id, missing_actor_id, task) -} - -#[tokio::test] -async fn create_note_persists_and_delivers_the_core_actions() { - let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; - let state = test_app_state(test_config()).expect("build app state"); - store_follower(&state, "https://remote.example/users/bob", &inbox); - - let result = state - .create_note(create_note_input()) - .await - .expect("create note"); - - assert_eq!(result.actions.len(), 2); - assert!(matches!(result.actions[0], Action::StoreObject(_))); - assert!(matches!( - &result.actions[1], - Action::SendActivity(send) - if matches!(&send.recipients, Recipients::Followers(_)) - )); - - let stored = state - .store - .lock() - .expect("store lock") - .load_object(&iri("http://127.0.0.1:3000/users/alice/posts/1")) - .expect("load note") - .expect("stored note"); - let Object::Note(note) = stored else { - panic!("expected stored Note"); - }; - assert_eq!(note.content.as_deref(), Some("Hello from Feder.")); - - let request = requests.recv().await.expect("receive Create request"); - let activity: serde_json::Value = - serde_json::from_slice(&request.body).expect("valid Create activity"); - assert_eq!(activity["type"], "Create"); - assert_eq!( - activity["to"], - "https://www.w3.org/ns/activitystreams#Public" - ); - assert_eq!( - activity["cc"], - "http://127.0.0.1:3000/users/alice/followers" - ); - assert_eq!(activity["object"]["id"], note.id.as_str()); - assert_eq!(activity["object"]["to"], activity["to"]); - assert_eq!(activity["object"]["cc"], activity["cc"]); - assert_eq!(activity["object"]["mediaType"], "text/html"); - assert_eq!(activity["object"]["url"], "http://127.0.0.1:3000/@alice/1"); - inbox_server.abort(); -} - -#[tokio::test] -async fn create_note_delivers_to_each_persisted_follower() { - let (bob_inbox, mut bob_requests, bob_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; - let (carol_inbox, mut carol_requests, carol_server) = - spawn_inbox_server(StatusCode::ACCEPTED).await; - let state = test_app_state(test_config()).expect("build app state"); - store_follower(&state, "https://remote.example/users/bob", &bob_inbox); - store_follower(&state, "https://another.example/users/carol", &carol_inbox); - - state - .create_note(create_note_input()) - .await - .expect("create note"); - - bob_requests.recv().await.expect("receive Bob delivery"); - carol_requests.recv().await.expect("receive Carol delivery"); - bob_server.abort(); - carol_server.abort(); -} - -#[tokio::test] -async fn create_note_delivers_once_to_shared_inbox_when_direct_actor_is_also_a_follower() { - let (personal_inbox, mut personal_requests, personal_inbox_server) = - spawn_inbox_server(StatusCode::ACCEPTED).await; - let (shared_inbox, mut shared_requests, shared_inbox_server) = - spawn_inbox_server(StatusCode::ACCEPTED).await; - let (actor_id, _missing_actor_id, actor_server) = spawn_actor_server(&personal_inbox).await; - let state = test_app_state(test_config()).expect("build app state"); - store_follower_with_shared_inbox( - &state, - actor_id.as_str(), - &personal_inbox, - Some(&shared_inbox), - ); - let mut input = create_note_input(); - input.to = feder_vocab::References::one(actor_id); - input.cc = feder_vocab::References::one( - state - .local_actor - .followers - .clone() - .expect("local actor has followers collection"), - ); - - state.create_note(input).await.expect("create note"); - - shared_requests - .recv() - .await - .expect("receive shared inbox delivery"); - assert!(matches!( - shared_requests.try_recv(), - Err(TryRecvError::Empty) - )); - assert!(matches!( - personal_requests.try_recv(), - Err(TryRecvError::Empty) - )); - actor_server.abort(); - shared_inbox_server.abort(); - personal_inbox_server.abort(); -} - -#[tokio::test] -async fn create_note_delivers_to_resolvable_actor_when_another_actor_cannot_be_resolved() { - let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; - let (actor_id, missing_actor_id, actor_server) = spawn_actor_server(&inbox).await; - let state = test_app_state(test_config()).expect("build app state"); - let mut input = create_note_input(); - input.to = feder_vocab::References::one(missing_actor_id); - input.cc = feder_vocab::References::one(actor_id); - - let result = state.create_note(input).await; - - assert!(matches!( - result, - Err(Error::ActorResolver( - ActorResolveError::UnsuccessfulStatus { .. } - )) - )); - requests - .recv() - .await - .expect("receive delivery for resolvable actor"); - actor_server.abort(); - inbox_server.abort(); -} - -#[tokio::test] -async fn create_note_keeps_the_persisted_object_when_delivery_fails() { - let (inbox, mut requests, inbox_server) = - spawn_inbox_server(StatusCode::INTERNAL_SERVER_ERROR).await; - let state = test_app_state(test_config()).expect("build app state"); - store_follower(&state, "https://remote.example/users/bob", &inbox); - - let result = state.create_note(create_note_input()).await; - - assert!(matches!( - result, - Err(Error::ActivitySender(SendError::UnsuccessfulStatus { .. })) - )); - requests.recv().await.expect("receive Create request"); - assert!( - state - .store - .lock() - .expect("store lock") - .load_object(&iri("http://127.0.0.1:3000/users/alice/posts/1")) - .expect("load note") - .is_some() - ); - inbox_server.abort(); -} - -#[tokio::test] -async fn create_note_resolves_persisted_followers_after_restart() { - let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; - let path = temporary_database_path("feder-create-note-recipients-test"); - let mut first_config = test_config(); - first_config.storage = StorageConfig::Sqlite { path: path.clone() }; - { - let state = test_app_state(first_config).expect("build app state"); - store_follower(&state, "https://remote.example/users/bob", &inbox); - } - - let mut second_config = test_config(); - second_config.storage = StorageConfig::Sqlite { path: path.clone() }; - let state = test_app_state(second_config).expect("reopen app state"); - assert!( - state - .core - .lock() - .expect("core lock") - .state() - .followers() - .is_empty() - ); - - state - .create_note(create_note_input()) - .await - .expect("create note after restart"); - - requests.recv().await.expect("receive Create request"); - inbox_server.abort(); - let _ = std::fs::remove_file(path); -} diff --git a/crates/feder-runtime-server/tests/cases/send.rs b/crates/feder-runtime-server/tests/cases/send.rs deleted file mode 100644 index 93bf867..0000000 --- a/crates/feder-runtime-server/tests/cases/send.rs +++ /dev/null @@ -1,234 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use axum::http::StatusCode; -use feder_core::{ - Activity, Recipients, SendActivity, - http_signatures::{ActorKeyPair, sign_draft_cavage}, -}; -use feder_runtime_server::{OutboundAddressPolicy, send::SendError}; -use feder_vocab::{Create, Follow, Note, Reference}; - -use crate::common::{spawn_inbox_server, test_activity_sender, test_activity_sender_with_policy}; - -fn create_note_send_action(inbox: &str) -> SendActivity { - let actor_id = "https://local.example/users/alice" - .parse() - .expect("valid actor IRI"); - let note = Note::new( - "https://local.example/notes/1" - .parse() - .expect("valid note IRI"), - ); - let create = Create::new( - "https://local.example/activities/create-1" - .parse() - .expect("valid activity IRI"), - Reference::id(actor_id), - Reference::object(note), - ); - - SendActivity { - activity: Activity::CreateNote(create), - recipients: Recipients::Inbox(inbox.parse().expect("valid inbox IRI")), - } -} - -fn follow_send_action(inbox: &str) -> SendActivity { - let follow = Follow::new( - "https://local.example/activities/follow-1" - .parse() - .expect("valid activity IRI"), - Reference::id( - "https://local.example/users/alice" - .parse() - .expect("valid actor IRI"), - ), - Reference::id( - "https://remote.example/users/bob" - .parse() - .expect("valid actor IRI"), - ), - ); - - SendActivity { - activity: Activity::Follow(follow), - recipients: Recipients::Inbox(inbox.parse().expect("valid inbox IRI")), - } -} - -#[tokio::test] -async fn sends_create_note_action() { - let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; - let inbox = format!("{inbox}?shared=true"); - let actions = [create_note_send_action(&inbox)]; - - test_activity_sender() - .send_actions(&actions) - .await - .expect("send Create activity"); - - let request = requests.recv().await.expect("receive Create request"); - assert_eq!(request.uri, "/inbox?shared=true"); - assert_eq!( - request.headers["host"], - inbox - .strip_prefix("http://") - .and_then(|value| value.split_once('/').map(|(authority, _)| authority)) - .expect("inbox authority") - ); - assert!(httpdate::parse_http_date(request.headers["date"].to_str().unwrap()).is_ok()); - assert_eq!( - request.headers["digest"], - feder_core::http_signatures::create_sha256_digest_header(&request.body) - ); - let signature = request.headers["signature"].to_str().unwrap(); - let headers = [ - ( - "content-type", - request.headers["content-type"].to_str().unwrap(), - ), - ("date", request.headers["date"].to_str().unwrap()), - ("digest", request.headers["digest"].to_str().unwrap()), - ("host", request.headers["host"].to_str().unwrap()), - ]; - let key_pair = ActorKeyPair::from_pem( - include_str!("../fixtures/rsa-private-key.pem").to_string(), - include_str!("../fixtures/rsa-public-key.pem").to_string(), - ) - .expect("load actor key pair fixture"); - let expected_signature = sign_draft_cavage( - &key_pair, - "https://local.example/users/alice#main-key", - "POST", - "/inbox?shared=true", - &headers, - ) - .expect("sign captured request"); - assert_eq!(signature, expected_signature); - let activity: serde_json::Value = - serde_json::from_slice(&request.body).expect("valid sent activity"); - assert_eq!(activity["type"], "Create"); - assert_eq!(activity["actor"], "https://local.example/users/alice"); - assert_eq!(activity["object"]["type"], "Note"); - inbox_server.abort(); -} - -#[tokio::test] -async fn sends_follow_action() { - let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; - - test_activity_sender() - .send_actions(&[follow_send_action(&inbox)]) - .await - .expect("send Follow activity"); - - let request = requests.recv().await.expect("receive Follow request"); - assert_eq!(request.uri, "/inbox"); - assert_eq!(request.headers["content-type"], "application/activity+json"); - assert!(request.headers.contains_key("signature")); - let activity: serde_json::Value = - serde_json::from_slice(&request.body).expect("valid sent activity"); - assert_eq!(activity["type"], "Follow"); - assert_eq!(activity["actor"], "https://local.example/users/alice"); - assert_eq!(activity["object"], "https://remote.example/users/bob"); - inbox_server.abort(); -} - -#[tokio::test] -async fn rejects_unresolved_follower_recipients() { - let mut action = create_note_send_action("https://remote.example/inbox"); - action.recipients = Recipients::Followers( - "https://local.example/users/alice" - .parse() - .expect("valid actor IRI"), - ); - - let result = test_activity_sender().send_actions(&[action]).await; - - assert!(matches!(result, Err(SendError::UnresolvedRecipients))); -} - -#[tokio::test] -async fn attempts_later_sends_after_failure() { - let (failed_inbox, mut failed_requests, failed_server) = - spawn_inbox_server(StatusCode::INTERNAL_SERVER_ERROR).await; - let (successful_inbox, mut successful_requests, successful_server) = - spawn_inbox_server(StatusCode::ACCEPTED).await; - let actions = [ - create_note_send_action(&failed_inbox), - create_note_send_action(&successful_inbox), - ]; - - let result = test_activity_sender().send_actions(&actions).await; - - assert!(result.is_err()); - failed_requests - .recv() - .await - .expect("receive failed request"); - successful_requests - .recv() - .await - .expect("receive later request"); - failed_server.abort(); - successful_server.abort(); -} - -#[tokio::test] -async fn blocks_literal_private_inbox_address() { - let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; - let actions = [create_note_send_action(&inbox)]; - - let result = test_activity_sender_with_policy(OutboundAddressPolicy::PublicOnly) - .send_actions(&actions) - .await; - - assert!(matches!( - result, - Err(SendError::PrivateInboxAddress { address, .. }) if address.is_loopback() - )); - assert!(requests.try_recv().is_err()); - inbox_server.abort(); -} - -#[tokio::test] -async fn blocks_hostname_resolving_to_private_address() { - let (inbox, mut requests, inbox_server) = spawn_inbox_server(StatusCode::ACCEPTED).await; - let inbox = inbox.replacen("127.0.0.1", "localhost", 1); - let actions = [create_note_send_action(&inbox)]; - - let result = test_activity_sender_with_policy(OutboundAddressPolicy::PublicOnly) - .send_actions(&actions) - .await; - - assert!(matches!(result, Err(SendError::Request(_)))); - assert!(requests.try_recv().is_err()); - inbox_server.abort(); -} - -#[tokio::test] -async fn blocks_special_use_ipv6_inbox_addresses() { - let sender = test_activity_sender_with_policy(OutboundAddressPolicy::PublicOnly); - - for address in ["100:0:0:1::1", "2001:2::1", "5f00::1"] { - let inbox = format!("http://[{address}]/inbox"); - let actions = [create_note_send_action(&inbox)]; - - let result = sender.send_actions(&actions).await; - - assert!(matches!(result, Err(SendError::PrivateInboxAddress { .. }))); - } -} diff --git a/crates/feder-runtime-server/tests/cases/webfinger.rs b/crates/feder-runtime-server/tests/cases/webfinger.rs deleted file mode 100644 index 3b6c963..0000000 --- a/crates/feder-runtime-server/tests/cases/webfinger.rs +++ /dev/null @@ -1,94 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use axum::{ - body::{Body, to_bytes}, - http::{Request, StatusCode, header}, -}; -use serde_json::Value; -use tower::ServiceExt; - -use crate::common::{test_config, test_router}; - -const WEBFINGER_PATH: &str = "/.well-known/webfinger?resource=acct:alice@127.0.0.1:3000"; - -#[tokio::test] -async fn returns_webfinger_descriptor_for_local_actor() { - let app = test_router(test_config()).expect("build router"); - - let response = app - .oneshot( - Request::builder() - .uri(WEBFINGER_PATH) - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response.headers().get(header::CONTENT_TYPE).unwrap(), - "application/jrd+json" - ); - - let body = to_bytes(response.into_body(), 1024) - .await - .expect("read response body"); - let json: Value = serde_json::from_slice(&body).expect("valid json"); - - assert_eq!(json["subject"], "acct:alice@127.0.0.1:3000"); - assert_eq!(json["aliases"][0], "http://127.0.0.1:3000/users/alice"); - assert_eq!(json["links"][0]["rel"], "self"); - assert_eq!(json["links"][0]["type"], "application/activity+json"); - assert_eq!( - json["links"][0]["href"], - "http://127.0.0.1:3000/users/alice" - ); -} - -#[tokio::test] -async fn rejects_missing_resource() { - let app = test_router(test_config()).expect("build router"); - - let response = app - .oneshot( - Request::builder() - .uri("/.well-known/webfinger") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::BAD_REQUEST); -} - -#[tokio::test] -async fn rejects_non_local_actor_resource() { - let app = test_router(test_config()).expect("build router"); - - let response = app - .oneshot( - Request::builder() - .uri("/.well-known/webfinger?resource=acct:bob@127.0.0.1:3000") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::NOT_FOUND); -} diff --git a/crates/feder-runtime-server/tests/common/mod.rs b/crates/feder-runtime-server/tests/common/mod.rs deleted file mode 100644 index 399fc04..0000000 --- a/crates/feder-runtime-server/tests/common/mod.rs +++ /dev/null @@ -1,179 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use std::sync::{Arc, Mutex}; - -use axum::{ - Router, - body::Bytes, - http::{HeaderMap, StatusCode, Uri}, - routing::post, -}; -use feder_core::{FederConfig, FederCore, http_signatures::ActorKeyPair}; -use feder_runtime_server::{ - Error, - actor::ActorResolver, - app::{AppState, router_with_state}, - config::{InboxAuthPolicy, OutboundAddressPolicy, RuntimeConfig, StorageConfig}, - send::ActivitySender, - storage::{RuntimeStore, SqliteStore}, -}; -use feder_vocab::{Actor, CryptographicKey, Reference}; -use iri_string::types::IriFragmentStr; -use tokio::{sync::mpsc, task::JoinHandle}; - -pub struct RecordedRequest { - pub headers: HeaderMap, - pub uri: Uri, - pub body: Bytes, -} - -pub async fn spawn_inbox_server( - response_status: StatusCode, -) -> (String, mpsc::Receiver, JoinHandle<()>) { - let (sender, receiver) = mpsc::channel(2); - let app = Router::new().route( - "/inbox", - post(move |headers: HeaderMap, uri: Uri, body: Bytes| { - let sender = sender.clone(); - async move { - sender - .send(RecordedRequest { headers, uri, body }) - .await - .expect("request receiver remains open"); - response_status - } - }), - ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind inbox server"); - let address = listener.local_addr().expect("inbox server address"); - let task = tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("serve inbox endpoint"); - }); - - (format!("http://{address}/inbox"), receiver, task) -} - -pub fn test_config() -> RuntimeConfig { - RuntimeConfig { - actor_id: "http://127.0.0.1:3000/users/alice" - .parse() - .expect("valid actor IRI"), - inbox: "http://127.0.0.1:3000/users/alice/inbox" - .parse() - .expect("valid inbox IRI"), - outbox: "http://127.0.0.1:3000/users/alice/outbox" - .parse() - .expect("valid outbox IRI"), - bind: "127.0.0.1:3000".parse().expect("valid bind address"), - username: "alice".to_string(), - handle_host: "127.0.0.1:3000".to_string(), - inbox_auth_policy: InboxAuthPolicy::AllowUnsignedInsecureDev, - outbound_address_policy: OutboundAddressPolicy::AllowPrivateAddress, - storage: StorageConfig::InMemory, - } -} - -pub fn test_app_state(config: RuntimeConfig) -> Result { - let mut actor = Actor::person(config.actor_id, config.inbox, config.outbox); - actor.preferred_username = Some(config.username.clone()); - actor.name = Some(config.username.clone()); - actor.followers = Some( - format!("{}/followers", actor.id.as_str().trim_end_matches('/')) - .parse() - .expect("valid followers IRI"), - ); - - let mut store = match &config.storage { - StorageConfig::InMemory => SqliteStore::open_in_memory()?, - StorageConfig::Sqlite { path } => SqliteStore::open(path)?, - }; - let actor_key_pair = match store.load_actor_key_pair(&actor.id)? { - Some(key_pair) => key_pair, - None => { - let key_pair = fixture_actor_key_pair()?; - store.insert_actor_key_pair(&actor.id, &key_pair)?; - key_pair - } - }; - let mut key_id = actor.id.clone(); - key_id.set_fragment(Some( - IriFragmentStr::new("main-key").expect("main-key is a valid IRI fragment"), - )); - actor.set_public_key(Reference::object(CryptographicKey::new( - key_id.clone(), - actor.id.clone(), - actor_key_pair.public_key_pem().to_string(), - ))); - let core = FederCore::new(FederConfig::new(actor.clone())); - let actor_key_pair = Arc::new(actor_key_pair); - let actor_resolver = ActorResolver::new(config.outbound_address_policy)?; - let activity_sender = ActivitySender::new( - actor_key_pair.clone(), - key_id.to_string(), - config.outbound_address_policy, - )?; - - Ok(AppState { - core: Arc::new(Mutex::new(core)), - store: Arc::new(Mutex::new(store)), - actor_key_pair, - actor_resolver, - activity_sender, - local_actor: actor, - username: config.username, - handle_host: config.handle_host, - inbox_auth_policy: config.inbox_auth_policy, - }) -} - -pub fn test_router(config: RuntimeConfig) -> Result { - Ok(router_with_state(test_app_state(config)?)) -} - -pub fn temporary_database_path(prefix: &str) -> std::path::PathBuf { - std::env::temp_dir().join(format!( - "{prefix}-{}-{}.sqlite3", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system time after unix epoch") - .as_nanos() - )) -} - -pub fn fixture_actor_key_pair() -> Result { - ActorKeyPair::from_pem( - include_str!("../fixtures/rsa-private-key.pem").to_string(), - include_str!("../fixtures/rsa-public-key.pem").to_string(), - ) -} - -pub fn test_activity_sender() -> ActivitySender { - test_activity_sender_with_policy(OutboundAddressPolicy::AllowPrivateAddress) -} - -pub fn test_activity_sender_with_policy(policy: OutboundAddressPolicy) -> ActivitySender { - ActivitySender::new( - Arc::new(fixture_actor_key_pair().expect("load actor key pair fixture")), - "https://local.example/users/alice#main-key".to_string(), - policy, - ) - .expect("build activity sender") -} diff --git a/crates/feder-runtime-server/tests/runtime.rs b/crates/feder-runtime-server/tests/runtime.rs deleted file mode 100644 index 7e1d253..0000000 --- a/crates/feder-runtime-server/tests/runtime.rs +++ /dev/null @@ -1,33 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -mod common; - -#[path = "cases/actor.rs"] -mod actor; -#[path = "cases/app.rs"] -mod app; -#[path = "cases/followers.rs"] -mod followers; -#[path = "cases/inbox.rs"] -mod inbox; -#[path = "cases/object.rs"] -mod object; -#[path = "cases/operation.rs"] -mod operation; -#[path = "cases/send.rs"] -mod send; -#[path = "cases/webfinger.rs"] -mod webfinger; diff --git a/crates/ref-feder-runtime-server/Cargo.toml b/crates/feder-server/Cargo.toml similarity index 79% rename from crates/ref-feder-runtime-server/Cargo.toml rename to crates/feder-server/Cargo.toml index 858c9a2..563ea8b 100644 --- a/crates/ref-feder-runtime-server/Cargo.toml +++ b/crates/feder-server/Cargo.toml @@ -1,7 +1,6 @@ [package] -name = "ref-feder-runtime-server" -description = "Experimental reference implementation of Feder's server runtime." -publish = false +name = "feder-server" +description = "ActivityPub server runtime for Feder on standard operating systems." version.workspace = true edition.workspace = true authors.workspace = true @@ -16,7 +15,7 @@ httpdate = "1" mime = "0.3.17" percent-encoding = "2.3.2" rand_core.workspace = true -ref-feder-core = { workspace = true, features = ["http-signatures"] } +feder-core = { workspace = true, features = ["http-signatures"] } reqwest = { version = "0.13.1", default-features = false, diff --git a/crates/ref-feder-runtime-server/src/actor.rs b/crates/feder-server/src/actor.rs similarity index 99% rename from crates/ref-feder-runtime-server/src/actor.rs rename to crates/feder-server/src/actor.rs index ae6784d..e84a677 100644 --- a/crates/ref-feder-runtime-server/src/actor.rs +++ b/crates/feder-server/src/actor.rs @@ -21,8 +21,8 @@ use axum::{ http::{HeaderMap, StatusCode, header}, response::{IntoResponse, Response}, }; +use feder_core::ActorDispatcher; use feder_vocab::{Actor, ActorType, CryptographicKey, Endpoints, Iri, Reference}; -use ref_feder_core::ActorDispatcher; use reqwest::{ Client, StatusCode as HttpStatusCode, Url, header::{ACCEPT, CONTENT_TYPE}, diff --git a/crates/ref-feder-runtime-server/src/config.rs b/crates/feder-server/src/config.rs similarity index 100% rename from crates/ref-feder-runtime-server/src/config.rs rename to crates/feder-server/src/config.rs diff --git a/crates/ref-feder-runtime-server/src/follow.rs b/crates/feder-server/src/follow.rs similarity index 97% rename from crates/ref-feder-runtime-server/src/follow.rs rename to crates/feder-server/src/follow.rs index 77973f2..443e9bf 100644 --- a/crates/ref-feder-runtime-server/src/follow.rs +++ b/crates/feder-server/src/follow.rs @@ -13,8 +13,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +use feder_core::{ActorDispatcher, follow::create_follow, storage::ServerStorage}; use feder_vocab::{Follow, Iri}; -use ref_feder_core::{ActorDispatcher, follow::create_follow, storage::ServerStorage}; use crate::{ActorResolveError, FederServer, send::SendError}; diff --git a/crates/ref-feder-runtime-server/src/followers.rs b/crates/feder-server/src/followers.rs similarity index 97% rename from crates/ref-feder-runtime-server/src/followers.rs rename to crates/feder-server/src/followers.rs index aa45eb4..cc38b5c 100644 --- a/crates/ref-feder-runtime-server/src/followers.rs +++ b/crates/feder-server/src/followers.rs @@ -21,8 +21,8 @@ use axum::{ http::{HeaderMap, StatusCode, header}, response::{IntoResponse, Response}, }; +use feder_core::{ActorDispatcher, storage::ServerStorage}; use feder_vocab::OrderedCollection; -use ref_feder_core::{ActorDispatcher, storage::ServerStorage}; use crate::{FederServer, negotiation::accepts_activitypub}; diff --git a/crates/ref-feder-runtime-server/src/inbox.rs b/crates/feder-server/src/inbox.rs similarity index 99% rename from crates/ref-feder-runtime-server/src/inbox.rs rename to crates/feder-server/src/inbox.rs index 4b56788..e6ccff5 100644 --- a/crates/ref-feder-runtime-server/src/inbox.rs +++ b/crates/feder-server/src/inbox.rs @@ -29,9 +29,7 @@ use axum::{ }, response::{IntoResponse, Response}, }; -use feder_vocab::{Accept, Actor, CryptographicKey, Follow, Iri, Reference, Undo}; -use mime::Mime; -use ref_feder_core::{ +use feder_core::{ ActorDispatcher, follow::{ AcceptFollowError, FollowError, PendingFollow, receive_accept_follow, receive_follow, @@ -40,6 +38,8 @@ use ref_feder_core::{ storage::ServerStorage, undo::{UndoFollowError, receive_undo_follow}, }; +use feder_vocab::{Accept, Actor, CryptographicKey, Follow, Iri, Reference, Undo}; +use mime::Mime; use serde_json::{Value, from_slice, from_value}; use crate::{ActorResolver, FederServer}; diff --git a/crates/ref-feder-runtime-server/src/lib.rs b/crates/feder-server/src/lib.rs similarity index 92% rename from crates/ref-feder-runtime-server/src/lib.rs rename to crates/feder-server/src/lib.rs index ad98eee..52a82f5 100644 --- a/crates/ref-feder-runtime-server/src/lib.rs +++ b/crates/feder-server/src/lib.rs @@ -13,11 +13,10 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! Experimental reference implementation of Feder's server runtime. +//! ActivityPub server runtime for Feder on standard operating systems. //! -//! This crate develops runtime orchestration against `ref-feder-core` while -//! the production `feder-runtime-server` remains operational. Its API is -//! intentionally unstable during the architecture refactoring. +//! This crate connects [`feder_core`] protocol decisions to Axum, SQLite, +//! remote actor resolution, HTTP signatures, and activity delivery. pub mod actor; pub mod config; pub mod follow; @@ -40,9 +39,9 @@ use axum::{ routing::{get, post}, }; pub use config::OutboundAddressPolicy; +pub use feder_core::ActorDispatcher; +use feder_core::storage::{NoteStore, ServerStorage}; pub use inbox::InboxAuthPolicy; -pub use ref_feder_core::ActorDispatcher; -use ref_feder_core::storage::{NoteStore, ServerStorage}; use crate::send::{ActivitySender, SendError}; diff --git a/crates/ref-feder-runtime-server/src/negotiation.rs b/crates/feder-server/src/negotiation.rs similarity index 100% rename from crates/ref-feder-runtime-server/src/negotiation.rs rename to crates/feder-server/src/negotiation.rs diff --git a/crates/ref-feder-runtime-server/src/note.rs b/crates/feder-server/src/note.rs similarity index 99% rename from crates/ref-feder-runtime-server/src/note.rs rename to crates/feder-server/src/note.rs index 6b396ce..b516d16 100644 --- a/crates/ref-feder-runtime-server/src/note.rs +++ b/crates/feder-server/src/note.rs @@ -15,12 +15,12 @@ use std::collections::HashSet; -use feder_vocab::Iri; -use ref_feder_core::{ +use feder_core::{ ActorDispatcher, note::{CreateNoteInput, CreateNoteOutcome, NoteRecipient, create_note}, storage::{FollowerDeliveryStore, NoteStore}, }; +use feder_vocab::Iri; use crate::{ActorResolveError, FederServer, send::SendError}; diff --git a/crates/ref-feder-runtime-server/src/object.rs b/crates/feder-server/src/object.rs similarity index 96% rename from crates/ref-feder-runtime-server/src/object.rs rename to crates/feder-server/src/object.rs index 50391e7..4101cf7 100644 --- a/crates/ref-feder-runtime-server/src/object.rs +++ b/crates/feder-server/src/object.rs @@ -21,8 +21,8 @@ use axum::{ http::{HeaderMap, StatusCode, header}, response::{IntoResponse, Response}, }; +use feder_core::{ActorDispatcher, note::is_public_note, storage::NoteStore}; use feder_vocab::Iri; -use ref_feder_core::{ActorDispatcher, note::is_public_note, storage::NoteStore}; use crate::{FederServer, negotiation::accepts_activitypub}; diff --git a/crates/ref-feder-runtime-server/src/send.rs b/crates/feder-server/src/send.rs similarity index 99% rename from crates/ref-feder-runtime-server/src/send.rs rename to crates/feder-server/src/send.rs index 8763467..960464a 100644 --- a/crates/ref-feder-runtime-server/src/send.rs +++ b/crates/feder-server/src/send.rs @@ -15,10 +15,10 @@ use std::time::SystemTime; -use feder_vocab::{Actor, Iri, Reference}; -use ref_feder_core::key::{ +use feder_core::key::{ ActorKeyPair, HttpSignatureError, create_sha256_digest_header, sign_draft_cavage, }; +use feder_vocab::{Actor, Iri, Reference}; use reqwest::{ Client, StatusCode, Url, header::{CONTENT_TYPE, DATE, HOST}, diff --git a/crates/ref-feder-runtime-server/src/storage/mod.rs b/crates/feder-server/src/storage/mod.rs similarity index 97% rename from crates/ref-feder-runtime-server/src/storage/mod.rs rename to crates/feder-server/src/storage/mod.rs index 9975e3d..1f1fc82 100644 --- a/crates/ref-feder-runtime-server/src/storage/mod.rs +++ b/crates/feder-server/src/storage/mod.rs @@ -17,7 +17,7 @@ mod sqlite; pub use sqlite::SqliteStore; -use ref_feder_core::key::KeyError; +use feder_core::key::KeyError; #[derive(Debug, thiserror::Error)] pub enum StoreError { diff --git a/crates/ref-feder-runtime-server/src/storage/sqlite.rs b/crates/feder-server/src/storage/sqlite.rs similarity index 99% rename from crates/ref-feder-runtime-server/src/storage/sqlite.rs rename to crates/feder-server/src/storage/sqlite.rs index c1e888e..93ea60e 100644 --- a/crates/ref-feder-runtime-server/src/storage/sqlite.rs +++ b/crates/feder-server/src/storage/sqlite.rs @@ -24,13 +24,13 @@ use std::{ os::unix::fs::{OpenOptionsExt, PermissionsExt}, }; -use feder_vocab::{Actor, Iri, Note}; -use rand_core::CryptoRngCore; -use ref_feder_core::{ +use feder_core::{ follow::PendingFollow, key::{ActorKeyPair, generate_actor_key_pair}, storage::{FollowerDeliveryStore, FollowerDeliveryTarget, NoteStore, ServerStorage, Storage}, }; +use feder_vocab::{Actor, Iri, Note}; +use rand_core::CryptoRngCore; use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params}; use super::StoreError; diff --git a/crates/ref-feder-runtime-server/src/url.rs b/crates/feder-server/src/url.rs similarity index 100% rename from crates/ref-feder-runtime-server/src/url.rs rename to crates/feder-server/src/url.rs diff --git a/crates/ref-feder-runtime-server/src/webfinger.rs b/crates/feder-server/src/webfinger.rs similarity index 98% rename from crates/ref-feder-runtime-server/src/webfinger.rs rename to crates/feder-server/src/webfinger.rs index d0e2ee3..e4c3040 100644 --- a/crates/ref-feder-runtime-server/src/webfinger.rs +++ b/crates/feder-server/src/webfinger.rs @@ -22,7 +22,7 @@ use axum::{ http::{StatusCode, header}, response::{IntoResponse, Response}, }; -use ref_feder_core::ActorDispatcher; +use feder_core::ActorDispatcher; use serde::{Deserialize, Serialize}; #[derive(Deserialize)] diff --git a/crates/ref-feder-runtime-server/tests/cases/actor.rs b/crates/feder-server/tests/cases/actor.rs similarity index 98% rename from crates/ref-feder-runtime-server/tests/cases/actor.rs rename to crates/feder-server/tests/cases/actor.rs index c629eba..a8c942b 100644 --- a/crates/ref-feder-runtime-server/tests/cases/actor.rs +++ b/crates/feder-server/tests/cases/actor.rs @@ -4,8 +4,8 @@ use axum::{ http::{Request, StatusCode, header}, routing::get, }; +use feder_server::{ActorResolveError, ActorResolver, OutboundAddressPolicy}; use feder_vocab::Actor; -use ref_feder_runtime_server::{ActorResolveError, ActorResolver, OutboundAddressPolicy}; use serde_json::Value; use tower::ServiceExt; diff --git a/crates/ref-feder-runtime-server/tests/cases/followers.rs b/crates/feder-server/tests/cases/followers.rs similarity index 98% rename from crates/ref-feder-runtime-server/tests/cases/followers.rs rename to crates/feder-server/tests/cases/followers.rs index 698e2d4..c1b2deb 100644 --- a/crates/ref-feder-runtime-server/tests/cases/followers.rs +++ b/crates/feder-server/tests/cases/followers.rs @@ -3,8 +3,8 @@ use axum::{ body::{Body, to_bytes}, http::{Request, StatusCode, header}, }; +use feder_core::storage::ServerStorage; use feder_vocab::Actor; -use ref_feder_core::storage::ServerStorage; use serde_json::Value; use tower::ServiceExt; diff --git a/crates/ref-feder-runtime-server/tests/cases/inbox.rs b/crates/feder-server/tests/cases/inbox.rs similarity index 98% rename from crates/ref-feder-runtime-server/tests/cases/inbox.rs rename to crates/feder-server/tests/cases/inbox.rs index fdab792..8602053 100644 --- a/crates/ref-feder-runtime-server/tests/cases/inbox.rs +++ b/crates/feder-server/tests/cases/inbox.rs @@ -4,8 +4,8 @@ use axum::{ http::{HeaderMap, Request, StatusCode, Uri, header::CONTENT_TYPE}, routing::{get, post}, }; -use ref_feder_core::key::{create_sha256_digest_header, sign_draft_cavage}; -use ref_feder_runtime_server::InboxAuthPolicy; +use feder_core::key::{create_sha256_digest_header, sign_draft_cavage}; +use feder_server::InboxAuthPolicy; use serde_json::{Value, json}; use tower::ServiceExt; diff --git a/crates/ref-feder-runtime-server/tests/cases/object.rs b/crates/feder-server/tests/cases/object.rs similarity index 98% rename from crates/ref-feder-runtime-server/tests/cases/object.rs rename to crates/feder-server/tests/cases/object.rs index d00bd2e..f61930d 100644 --- a/crates/ref-feder-runtime-server/tests/cases/object.rs +++ b/crates/feder-server/tests/cases/object.rs @@ -3,8 +3,8 @@ use axum::{ body::{Body, to_bytes}, http::{Request, StatusCode, header}, }; +use feder_core::{note::PUBLIC_COLLECTION, storage::NoteStore}; use feder_vocab::{Note, Reference, References}; -use ref_feder_core::{note::PUBLIC_COLLECTION, storage::NoteStore}; use serde_json::Value; use tower::ServiceExt; diff --git a/crates/ref-feder-runtime-server/tests/cases/operation.rs b/crates/feder-server/tests/cases/operation.rs similarity index 97% rename from crates/ref-feder-runtime-server/tests/cases/operation.rs rename to crates/feder-server/tests/cases/operation.rs index 37e1805..e9a427f 100644 --- a/crates/ref-feder-runtime-server/tests/cases/operation.rs +++ b/crates/feder-server/tests/cases/operation.rs @@ -1,12 +1,12 @@ use std::sync::Arc; use axum::{Json, Router, body::Body, http::header::CONTENT_TYPE, routing::get}; -use feder_vocab::{Actor, Iri, References}; -use ref_feder_core::{ +use feder_core::{ note::{CreateNoteInput, PUBLIC_COLLECTION}, storage::ServerStorage, }; -use ref_feder_runtime_server::{InboxAuthPolicy, build_router_with_state, storage::SqliteStore}; +use feder_server::{InboxAuthPolicy, build_router_with_state, storage::SqliteStore}; +use feder_vocab::{Actor, Iri, References}; use serde_json::{Value, json}; use tower::ServiceExt; diff --git a/crates/ref-feder-runtime-server/tests/cases/send.rs b/crates/feder-server/tests/cases/send.rs similarity index 96% rename from crates/ref-feder-runtime-server/tests/cases/send.rs rename to crates/feder-server/tests/cases/send.rs index 209bc28..fc8c1f8 100644 --- a/crates/ref-feder-runtime-server/tests/cases/send.rs +++ b/crates/feder-server/tests/cases/send.rs @@ -1,10 +1,10 @@ use axum::http::StatusCode; -use feder_vocab::{Follow, Reference}; -use ref_feder_core::key::verify_draft_cavage; -use ref_feder_runtime_server::{ +use feder_core::key::verify_draft_cavage; +use feder_server::{ OutboundAddressPolicy, send::{ActivitySender, SendError}, }; +use feder_vocab::{Follow, Reference}; use crate::common::{actor_key_pair, iri, local_actor, spawn_inbox_server}; @@ -35,7 +35,7 @@ async fn sends_signed_activity_to_exact_inbox_target() { assert_eq!(request.headers["content-type"], "application/activity+json"); assert_eq!( request.headers["digest"], - ref_feder_core::key::create_sha256_digest_header(&request.body) + feder_core::key::create_sha256_digest_header(&request.body) ); let signature = request.headers["signature"] .to_str() diff --git a/crates/ref-feder-runtime-server/tests/cases/webfinger.rs b/crates/feder-server/tests/cases/webfinger.rs similarity index 100% rename from crates/ref-feder-runtime-server/tests/cases/webfinger.rs rename to crates/feder-server/tests/cases/webfinger.rs diff --git a/crates/ref-feder-runtime-server/tests/common/mod.rs b/crates/feder-server/tests/common/mod.rs similarity index 98% rename from crates/ref-feder-runtime-server/tests/common/mod.rs rename to crates/feder-server/tests/common/mod.rs index 62d11d0..6d1928d 100644 --- a/crates/ref-feder-runtime-server/tests/common/mod.rs +++ b/crates/feder-server/tests/common/mod.rs @@ -6,11 +6,11 @@ use axum::{ http::{HeaderMap, StatusCode, Uri}, routing::post, }; -use feder_vocab::{Actor, CryptographicKey, Endpoints, Iri, Reference}; -use ref_feder_core::{ActorDispatcher, key::ActorKeyPair}; -use ref_feder_runtime_server::{ +use feder_core::{ActorDispatcher, key::ActorKeyPair}; +use feder_server::{ FederServer, InboxAuthPolicy, OutboundAddressPolicy, build_router, storage::SqliteStore, }; +use feder_vocab::{Actor, CryptographicKey, Endpoints, Iri, Reference}; use tokio::{sync::mpsc, task::JoinHandle}; pub const IDENTIFIER: &str = "alice"; diff --git a/crates/feder-runtime-server/tests/fixtures/rsa-private-key.pem b/crates/feder-server/tests/fixtures/rsa-private-key.pem similarity index 100% rename from crates/feder-runtime-server/tests/fixtures/rsa-private-key.pem rename to crates/feder-server/tests/fixtures/rsa-private-key.pem diff --git a/crates/feder-runtime-server/tests/fixtures/rsa-public-key.pem b/crates/feder-server/tests/fixtures/rsa-public-key.pem similarity index 100% rename from crates/feder-runtime-server/tests/fixtures/rsa-public-key.pem rename to crates/feder-server/tests/fixtures/rsa-public-key.pem diff --git a/crates/ref-feder-runtime-server/tests/runtime.rs b/crates/feder-server/tests/runtime.rs similarity index 100% rename from crates/ref-feder-runtime-server/tests/runtime.rs rename to crates/feder-server/tests/runtime.rs diff --git a/crates/ref-feder-runtime-server/tests/storage.rs b/crates/feder-server/tests/storage.rs similarity index 98% rename from crates/ref-feder-runtime-server/tests/storage.rs rename to crates/feder-server/tests/storage.rs index 2deae40..936b71a 100644 --- a/crates/ref-feder-runtime-server/tests/storage.rs +++ b/crates/feder-server/tests/storage.rs @@ -1,11 +1,11 @@ -use feder_vocab::{Actor, Endpoints, Iri, Note, Reference}; -use rand_core::OsRng; -use ref_feder_core::{ +use feder_core::{ follow::PendingFollow, key::ActorKeyPair, storage::{FollowerDeliveryStore, FollowerDeliveryTarget, NoteStore, ServerStorage}, }; -use ref_feder_runtime_server::storage::SqliteStore; +use feder_server::storage::SqliteStore; +use feder_vocab::{Actor, Endpoints, Iri, Note, Reference}; +use rand_core::OsRng; const PRIVATE_KEY_PEM: &str = include_str!("fixtures/rsa-private-key.pem"); const PUBLIC_KEY_PEM: &str = include_str!("fixtures/rsa-public-key.pem"); diff --git a/crates/ref-feder-core/Cargo.toml b/crates/ref-feder-core/Cargo.toml deleted file mode 100644 index 81fe410..0000000 --- a/crates/ref-feder-core/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "ref-feder-core" -description = "Experimental reference implementation of Feder's protocol core." -publish = false -version.workspace = true -edition.workspace = true -authors.workspace = true -license.workspace = true -homepage.workspace = true -repository.workspace = true - -[features] -http-signatures = ["dep:base64", "dep:rsa", "dep:zeroize"] - -[dependencies] -base64 = { workspace = true, optional = true } -feder-vocab.workspace = true -rsa = { workspace = true, optional = true } -zeroize = { workspace = true, optional = true } - -[lints] -workspace = true - -[[test]] -name = "key" -path = "tests/key.rs" -required-features = ["http-signatures"] diff --git a/crates/ref-feder-core/src/lib.rs b/crates/ref-feder-core/src/lib.rs deleted file mode 100644 index 9001296..0000000 --- a/crates/ref-feder-core/src/lib.rs +++ /dev/null @@ -1,44 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -//! Experimental reference implementation of Feder's protocol core. -//! -//! This crate develops the replacement architecture alongside `feder-core`. -//! Its API is intentionally unstable until the core ownership boundary has -//! been proven against the existing runtime and Federog. -#![no_std] - -extern crate alloc; - -pub use feder_vocab as vocab; -use feder_vocab::{Actor, Iri}; - -pub mod follow; -#[cfg(feature = "http-signatures")] -pub mod key; -pub mod note; -pub mod storage; -pub mod undo; - -#[derive(Debug, Default)] -pub struct FederCore; - -pub trait ActorDispatcher { - type Error; - - fn get_actor(&self, identifier: &str) -> Result, Self::Error>; - - fn get_actor_by_id(&self, actor_id: &Iri) -> Result, Self::Error>; -} diff --git a/crates/ref-feder-core/tests/fixtures/rsa-other-public-key.pem b/crates/ref-feder-core/tests/fixtures/rsa-other-public-key.pem deleted file mode 100644 index 86fa3a6..0000000 --- a/crates/ref-feder-core/tests/fixtures/rsa-other-public-key.pem +++ /dev/null @@ -1,9 +0,0 @@ ------BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAo9l6PexyP7BtQOAvgpFV -Sb+iFq5jPL3VoFdr1jevGhBHslqP881CUQHOsSzjJFDkHFTHhN30uuucceAajt9N -IpPAoS9Ft6ouYDheg/MZEPNvbnUuen7IAx1hOSAGLzdXKHIFbQp9eBEcc8pKXuR5 -mhowALUjioqA7Ax3N6iw7uI7ANzrMX+VSmCmuHgUcXbJUy/4SqCmI1M3xyKvxEFh -+KPQCMYRpIh02j6tFU5k7P5aynDitPwqXJr1w+NVcLl1qh4y4tGQ6GpscS5KGEqd -sojcVZuweV0hku4fT5onA2WAd4CWZVUVM3gBau1OzTgyCajjbgWURQjH9DrAHSrO -FQIDAQAB ------END PUBLIC KEY----- diff --git a/crates/ref-feder-core/tests/fixtures/rsa-private-key.pem b/crates/ref-feder-core/tests/fixtures/rsa-private-key.pem deleted file mode 100644 index 0355a1e..0000000 --- a/crates/ref-feder-core/tests/fixtures/rsa-private-key.pem +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDSXUHF1268trok -ZnAB9gVqnh4tL5gZc3WBSDIeNG/1niVRMVhMZ6kvLwv+WVqoyphMvTajUgeXAHIH -WIrUgJNQ8N7JhoDpqplt7+q+09l0treTRInuc+A6vjidawyMUFS1qxDK73JHmFO7 -5w+rbQneikedkGUIXL3vh7B1iOjz6o6f7g0cL0ykiVG05WhkWedY3iuHOYzL8YG+ -VazI1/7jBptlVs0OMg50y4jRogTwkmKoqUCae5F3kER2F5nw51a8D31l+KcOCNJF -7ZzPDrqaJgDXu/G1JaGSOuIizZS1d5BW6Pm6ftzv9UzOzeKtK4zEsLeBZhi0FrtI -5vKhe/l1AgMBAAECggEAMKIhPNstrYDEH3q0PevR/EBiYxlr/URRX+pgNdXzJVJi -t7bj/kP/29nxWKPxPvEZjTI4UcE64njWo+afL/oqtK1/IBGRt5O6hW1QNL5W+XHt -lmUjy0YsSoBkJ9aSD9VZhCdwii4Z2j3368rDN2NNww5ueJmjle+U9K3GyKF2k78U -2mczUoJ6LEJiAOpKVrrIdLEdb/NBE8b2lwqITHaL2Pidj9cbBfrU16pb1RG6z7Eb -Xv2AtobemO54oz+RnzLk6ApY9v27r5uXE/61WKN9iWFNHncoK/l95W/lktoCJLwM -3cMVZVPtEJdRCpEPbyfXG6pyAmgIjJDUj2lSwnr2kQKBgQDrkrVlZ+5H3qxoc6j7 -K4aWl1T7n4IHu3mGLE6ByLMHhjirq8SlG9tqJ09kpeprT5S+PtQiyVhud8PgejNJ -9eo/pU4ybE9VdAFacRCAgKN+jIFM6TniyLraNUK9+tHX53ca6Aolus8yiEaUQln5 -n8J9wrS18JV8T+9+OOJZPwql5QKBgQDkmvWOXD+wb23Sb4F8aD0WXOIqQacSHkjy -Fllp1FslvhgMGDIfwCAHDIT9osE4aK5Yy+88Fg5MGT0kgU0viguJ+fJheJ3oEwb+ -/wceEO11ts6+vaS9ZqAQO95lM+XoV6PU3uoyWE9uLYkxJd6Siz7BGUzl/TzZbWsw -ZzWD6I/MUQKBgCR1hUuXhUJsTSSxWeLdvqvJ6iYzbq2Br3I7oz7k8AhnFphDMmEX -aaMJSHlcUGahX3T+RljH7r7SHGe+ofd9bu7Ax9R3/ONN2/PCcfphbmxklJJxujrG -NF0XRygeDKIsubtZVFC4k97PRpUlm8VNm41ZOBy8inY97OQNK8MCRcSdAoGAVWXV -yWKIoD5gBjaFZpYCC/KSwjpYURpjIZxbtn8PtZ+3l/0J7HZ3AGsa2y0LhSkFyEIW -kpmiqabcAmETFmk5OkfW1babNnC1MljOrdqg+lJaFUL+4YoOzUGwKJokjpD+sKy9 -TCVVNtFn6KY+6Pt/a98prNjW/Fo1qpVDlo0v+qECgYBNH81yghesDmxGiRTcXrSF -l/eX53n77wqNnk7kay/hJj4uSH5QYkUEGRbwxLeO/X7cZ7X7mrc86QV5AXJbbdS9 -/zhWdXp7A16vqyCpSBJ2QcUreA+bZJ3eyTibBlE7pL8DMP4EDRryg4Zf563iCybO -JxO6BZUqb4N5zLW8t8GfXw== ------END PRIVATE KEY----- diff --git a/crates/ref-feder-core/tests/fixtures/rsa-public-key.pem b/crates/ref-feder-core/tests/fixtures/rsa-public-key.pem deleted file mode 100644 index 6dfef20..0000000 --- a/crates/ref-feder-core/tests/fixtures/rsa-public-key.pem +++ /dev/null @@ -1,9 +0,0 @@ ------BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0l1BxdduvLa6JGZwAfYF -ap4eLS+YGXN1gUgyHjRv9Z4lUTFYTGepLy8L/llaqMqYTL02o1IHlwByB1iK1ICT -UPDeyYaA6aqZbe/qvtPZdLa3k0SJ7nPgOr44nWsMjFBUtasQyu9yR5hTu+cPq20J -3opHnZBlCFy974ewdYjo8+qOn+4NHC9MpIlRtOVoZFnnWN4rhzmMy/GBvlWsyNf+ -4wabZVbNDjIOdMuI0aIE8JJiqKlAmnuRd5BEdheZ8OdWvA99ZfinDgjSRe2czw66 -miYA17vxtSWhkjriIs2UtXeQVuj5un7c7/VMzs3irSuMxLC3gWYYtBa7SObyoXv5 -dQIDAQAB ------END PUBLIC KEY----- diff --git a/crates/ref-feder-runtime-server/tests/fixtures/rsa-private-key.pem b/crates/ref-feder-runtime-server/tests/fixtures/rsa-private-key.pem deleted file mode 100644 index 0355a1e..0000000 --- a/crates/ref-feder-runtime-server/tests/fixtures/rsa-private-key.pem +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDSXUHF1268trok -ZnAB9gVqnh4tL5gZc3WBSDIeNG/1niVRMVhMZ6kvLwv+WVqoyphMvTajUgeXAHIH -WIrUgJNQ8N7JhoDpqplt7+q+09l0treTRInuc+A6vjidawyMUFS1qxDK73JHmFO7 -5w+rbQneikedkGUIXL3vh7B1iOjz6o6f7g0cL0ykiVG05WhkWedY3iuHOYzL8YG+ -VazI1/7jBptlVs0OMg50y4jRogTwkmKoqUCae5F3kER2F5nw51a8D31l+KcOCNJF -7ZzPDrqaJgDXu/G1JaGSOuIizZS1d5BW6Pm6ftzv9UzOzeKtK4zEsLeBZhi0FrtI -5vKhe/l1AgMBAAECggEAMKIhPNstrYDEH3q0PevR/EBiYxlr/URRX+pgNdXzJVJi -t7bj/kP/29nxWKPxPvEZjTI4UcE64njWo+afL/oqtK1/IBGRt5O6hW1QNL5W+XHt -lmUjy0YsSoBkJ9aSD9VZhCdwii4Z2j3368rDN2NNww5ueJmjle+U9K3GyKF2k78U -2mczUoJ6LEJiAOpKVrrIdLEdb/NBE8b2lwqITHaL2Pidj9cbBfrU16pb1RG6z7Eb -Xv2AtobemO54oz+RnzLk6ApY9v27r5uXE/61WKN9iWFNHncoK/l95W/lktoCJLwM -3cMVZVPtEJdRCpEPbyfXG6pyAmgIjJDUj2lSwnr2kQKBgQDrkrVlZ+5H3qxoc6j7 -K4aWl1T7n4IHu3mGLE6ByLMHhjirq8SlG9tqJ09kpeprT5S+PtQiyVhud8PgejNJ -9eo/pU4ybE9VdAFacRCAgKN+jIFM6TniyLraNUK9+tHX53ca6Aolus8yiEaUQln5 -n8J9wrS18JV8T+9+OOJZPwql5QKBgQDkmvWOXD+wb23Sb4F8aD0WXOIqQacSHkjy -Fllp1FslvhgMGDIfwCAHDIT9osE4aK5Yy+88Fg5MGT0kgU0viguJ+fJheJ3oEwb+ -/wceEO11ts6+vaS9ZqAQO95lM+XoV6PU3uoyWE9uLYkxJd6Siz7BGUzl/TzZbWsw -ZzWD6I/MUQKBgCR1hUuXhUJsTSSxWeLdvqvJ6iYzbq2Br3I7oz7k8AhnFphDMmEX -aaMJSHlcUGahX3T+RljH7r7SHGe+ofd9bu7Ax9R3/ONN2/PCcfphbmxklJJxujrG -NF0XRygeDKIsubtZVFC4k97PRpUlm8VNm41ZOBy8inY97OQNK8MCRcSdAoGAVWXV -yWKIoD5gBjaFZpYCC/KSwjpYURpjIZxbtn8PtZ+3l/0J7HZ3AGsa2y0LhSkFyEIW -kpmiqabcAmETFmk5OkfW1babNnC1MljOrdqg+lJaFUL+4YoOzUGwKJokjpD+sKy9 -TCVVNtFn6KY+6Pt/a98prNjW/Fo1qpVDlo0v+qECgYBNH81yghesDmxGiRTcXrSF -l/eX53n77wqNnk7kay/hJj4uSH5QYkUEGRbwxLeO/X7cZ7X7mrc86QV5AXJbbdS9 -/zhWdXp7A16vqyCpSBJ2QcUreA+bZJ3eyTibBlE7pL8DMP4EDRryg4Zf563iCybO -JxO6BZUqb4N5zLW8t8GfXw== ------END PRIVATE KEY----- diff --git a/crates/ref-feder-runtime-server/tests/fixtures/rsa-public-key.pem b/crates/ref-feder-runtime-server/tests/fixtures/rsa-public-key.pem deleted file mode 100644 index 6dfef20..0000000 --- a/crates/ref-feder-runtime-server/tests/fixtures/rsa-public-key.pem +++ /dev/null @@ -1,9 +0,0 @@ ------BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0l1BxdduvLa6JGZwAfYF -ap4eLS+YGXN1gUgyHjRv9Z4lUTFYTGepLy8L/llaqMqYTL02o1IHlwByB1iK1ICT -UPDeyYaA6aqZbe/qvtPZdLa3k0SJ7nPgOr44nWsMjFBUtasQyu9yR5hTu+cPq20J -3opHnZBlCFy974ewdYjo8+qOn+4NHC9MpIlRtOVoZFnnWN4rhzmMy/GBvlWsyNf+ -4wabZVbNDjIOdMuI0aIE8JJiqKlAmnuRd5BEdheZ8OdWvA99ZfinDgjSRe2czw66 -miYA17vxtSWhkjriIs2UtXeQVuj5un7c7/VMzs3irSuMxLC3gWYYtBa7SObyoXv5 -dQIDAQAB ------END PUBLIC KEY----- diff --git a/examples/ref-actor-server/Cargo.toml b/examples/ref-actor-server/Cargo.toml deleted file mode 100644 index fa66b0d..0000000 --- a/examples/ref-actor-server/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "ref-actor-server" -version.workspace = true -edition.workspace = true -license.workspace = true - -[dependencies] -axum = "0.8" -feder-vocab.workspace = true -ref-feder-core = { workspace = true, features = ["http-signatures"] } -ref-feder-runtime-server = { path = "../../crates/ref-feder-runtime-server" } -tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] } -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } - -[lints] -workspace = true diff --git a/examples/ref-actor-server/README.md b/examples/ref-actor-server/README.md deleted file mode 100644 index 30b21a0..0000000 --- a/examples/ref-actor-server/README.md +++ /dev/null @@ -1,172 +0,0 @@ -Reference ActivityPub Server -============================ - -Minimal actor, WebFinger, personal inbox, and shared inbox endpoints using -`ref-feder-core` capabilities through `ref-feder-runtime-server`. - - -Run ---- - -~~~~ sh -RUST_LOG=info cargo run -p ref-actor-server -~~~~ - -Request the hardcoded local actor: - -~~~~ sh -curl -i \ - -H 'Accept: application/activity+json' \ - http://127.0.0.1:3000/users/alice -~~~~ - -The endpoint returns `200 OK` with an ActivityPub actor document. Requests for -another identifier return `404 Not Found`, and requests that do not prefer an -ActivityPub representation return `406 Not Acceptable`. - -Discover the actor through WebFinger: - -~~~~ sh -curl -i \ - -H 'Host: 127.0.0.1:3000' \ - 'http://127.0.0.1:3000/.well-known/webfinger?resource=acct:alice@127.0.0.1:3000' -~~~~ - -The endpoint returns `application/jrd+json` with a `self` link to -`http://127.0.0.1:3000/users/alice`. The domain in the `acct:` resource must -match the request's `Host` header. - -Send an unsigned development Follow to the shared inbox. The runtime selects -Alice from the Follow's `object` IRI: - -~~~~ sh -curl -i \ - -H 'Content-Type: application/activity+json' \ - --data-binary '{ - "@context": "https://www.w3.org/ns/activitystreams", - "id": "http://127.0.0.1:3000/remote/activities/follow/1", - "type": "Follow", - "actor": { - "id": "http://127.0.0.1:3000/remote/users/bob", - "type": "Person", - "inbox": "http://127.0.0.1:3000/remote-inbox", - "outbox": "http://127.0.0.1:3000/remote/users/bob/outbox" - }, - "object": "http://127.0.0.1:3000/users/alice" - }' \ - http://127.0.0.1:3000/inbox -~~~~ - -The endpoint stores the latest follower, loads Alice's key pair, and sends a -signed `Accept` to the example's `/remote-inbox` recipient before returning -`202 Accepted`. The recipient checks that the request has a `Signature` header -and logs receipt of the activity. - -Undo that Follow: - -~~~~ sh -curl -i \ - -H 'Content-Type: application/activity+json' \ - --data-binary '{ - "@context": "https://www.w3.org/ns/activitystreams", - "id": "http://127.0.0.1:3000/remote/activities/undo/1", - "type": "Undo", - "actor": { - "id": "http://127.0.0.1:3000/remote/users/bob", - "type": "Person", - "inbox": "http://127.0.0.1:3000/remote-inbox", - "outbox": "http://127.0.0.1:3000/remote/users/bob/outbox" - }, - "object": { - "id": "http://127.0.0.1:3000/remote/activities/follow/1", - "type": "Follow", - "actor": "http://127.0.0.1:3000/remote/users/bob", - "object": "http://127.0.0.1:3000/users/alice" - } - }' \ - http://127.0.0.1:3000/inbox -~~~~ - -The endpoint validates that Bob owns the embedded Follow, removes the matching -follower relationship, and returns `202 Accepted`. Repeating the Undo is safe. - -Request Alice's followers collection: - -~~~~ sh -curl -i \ - -H 'Accept: application/activity+json' \ - http://127.0.0.1:3000/users/alice/followers -~~~~ - -The endpoint returns an ActivityStreams `OrderedCollection`. Its -`orderedItems` contains Bob after the Follow request and is empty after the -Undo request. - -Send an outbound Follow from Alice to the example's remote Bob actor: - -~~~~ sh -curl -i -X POST http://127.0.0.1:3000/send-follow -~~~~ - -The application supplies the local actor, remote actor, and activity IRIs to -`FederServer::follow_actor()`. The runtime resolves Bob, asks core to construct -the activity and pending relationship, persists that relationship, loads -Alice's key, and sends a signed Follow to Bob's inbox. The pending relationship -is bounded to one entry in this example. - -Confirm that pending Follow with a linked `Accept` through the shared inbox: - -~~~~ sh -curl -i \ - -H 'Content-Type: application/activity+json' \ - --data-binary '{ - "@context": "https://www.w3.org/ns/activitystreams", - "id": "http://127.0.0.1:3000/remote/activities/accept/example", - "type": "Accept", - "actor": { - "id": "http://127.0.0.1:3000/remote/users/bob", - "type": "Person", - "inbox": "http://127.0.0.1:3000/remote-inbox", - "outbox": "http://127.0.0.1:3000/remote/users/bob/outbox" - }, - "object": "http://127.0.0.1:3000/users/alice/activities/follow/example" - }' \ - http://127.0.0.1:3000/inbox -~~~~ - -The shared inbox uses the linked Follow IRI to find Alice, core validates Bob -against the pending relationship, and storage atomically changes that exact -relationship from pending to accepted. Repeating the Accept is a safe no-op. - -Create and persist a local Note: - -~~~~ sh -curl -i -X POST http://127.0.0.1:3000/create-note -~~~~ - -The application supplies stable Note and Create activity IRIs plus the Note's -content and addressing. Core constructs both values, and the runtime persists -only the durable Note through `NoteStore`. It then expands the followers -address through `FollowerDeliveryStore`, prefers shared inboxes, deduplicates -destinations, and sends the transient Create activity with Alice's key. Send -the earlier inbound Follow first to observe delivery to Bob's example inbox. - -Fetch the persisted public Note: - -~~~~ sh -curl -i \ - -H 'Accept: application/activity+json' \ - http://127.0.0.1:3000/users/alice/posts/example -~~~~ - -The endpoint derives the canonical Note IRI from Alice and `example`, loads it -through `NoteStore`, verifies its public addressing in core, and returns the -ActivityPub Note with `Vary: Accept`. - -The example loads its actor key pair from the repository's test fixture and -retains only that pair, the latest follower, the latest outbound Follow, and -the latest Note, so repeated requests do not grow an in-memory protocol -history. The fixture key is public test data and must never be used for a real -actor. Unsigned incoming requests and private outbound addresses are enabled -only for this local development example; `FederServer` requires signed -requests and public destinations by default. diff --git a/examples/ref-actor-server/src/main.rs b/examples/ref-actor-server/src/main.rs deleted file mode 100644 index f496966..0000000 --- a/examples/ref-actor-server/src/main.rs +++ /dev/null @@ -1,449 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use std::{ - convert::Infallible, - fmt, - net::SocketAddr, - sync::{Arc, Mutex}, -}; - -use axum::{ - Json, - body::Bytes, - http::{HeaderMap, StatusCode, header}, - response::IntoResponse, - routing::{get, post}, -}; -use feder_vocab::{Actor, CryptographicKey, Endpoints, Iri, Note, Reference, References}; -use ref_feder_core::{ - follow::PendingFollow, - key::ActorKeyPair, - note::CreateNoteInput, - storage::{FollowerDeliveryStore, FollowerDeliveryTarget, NoteStore, ServerStorage, Storage}, -}; -use ref_feder_runtime_server::{ - ActorDispatcher, Error, FederServer, InboxAuthPolicy, OutboundAddressPolicy, - build_router_with_state, -}; - -const IDENTIFIER: &str = "alice"; -const ORIGIN: &str = "http://127.0.0.1:3000"; -const HANDLE_HOST: &str = "127.0.0.1:3000"; -const REMOTE_ACTOR_ID: &str = "http://127.0.0.1:3000/remote/users/bob"; -const ACTOR_PRIVATE_KEY_PEM: &str = - include_str!("../../../crates/feder-core/tests/fixtures/rsa-private-key.pem"); -const ACTOR_PUBLIC_KEY_PEM: &str = - include_str!("../../../crates/feder-core/tests/fixtures/rsa-public-key.pem"); - -struct SingleActorDispatcher { - actor: Actor, -} - -struct ExampleStorage { - local_actor_id: Iri, - actor_key_pair: ActorKeyPair, - latest_follower: Mutex>, - latest_outbound_follow: Mutex>, - latest_note: Mutex>, -} - -enum OutboundFollowState { - Pending(PendingFollow), - Accepted(PendingFollow), -} - -#[derive(Debug)] -struct ExampleStorageError(&'static str); - -impl fmt::Display for ExampleStorageError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.0) - } -} - -impl std::error::Error for ExampleStorageError {} - -impl ActorDispatcher for SingleActorDispatcher { - type Error = Infallible; - - fn get_actor(&self, identifier: &str) -> Result, Self::Error> { - Ok((identifier == IDENTIFIER).then(|| self.actor.clone())) - } - - fn get_actor_by_id(&self, actor_id: &Iri) -> Result, Self::Error> { - Ok((actor_id == &self.actor.id).then(|| self.actor.clone())) - } -} - -impl Storage for ExampleStorage { - type Error = ExampleStorageError; -} - -impl ServerStorage for ExampleStorage { - fn store_follower(&self, follower: &Actor, following: &Iri) -> Result<(), Self::Error> { - *self - .latest_follower - .lock() - .map_err(|_| ExampleStorageError("follower state lock poisoned"))? = - Some((follower.clone(), following.clone())); - tracing::info!(follower = %follower.id, following = %following, "stored follower"); - Ok(()) - } - - fn load_actor_key_pair(&self, actor_id: &Iri) -> Result, Self::Error> { - Ok((actor_id == &self.local_actor_id).then(|| self.actor_key_pair.clone())) - } - - fn remove_follower(&self, follower: &Iri, following: &Iri) -> Result<(), Self::Error> { - let mut latest_follower = self - .latest_follower - .lock() - .map_err(|_| ExampleStorageError("follower state lock poisoned"))?; - if latest_follower - .as_ref() - .is_some_and(|(stored_follower, stored_following)| { - stored_follower.id == *follower && stored_following == following - }) - { - *latest_follower = None; - tracing::info!(%follower, %following, "removed follower"); - } - Ok(()) - } - - fn list_followers(&self, following: &Iri) -> Result, Self::Error> { - let latest_follower = self - .latest_follower - .lock() - .map_err(|_| ExampleStorageError("follower state lock poisoned"))?; - Ok(latest_follower - .as_ref() - .filter(|(_, stored_following)| stored_following == following) - .map(|(follower, _)| vec![follower.id.clone()]) - .unwrap_or_default()) - } - - fn store_pending_follow(&self, follow: &PendingFollow) -> Result<(), Self::Error> { - *self - .latest_outbound_follow - .lock() - .map_err(|_| ExampleStorageError("pending Follow state lock poisoned"))? = - Some(OutboundFollowState::Pending(follow.clone())); - tracing::info!( - local_actor = %follow.local_actor, - remote_actor = %follow.remote_actor.id, - follow_activity = %follow.follow_activity, - "stored pending Follow" - ); - Ok(()) - } - - fn load_pending_follow( - &self, - follow_activity: &Iri, - ) -> Result, Self::Error> { - let outbound = self - .latest_outbound_follow - .lock() - .map_err(|_| ExampleStorageError("outbound Follow state lock poisoned"))?; - Ok(match outbound.as_ref() { - Some(OutboundFollowState::Pending(follow)) - if follow.follow_activity == *follow_activity => - { - Some(follow.clone()) - } - Some(OutboundFollowState::Accepted(follow)) => { - tracing::debug!( - follow_activity = %follow.follow_activity, - "Follow is already accepted" - ); - None - } - Some(OutboundFollowState::Pending(_)) | None => None, - }) - } - - fn confirm_pending_follow(&self, expected: &PendingFollow) -> Result { - let mut outbound = self - .latest_outbound_follow - .lock() - .map_err(|_| ExampleStorageError("outbound Follow state lock poisoned"))?; - let matches = matches!( - outbound.as_ref(), - Some(OutboundFollowState::Pending(follow)) if follow == expected - ); - if matches { - *outbound = Some(OutboundFollowState::Accepted(expected.clone())); - tracing::info!( - local_actor = %expected.local_actor, - remote_actor = %expected.remote_actor.id, - follow_activity = %expected.follow_activity, - "confirmed pending Follow" - ); - } - Ok(matches) - } -} - -impl NoteStore for ExampleStorage { - fn store_note(&self, note: &Note) -> Result<(), Self::Error> { - *self - .latest_note - .lock() - .map_err(|_| ExampleStorageError("Note state lock poisoned"))? = Some(note.clone()); - tracing::info!(note = %note.id, "stored Note"); - Ok(()) - } - - fn load_note(&self, note_id: &Iri) -> Result, Self::Error> { - let latest_note = self - .latest_note - .lock() - .map_err(|_| ExampleStorageError("Note state lock poisoned"))?; - Ok(latest_note - .as_ref() - .filter(|note| note.id == *note_id) - .cloned()) - } -} - -impl FollowerDeliveryStore for ExampleStorage { - fn list_follower_delivery_targets( - &self, - local_actor: &Iri, - ) -> Result, Self::Error> { - let latest_follower = self - .latest_follower - .lock() - .map_err(|_| ExampleStorageError("follower state lock poisoned"))?; - Ok(latest_follower - .as_ref() - .filter(|(_, following)| following == local_actor) - .map(|(follower, _)| { - vec![FollowerDeliveryTarget { - actor_id: follower.id.clone(), - inbox: follower.inbox.clone(), - shared_inbox: follower - .endpoints - .as_ref() - .and_then(|endpoints| endpoints.shared_inbox.clone()), - }] - }) - .unwrap_or_default()) - } -} - -fn local_actor(key_pair: &ActorKeyPair) -> Actor { - let actor_id = format!("{ORIGIN}/users/{IDENTIFIER}"); - let mut actor = Actor::person( - actor_id.parse().expect("valid actor IRI"), - format!("{actor_id}/inbox") - .parse() - .expect("valid inbox IRI"), - format!("{actor_id}/outbox") - .parse() - .expect("valid outbox IRI"), - ); - actor.preferred_username = Some(IDENTIFIER.to_string()); - actor.name = Some("Alice".to_string()); - actor.followers = Some( - format!("{actor_id}/followers") - .parse() - .expect("valid followers collection IRI"), - ); - actor.endpoints = Some(Endpoints { - shared_inbox: Some( - format!("{ORIGIN}/inbox") - .parse() - .expect("valid shared inbox IRI"), - ), - }); - actor.set_public_key(Reference::object(CryptographicKey::new( - format!("{actor_id}#main-key") - .parse() - .expect("valid actor key IRI"), - actor.id.clone(), - key_pair.public_key_pem().to_string(), - ))); - actor -} - -fn remote_actor() -> Actor { - let mut actor = Actor::person( - REMOTE_ACTOR_ID.parse().expect("valid remote actor IRI"), - format!("{ORIGIN}/remote-inbox") - .parse() - .expect("valid remote inbox IRI"), - format!("{REMOTE_ACTOR_ID}/outbox") - .parse() - .expect("valid remote outbox IRI"), - ); - actor.preferred_username = Some("bob".to_string()); - actor -} - -async fn remote_actor_document() -> impl IntoResponse { - ( - [(header::CONTENT_TYPE, "application/activity+json")], - Json(remote_actor()), - ) -} - -async fn remote_inbox(headers: HeaderMap, body: Bytes) -> StatusCode { - if !headers.contains_key("signature") { - return StatusCode::UNAUTHORIZED; - } - - tracing::info!(body_size = body.len(), "received signed activity"); - StatusCode::ACCEPTED -} - -type ExampleServer = FederServer; - -async fn send_example_follow(server: Arc) -> StatusCode { - let local_actor_id = format!("{ORIGIN}/users/{IDENTIFIER}") - .parse() - .expect("valid local actor IRI"); - let remote_actor_id = REMOTE_ACTOR_ID.parse().expect("valid remote actor IRI"); - let follow_id = format!("{ORIGIN}/users/{IDENTIFIER}/activities/follow/example") - .parse() - .expect("valid Follow activity IRI"); - - match server - .follow_actor(&local_actor_id, &remote_actor_id, follow_id) - .await - { - Ok(follow) => { - tracing::info!(follow = %follow.id, "sent Follow"); - StatusCode::ACCEPTED - } - Err(error) => { - tracing::error!(%error, "failed to send Follow"); - StatusCode::BAD_GATEWAY - } - } -} - -async fn create_example_note(server: Arc) -> StatusCode { - let local_actor_id = format!("{ORIGIN}/users/{IDENTIFIER}") - .parse() - .expect("valid local actor IRI"); - let note_id = format!("{ORIGIN}/users/{IDENTIFIER}/posts/example") - .parse() - .expect("valid Note IRI"); - let create_id = format!("{ORIGIN}/users/{IDENTIFIER}/activities/create/example") - .parse() - .expect("valid Create activity IRI"); - let public = "https://www.w3.org/ns/activitystreams#Public" - .parse() - .expect("valid ActivityStreams Public collection IRI"); - let followers = format!("{ORIGIN}/users/{IDENTIFIER}/followers") - .parse() - .expect("valid followers collection IRI"); - - match server - .create_note( - &local_actor_id, - CreateNoteInput { - note_id, - create_id, - to: References::one(public), - cc: References::one(followers), - content: "Hello from the reference Feder runtime.".to_string(), - media_type: Some("text/plain".to_string()), - published: None, - url: None, - }, - ) - .await - { - Ok(outcome) => { - tracing::info!( - note = %outcome.note.id, - activity = %outcome.activity.id, - "created Note" - ); - StatusCode::CREATED - } - Err(error) => { - tracing::error!(%error, "failed to create Note"); - StatusCode::INTERNAL_SERVER_ERROR - } - } -} - -#[tokio::main] -async fn main() -> Result<(), Error> { - tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) - .init(); - - let bind: SocketAddr = "127.0.0.1:3000" - .parse() - .expect("valid default bind address"); - let actor_key_pair = ActorKeyPair::from_pem( - ACTOR_PRIVATE_KEY_PEM.to_string(), - ACTOR_PUBLIC_KEY_PEM.to_string(), - ) - .expect("bundled example actor key pair is valid"); - let actor = local_actor(&actor_key_pair); - let storage = ExampleStorage { - local_actor_id: actor.id.clone(), - actor_key_pair, - latest_follower: Mutex::new(None), - latest_outbound_follow: Mutex::new(None), - latest_note: Mutex::new(None), - }; - let dispatcher = SingleActorDispatcher { actor }; - let server = Arc::new( - FederServer::with_outbound_address_policy( - dispatcher, - storage, - HANDLE_HOST, - OutboundAddressPolicy::AllowPrivateAddress, - )? - .with_inbox_auth_policy(InboxAuthPolicy::AllowUnsignedInsecureDev), - ); - let follow_server = Arc::clone(&server); - let note_server = Arc::clone(&server); - let app = build_router_with_state(server) - .route("/remote/users/bob", get(remote_actor_document)) - .route("/remote-inbox", post(remote_inbox)) - .route( - "/send-follow", - post(move || send_example_follow(Arc::clone(&follow_server))), - ) - .route( - "/create-note", - post(move || create_example_note(Arc::clone(¬e_server))), - ); - - tracing::info!( - bind = %bind, - actor = %format!("{ORIGIN}/users/{IDENTIFIER}"), - webfinger = %format!("{ORIGIN}/.well-known/webfinger"), - inbox = %format!("{ORIGIN}/users/{IDENTIFIER}/inbox"), - shared_inbox = %format!("{ORIGIN}/inbox"), - "starting reference ActivityPub server example" - ); - - let listener = tokio::net::TcpListener::bind(bind) - .await - .map_err(Error::Bind)?; - axum::serve(listener, app).await.map_err(Error::Serve)?; - - Ok(()) -} diff --git a/examples/single-user-server/Cargo.toml b/examples/single-user-server/Cargo.toml index 07f5e08..8962f38 100644 --- a/examples/single-user-server/Cargo.toml +++ b/examples/single-user-server/Cargo.toml @@ -1,5 +1,6 @@ [package] name = "single-user-server" +publish = false version.workspace = true edition.workspace = true license.workspace = true @@ -8,8 +9,8 @@ license.workspace = true axum = "0.8" feder-vocab.workspace = true rand_core.workspace = true -ref-feder-core = { workspace = true, features = ["http-signatures"] } -ref-feder-runtime-server = { path = "../../crates/ref-feder-runtime-server" } +feder-core = { workspace = true, features = ["http-signatures"] } +feder-server.workspace = true tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/examples/single-user-server/README.md b/examples/single-user-server/README.md index f49c93a..f9a4aec 100644 --- a/examples/single-user-server/README.md +++ b/examples/single-user-server/README.md @@ -1,7 +1,7 @@ Single-User Server Example ========================== -Demo app using `ref-feder-runtime-server` with one hardcoded local actor and +Demo app using `feder-server` with one hardcoded local actor and the built-in SQLite storage adapter. diff --git a/examples/single-user-server/src/main.rs b/examples/single-user-server/src/main.rs index 123b6af..f478bcf 100644 --- a/examples/single-user-server/src/main.rs +++ b/examples/single-user-server/src/main.rs @@ -15,12 +15,12 @@ use std::{convert::Infallible, env, error::Error, net::SocketAddr, path::PathBuf}; -use feder_vocab::{Actor, CryptographicKey, Endpoints, Iri, Reference}; -use rand_core::OsRng; -use ref_feder_core::{ActorDispatcher, key::ActorKeyPair}; -use ref_feder_runtime_server::{ +use feder_core::{ActorDispatcher, key::ActorKeyPair}; +use feder_server::{ FederServer, InboxAuthPolicy, OutboundAddressPolicy, build_router, storage::SqliteStore, }; +use feder_vocab::{Actor, CryptographicKey, Endpoints, Iri, Reference}; +use rand_core::OsRng; const IDENTIFIER: &str = "alice"; const ORIGIN: &str = "http://127.0.0.1:3000"; diff --git a/mise.toml b/mise.toml index 8c9e654..1bf199d 100644 --- a/mise.toml +++ b/mise.toml @@ -52,7 +52,9 @@ let dependency_version = ($publish_version | split row "+" | first) let crate_names = ( cargo metadata --no-deps --format-version 1 | from json - | get packages.name + | get packages + | where {|package| $package.publish != [] } + | get name ) let manifest = (