From f56aaaf6af2b41daf773c357aa9ff7f7e3e81128 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 29 Jul 2026 22:56:26 +0900 Subject: [PATCH 01/32] 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 02/32] 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 03/32] 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 04/32] 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 05/32] 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 06/32] 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 07/32] 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 08/32] 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 09/32] 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 10/32] 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 11/32] 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 12/32] 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 13/32] 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 14/32] 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 15/32] 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 16/32] 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 17/32] 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 18/32] 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 19/32] 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 20/32] 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 21/32] 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 22/32] 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 23/32] 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 24/32] 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 25/32] 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 26/32] 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 27/32] 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 28/32] 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 29/32] 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 = ( From c53c89a7b07a8a34ca410d1dd82f1ded171b2aba Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Fri, 7 Aug 2026 16:35:41 +0900 Subject: [PATCH 30/32] Ignore unsupported nested inbox activities Return 202 Accepted for Accept and Undo activities wrapping unsupported activity types before attempting Follow-specific deserialization. Assisted-by: Codex:gpt-5.6-sol --- crates/feder-server/src/inbox.rs | 17 ++++++++++++++ crates/feder-server/tests/cases/inbox.rs | 29 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/crates/feder-server/src/inbox.rs b/crates/feder-server/src/inbox.rs index e6ccff5..f533a88 100644 --- a/crates/feder-server/src/inbox.rs +++ b/crates/feder-server/src/inbox.rs @@ -185,6 +185,15 @@ where match value.get("type").and_then(Value::as_str) { Some("Follow") => {} Some("Accept") => { + if value + .get("object") + .filter(|object| object.is_object()) + .and_then(|object| object.get("type")) + .and_then(Value::as_str) + .is_some_and(|kind| kind != "Follow") + { + return Ok(StatusCode::ACCEPTED.into_response()); + } let accept: Accept = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; let remote_actor = match verified_actor { Some(actor) => actor, @@ -222,6 +231,14 @@ where return Ok(StatusCode::ACCEPTED.into_response()); } 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 remote_actor = match verified_actor { Some(actor) => actor, diff --git a/crates/feder-server/tests/cases/inbox.rs b/crates/feder-server/tests/cases/inbox.rs index 8602053..174ed21 100644 --- a/crates/feder-server/tests/cases/inbox.rs +++ b/crates/feder-server/tests/cases/inbox.rs @@ -333,6 +333,35 @@ async fn inbox_rejects_invalid_content_before_dispatch() { assert_eq!(malformed.status(), StatusCode::BAD_REQUEST); } +#[tokio::test] +async fn ignores_accept_and_undo_of_unsupported_activities() { + for kind in ["Accept", "Undo"] { + let body = serde_json::to_vec(&json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": kind, + "id": format!("https://remote.example/activities/{kind}-1"), + "actor": "https://remote.example/users/bob", + "object": { + "type": "Like", + "id": "https://remote.example/activities/like-1", + "actor": "https://remote.example/users/bob", + "object": format!("{ORIGIN}/users/alice/posts/1") + } + })) + .expect("serialize unsupported nested activity"); + + let response = post_inbox( + test_router(), + "/users/alice/inbox", + "application/activity+json", + body, + ) + .await; + + assert_eq!(response.status(), StatusCode::ACCEPTED, "{kind}"); + } +} + #[tokio::test] async fn personal_inbox_rejects_an_unknown_local_actor() { let response = post_inbox( From 9518b40cf64ba8f9b14b1ada72f94f4dfd6a7ab1 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sat, 8 Aug 2026 20:56:21 +0900 Subject: [PATCH 31/32] Retry development publication Assisted-by: Codex:gpt-5.6-sol From 5ec2cfc5992902a02079835d37e6ede236fec051 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Sat, 8 Aug 2026 23:01:36 +0900 Subject: [PATCH 32/32] Address release-blocking federation review feedback Normalize compact Public addressing, improve inbox signature compatibility, deliver direct Follows to personal inboxes, and keep accepted outbound relationships from reopening. Add regression coverage for Follow and Accept flows and document outbound policy security. Assisted-by: Codex:gpt-5.6-sol --- Cargo.lock | 49 ++++++ crates/feder-server/Cargo.toml | 3 +- crates/feder-server/src/follow.rs | 13 +- crates/feder-server/src/inbox.rs | 14 +- crates/feder-server/src/lib.rs | 7 +- crates/feder-server/src/storage/sqlite.rs | 4 +- crates/feder-server/tests/cases/inbox.rs | 148 ++++++++++++++++++- crates/feder-server/tests/cases/operation.rs | 42 +++++- crates/feder-server/tests/common/mod.rs | 13 +- crates/feder-server/tests/storage.rs | 31 ++++ crates/feder-vocab/src/lib.rs | 70 ++++++++- 11 files changed, 364 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bc02422..b4f230b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -280,6 +280,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -292,6 +302,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "feder-core" version = "0.1.0" @@ -318,6 +334,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "tempfile", "thiserror", "tokio", "tower", @@ -787,6 +804,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -1238,6 +1261,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.42" @@ -1608,6 +1644,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.19" diff --git a/crates/feder-server/Cargo.toml b/crates/feder-server/Cargo.toml index 563ea8b..bd880bf 100644 --- a/crates/feder-server/Cargo.toml +++ b/crates/feder-server/Cargo.toml @@ -9,7 +9,7 @@ homepage.workspace = true repository.workspace = true [dependencies] -axum = "=0.8" +axum = "0.8" feder-vocab.workspace = true httpdate = "1" mime = "0.3.17" @@ -30,6 +30,7 @@ ipnet = "2.11.0" tokio = { version = "1", features = ["net"] } [dev-dependencies] +tempfile = "3" tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "sync"] } tower.workspace = true diff --git a/crates/feder-server/src/follow.rs b/crates/feder-server/src/follow.rs index 443e9bf..fa401ee 100644 --- a/crates/feder-server/src/follow.rs +++ b/crates/feder-server/src/follow.rs @@ -53,14 +53,13 @@ where .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) + .send_activity( + &local_actor, + &key_pair, + &outcome.activity, + &remote_actor.inbox, + ) .await .map_err(FollowActorError::ActivitySender)?; diff --git a/crates/feder-server/src/inbox.rs b/crates/feder-server/src/inbox.rs index f533a88..5698752 100644 --- a/crates/feder-server/src/inbox.rs +++ b/crates/feder-server/src/inbox.rs @@ -324,8 +324,10 @@ async fn verify_signed_request( .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)") + if !matches!( + signature.algorithm.as_deref(), + None | Some("rsa-sha256" | "hs2019") + ) || signature.signed_headers.first().map(String::as_str) != Some("(request-target)") { return Err(StatusCode::UNAUTHORIZED); } @@ -564,7 +566,7 @@ fn verify_request_digest(headers: &HeaderMap, body: &[u8]) -> Result<(), StatusC struct ParsedSignature { key_id: String, - algorithm: String, + algorithm: Option, signed_headers: Vec, signature: String, } @@ -599,7 +601,9 @@ fn parse_signature_header(header: &str) -> Option { } let key_id = parameters.remove("keyid")?; - let algorithm = parameters.remove("algorithm")?; + let algorithm = parameters + .remove("algorithm") + .map(|algorithm| algorithm.to_ascii_lowercase()); let signed_headers = parameters .remove("headers")? .split_ascii_whitespace() @@ -611,7 +615,7 @@ fn parse_signature_header(header: &str) -> Option { } Some(ParsedSignature { key_id, - algorithm: algorithm.to_ascii_lowercase(), + algorithm, signed_headers, signature, }) diff --git a/crates/feder-server/src/lib.rs b/crates/feder-server/src/lib.rs index 52a82f5..51276fc 100644 --- a/crates/feder-server/src/lib.rs +++ b/crates/feder-server/src/lib.rs @@ -84,7 +84,12 @@ impl FederServer { }) } - // for development + /// Constructs a server with an explicit outbound-address policy. + /// + /// Allowing private addresses disables Feder's SSRF protection and must + /// only be used in trusted development or test environments. Production + /// servers should use [`FederServer::new`], which permits public addresses + /// only. pub fn with_outbound_address_policy( actors: A, storage: S, diff --git a/crates/feder-server/src/storage/sqlite.rs b/crates/feder-server/src/storage/sqlite.rs index 93ea60e..5b88da9 100644 --- a/crates/feder-server/src/storage/sqlite.rs +++ b/crates/feder-server/src/storage/sqlite.rs @@ -259,8 +259,8 @@ impl ServerStorage for SqliteStore { 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' + remote_actor_json = excluded.remote_actor_json + WHERE outbound_follows.state = 'pending' "#, params![ follow.follow_activity.as_str(), diff --git a/crates/feder-server/tests/cases/inbox.rs b/crates/feder-server/tests/cases/inbox.rs index 174ed21..a3fe486 100644 --- a/crates/feder-server/tests/cases/inbox.rs +++ b/crates/feder-server/tests/cases/inbox.rs @@ -4,13 +4,19 @@ use axum::{ http::{HeaderMap, Request, StatusCode, Uri, header::CONTENT_TYPE}, routing::{get, post}, }; -use feder_core::key::{create_sha256_digest_header, sign_draft_cavage}; -use feder_server::InboxAuthPolicy; +use feder_core::{ + follow::PendingFollow, + key::{create_sha256_digest_header, sign_draft_cavage}, + storage::ServerStorage, +}; +use feder_server::{InboxAuthPolicy, build_router, storage::SqliteStore}; +use feder_vocab::{Actor, Iri}; use serde_json::{Value, json}; use tower::ServiceExt; use crate::common::{ - HANDLE_HOST, ORIGIN, RecordedRequest, actor_key_pair, test_router, test_router_with_policy, + HANDLE_HOST, ORIGIN, RecordedRequest, actor_key_pair, iri, test_router, + test_router_with_policy, test_server_with_store, }; fn follow_body(actor_id: &str) -> Vec { @@ -121,6 +127,27 @@ async fn post_signed_inbox( signed_body: &[u8], delivered_body: impl Into, host: &str, +) -> axum::response::Response { + post_signed_inbox_with_algorithm( + app, + uri, + actor_id, + signed_body, + delivered_body, + host, + Some("rsa-sha256"), + ) + .await +} + +async fn post_signed_inbox_with_algorithm( + app: Router, + uri: &str, + actor_id: &str, + signed_body: &[u8], + delivered_body: impl Into, + host: &str, + algorithm: Option<&str>, ) -> axum::response::Response { let date = httpdate::fmt_http_date(std::time::SystemTime::now()); let digest = create_sha256_digest_header(signed_body); @@ -130,7 +157,7 @@ async fn post_signed_inbox( ("digest", digest.as_str()), ("host", host), ]; - let signature = sign_draft_cavage( + let mut signature = sign_draft_cavage( &actor_key_pair(), &format!("{actor_id}#main-key"), "POST", @@ -138,6 +165,16 @@ async fn post_signed_inbox( &headers, ) .expect("sign inbox request"); + if algorithm != Some("rsa-sha256") { + signature = match algorithm { + Some(algorithm) => signature.replacen( + "algorithm=\"rsa-sha256\"", + &format!("algorithm=\"{algorithm}\""), + 1, + ), + None => signature.replacen("algorithm=\"rsa-sha256\",", "", 1), + }; + } app.oneshot( Request::builder() @@ -155,6 +192,55 @@ async fn post_signed_inbox( .expect("signed inbox response") } +fn accept_body(actor_id: &str, follow_id: &Iri) -> Vec { + serde_json::to_vec(&json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Accept", + "id": format!("{actor_id}/accepts/1"), + "actor": actor_id, + "object": follow_id, + })) + .expect("serialize Accept") +} + +async fn assert_valid_accept_is_confirmed(inbox_uri: &str) { + let (actor_id, _requests, remote_server) = spawn_remote_actor().await; + let directory = tempfile::tempdir().expect("create temporary database directory"); + let database_path = directory.path().join("feder.sqlite"); + let storage = SqliteStore::open(&database_path).expect("open store"); + let follow_id = iri(&format!("{ORIGIN}/users/alice/activities/follow/1")); + let pending = PendingFollow { + local_actor: iri(&format!("{ORIGIN}/users/alice")), + remote_actor: Actor::person( + iri(&actor_id), + iri(&format!("{actor_id}/inbox")), + iri(&format!("{actor_id}/outbox")), + ), + follow_activity: follow_id.clone(), + }; + storage + .store_pending_follow(&pending) + .expect("store pending Follow"); + let app = build_router(test_server_with_store( + storage, + InboxAuthPolicy::RequireSigned, + )); + let body = accept_body(&actor_id, &follow_id); + + let response = + post_signed_inbox(app, inbox_uri, &actor_id, &body, body.clone(), HANDLE_HOST).await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + let observer = SqliteStore::open(&database_path).expect("reopen store"); + assert_eq!( + observer + .load_pending_follow(&follow_id) + .expect("load accepted Follow"), + None + ); + remote_server.abort(); +} + async fn follower_count(app: Router) -> u64 { let response = app .oneshot( @@ -201,6 +287,60 @@ async fn valid_signed_follow_is_stored_and_accept_is_sent() { remote_server.abort(); } +#[tokio::test] +async fn signed_follow_accepts_hs2019_algorithm() { + 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_with_algorithm( + app, + "/users/alice/inbox", + &actor_id, + &body, + body.clone(), + HANDLE_HOST, + Some("hs2019"), + ) + .await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + requests.recv().await.expect("receive Accept activity"); + remote_server.abort(); +} + +#[tokio::test] +async fn signed_follow_accepts_omitted_algorithm() { + 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_with_algorithm( + app, + "/users/alice/inbox", + &actor_id, + &body, + body.clone(), + HANDLE_HOST, + None, + ) + .await; + + assert_eq!(response.status(), StatusCode::ACCEPTED); + requests.recv().await.expect("receive Accept activity"); + remote_server.abort(); +} + +#[tokio::test] +async fn personal_inbox_confirms_a_valid_accept() { + assert_valid_accept_is_confirmed("/users/alice/inbox").await; +} + +#[tokio::test] +async fn shared_inbox_confirms_a_valid_accept() { + assert_valid_accept_is_confirmed("/inbox").await; +} + #[tokio::test] async fn unsigned_follow_is_rejected_when_signatures_are_required() { let (actor_id, _requests, remote_server) = spawn_remote_actor().await; diff --git a/crates/feder-server/tests/cases/operation.rs b/crates/feder-server/tests/cases/operation.rs index e9a427f..389922c 100644 --- a/crates/feder-server/tests/cases/operation.rs +++ b/crates/feder-server/tests/cases/operation.rs @@ -10,7 +10,10 @@ use feder_vocab::{Actor, Iri, References}; use serde_json::{Value, json}; use tower::ServiceExt; -use crate::common::{ORIGIN, RecordedRequest, iri, spawn_inbox_server, test_server_with_storage}; +use crate::common::{ + ORIGIN, RecordedRequest, iri, spawn_inbox_server, test_server_with_storage, + test_server_with_store, +}; fn create_note_input() -> CreateNoteInput { CreateNoteInput { @@ -41,9 +44,13 @@ async fn spawn_remote_actor() -> ( "type": "Person", "id": actor_id, "inbox": inbox, - "outbox": format!("http://{address}/users/bob/outbox") + "outbox": format!("http://{address}/users/bob/outbox"), + "endpoints": { + "sharedInbox": format!("http://{address}/shared-inbox") + } }); let (sender, receiver) = tokio::sync::mpsc::channel(2); + let shared_sender = sender.clone(); let app = Router::new() .route( "/users/bob", @@ -68,6 +75,23 @@ async fn spawn_remote_actor() -> ( } }, ), + ) + .route( + "/shared-inbox", + axum::routing::post( + move |headers: axum::http::HeaderMap, + uri: axum::http::Uri, + body: axum::body::Bytes| { + let sender = shared_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) @@ -81,7 +105,10 @@ async fn spawn_remote_actor() -> ( #[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 directory = tempfile::tempdir().expect("create temporary database directory"); + let database_path = directory.path().join("feder.sqlite"); + let storage = SqliteStore::open(&database_path).expect("open store"); + let server = test_server_with_store(storage, InboxAuthPolicy::RequireSigned); let local_actor_id = iri(&format!("{ORIGIN}/users/alice")); let follow_id = iri(&format!("{ORIGIN}/users/alice/activities/follow/1")); @@ -92,11 +119,20 @@ async fn outbound_follow_is_persisted_before_signed_delivery() { assert_eq!(follow.id, follow_id); let request = requests.recv().await.expect("receive Follow delivery"); + assert_eq!(request.uri.path(), "/inbox"); 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()); + let observer = SqliteStore::open(&database_path).expect("reopen store"); + let pending = observer + .load_pending_follow(&follow_id) + .expect("load pending Follow") + .expect("pending Follow was persisted"); + assert_eq!(pending.local_actor, local_actor_id); + assert_eq!(pending.remote_actor.id, remote_actor_id); + assert_eq!(pending.follow_activity, follow_id); remote_server.abort(); } diff --git a/crates/feder-server/tests/common/mod.rs b/crates/feder-server/tests/common/mod.rs index 6d1928d..6f2bc5c 100644 --- a/crates/feder-server/tests/common/mod.rs +++ b/crates/feder-server/tests/common/mod.rs @@ -85,7 +85,7 @@ 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( +pub fn test_router_with_storage_and_policy( configure: impl FnOnce(&SqliteStore), inbox_auth_policy: InboxAuthPolicy, ) -> Router { @@ -96,12 +96,19 @@ 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"); + configure(&storage); + test_server_with_store(storage, inbox_auth_policy) +} + +pub fn test_server_with_store( + storage: SqliteStore, + inbox_auth_policy: InboxAuthPolicy, +) -> FederServer { + let actor = local_actor(); 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, diff --git a/crates/feder-server/tests/storage.rs b/crates/feder-server/tests/storage.rs index 936b71a..a5f405b 100644 --- a/crates/feder-server/tests/storage.rs +++ b/crates/feder-server/tests/storage.rs @@ -119,6 +119,37 @@ fn confirms_only_the_expected_pending_follow() { ); } +#[test] +fn storing_pending_follow_does_not_reopen_an_accepted_relationship() { + 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"); + assert!( + store + .confirm_pending_follow(&pending) + .expect("confirm pending Follow") + ); + + let mut replacement = pending.clone(); + replacement.remote_actor = actor("https://remote.example/users/mallory"); + store + .store_pending_follow(&replacement) + .expect("retry storing accepted 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"); diff --git a/crates/feder-vocab/src/lib.rs b/crates/feder-vocab/src/lib.rs index a22cb34..f738980 100644 --- a/crates/feder-vocab/src/lib.rs +++ b/crates/feder-vocab/src/lib.rs @@ -160,6 +160,31 @@ where } } +fn deserialize_addressing<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + fn parse_iri(value: String) -> Result + where + E: serde::de::Error, + { + let value = match value.as_str() { + "Public" | "as:Public" => "https://www.w3.org/ns/activitystreams#Public", + _ => &value, + }; + value.parse().map_err(E::custom) + } + + match OneOrMany::::deserialize(deserializer)? { + OneOrMany::One(value) => parse_iri(value).map(References::one), + OneOrMany::Many(values) => values + .into_iter() + .map(parse_iri) + .collect::, _>>() + .map(References::many), + } +} + macro_rules! activitystreams_type { ($name:ident, $variant:ident) => { #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] @@ -329,9 +354,17 @@ 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")] + #[serde( + default, + deserialize_with = "deserialize_addressing", + skip_serializing_if = "References::is_empty" + )] pub to: References, - #[serde(default, skip_serializing_if = "References::is_empty")] + #[serde( + default, + deserialize_with = "deserialize_addressing", + skip_serializing_if = "References::is_empty" + )] pub cc: References, #[serde(skip_serializing_if = "Option::is_none")] pub content: Option, @@ -462,9 +495,17 @@ pub struct Create { pub id: Iri, pub actor: Reference, pub object: Reference, - #[serde(default, skip_serializing_if = "References::is_empty")] + #[serde( + default, + deserialize_with = "deserialize_addressing", + skip_serializing_if = "References::is_empty" + )] pub to: References, - #[serde(default, skip_serializing_if = "References::is_empty")] + #[serde( + default, + deserialize_with = "deserialize_addressing", + skip_serializing_if = "References::is_empty" + )] pub cc: References, } @@ -642,6 +683,27 @@ mod tests { assert_eq!(roundtrip(&create), create); } + #[test] + fn addressing_normalizes_public_compact_terms() { + for public_address in ["Public", "as:Public"] { + let note: Note = serde_json::from_value(json!({ + "type": "Note", + "id": "https://example.com/notes/1", + "to": public_address, + })) + .expect("deserialize compact Public term"); + + assert_eq!( + note.to, + References::one(iri("https://www.w3.org/ns/activitystreams#Public")) + ); + assert_eq!( + serde_json::to_value(note).expect("serialize normalized Note")["to"], + json!("https://www.w3.org/ns/activitystreams#Public") + ); + } + } + #[test] fn concrete_types_reject_wrong_activitystreams_type() { let result = serde_json::from_value::(json!({