From d0ba5d94f54c10755dd98303c3f9a3731392af98 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Fri, 3 Jul 2026 17:25:35 +0900 Subject: [PATCH 1/8] Refactor runtime server into reusable crate Move the hardcoded single-user demo into examples/single-user-server and leave feder-runtime-server as reusable Axum/Tokio integration. Expose the router/config building blocks, remove default local demo config from the library, and test the reusable routes directly. Assisted-by: Codex:gpt-5.5 --- Cargo.lock | 13 ++- Cargo.toml | 1 + crates/feder-runtime-server/Cargo.toml | 6 +- crates/feder-runtime-server/README.md | 82 ++++++-------- crates/feder-runtime-server/src/actor.rs | 6 +- crates/feder-runtime-server/src/app.rs | 59 ++++++---- crates/feder-runtime-server/src/config.rs | 41 +++---- crates/feder-runtime-server/src/lib.rs | 5 +- crates/feder-runtime-server/src/note.rs | 107 ------------------ crates/feder-runtime-server/src/webfinger.rs | 8 +- examples/single-user-server/Cargo.toml | 15 +++ examples/single-user-server/README.md | 49 ++++++++ .../single-user-server}/src/main.rs | 31 ++++- 13 files changed, 203 insertions(+), 220 deletions(-) delete mode 100644 crates/feder-runtime-server/src/note.rs create mode 100644 examples/single-user-server/Cargo.toml create mode 100644 examples/single-user-server/README.md rename {crates/feder-runtime-server => examples/single-user-server}/src/main.rs (50%) diff --git a/Cargo.lock b/Cargo.lock index e730504..ec9fc09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,8 +100,6 @@ dependencies = [ "thiserror", "tokio", "tower", - "tracing", - "tracing-subscriber", ] [[package]] @@ -449,6 +447,17 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "single-user-server" +version = "0.1.0" +dependencies = [ + "axum", + "feder-runtime-server", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "slab" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index f889890..7fba940 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "crates/feder-core", "crates/feder-vocab", "crates/feder-runtime-server", + "examples/single-user-server", ] resolver = "3" diff --git a/crates/feder-runtime-server/Cargo.toml b/crates/feder-runtime-server/Cargo.toml index d32af2b..de07bb4 100644 --- a/crates/feder-runtime-server/Cargo.toml +++ b/crates/feder-runtime-server/Cargo.toml @@ -8,14 +8,12 @@ license.workspace = true feder-core = { path = "../feder-core" } feder-vocab = { path = "../feder-vocab" } axum = "0.8" -thiserror = "2" -tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] } -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } serde.workspace = true +thiserror = "2" [dev-dependencies] serde_json.workspace = true +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } tower.workspace = true [lints] diff --git a/crates/feder-runtime-server/README.md b/crates/feder-runtime-server/README.md index 77a93c1..fcaa759 100644 --- a/crates/feder-runtime-server/README.md +++ b/crates/feder-runtime-server/README.md @@ -1,54 +1,46 @@ Feder Runtime Server ==================== -Runnable server runtime for Feder on standard operating systems. - -This crate currently starts a local development server with one configured -actor and a health check endpoint. ActivityPub discovery, inbox handling, -storage, signing, and delivery are intentionally left to later issues. - - -Run ---- - -On Unix shells: - -~~~~ sh -RUST_LOG=info cargo run -p feder-runtime-server -~~~~ - -On PowerShell: - -~~~~ powershell -$env:RUST_LOG = "info"; cargo run -p feder-runtime-server -~~~~ - -On cmd.exe: - -~~~~ bat -set RUST_LOG=info && cargo run -p feder-runtime-server -~~~~ - -The default local actor is: - -~~~~ text -http://127.0.0.1:3000/users/alice +Reusable Axum/Tokio server integration for Feder on standard operating +systems. + +This crate builds an Axum router from caller-provided runtime configuration. +It provides a health check endpoint, WebFinger discovery, and a local actor +route. The caller chooses concrete bind addresses, actor IRIs, usernames, and +handle hosts. + +ActivityPub inbox handling, storage, signing, and delivery are intentionally +left to later issues. + +Example +------- + +~~~~ rust +use feder_runtime_server::{RuntimeConfig, 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(), +}; + +let app = build_router(config); ~~~~ -The server listens on: +Demo +---- -~~~~ text -127.0.0.1:3000 -~~~~ - -Check the process: +A runnable single-user demo lives in `examples/single-user-server`: ~~~~ sh -curl -i http://127.0.0.1:3000/healthz -~~~~ - -Expected response: - -~~~~ text -HTTP/1.1 204 No Content +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 index daf6028..e686b66 100644 --- a/crates/feder-runtime-server/src/actor.rs +++ b/crates/feder-runtime-server/src/actor.rs @@ -47,11 +47,11 @@ mod tests { use serde_json::Value; use tower::ServiceExt; - use crate::{app::build_router, config::RuntimeConfig}; + use crate::{build_router, config::test_config}; #[tokio::test] async fn returns_local_actor() { - let app = build_router(&RuntimeConfig::default_local()); + let app = build_router(test_config()); let response = app .oneshot( @@ -85,7 +85,7 @@ mod tests { #[tokio::test] async fn rejects_unknown_actor() { - let app = build_router(&RuntimeConfig::default_local()); + let app = build_router(test_config()); let response = app .oneshot( diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs index fa591b9..9185d0d 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -15,12 +15,12 @@ use std::sync::{Arc, Mutex}; +use crate::actor::actor; use crate::config::RuntimeConfig; use crate::webfinger::webfinger; -use crate::{actor::actor, note::note}; use axum::{Router, http::StatusCode, routing::get}; use feder_core::{FederConfig, FederCore}; -use feder_vocab::{Actor, Note, Reference}; +use feder_vocab::Actor; #[derive(Clone)] pub struct AppState { @@ -28,50 +28,63 @@ pub struct AppState { pub local_actor: Actor, pub username: String, pub handle_host: String, - // TODO(#25): Replace this seeded preview note with durable runtime storage. - pub notes: Vec, } impl AppState { - pub fn from_config(config: &RuntimeConfig) -> Self { - let mut actor = Actor::person( - config.actor_id.clone(), - config.inbox.clone(), - config.outbox.clone(), - ); - actor.preferred_username = Some(config.preferred_username.clone()); + pub fn from_config(config: RuntimeConfig) -> Self { + 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()); - // TODO(#25): Replace this seeded preview note with durable runtime storage. - let mut note = Note::new(config.note_id.clone()); - note.attributed_to = Some(Reference::id(config.actor_id.clone())); - note.content = - Some("Hello, World! This is Feder, a portable AP core for many runtimes.".to_string()); - let notes = vec![note]; - let core = FederCore::new(FederConfig::new(actor.clone())); Self { core: Arc::new(Mutex::new(core)), local_actor: actor, - username: config.username.clone(), - handle_host: config.handle_host.clone(), - notes, + username: config.username, + handle_host: config.handle_host, } } } -pub fn build_router(config: &RuntimeConfig) -> Router { +pub fn build_router(config: RuntimeConfig) -> Router { let state = AppState::from_config(config); Router::new() .route("/healthz", get(healthz)) .route("/.well-known/webfinger", get(webfinger)) .route("/users/{username}", get(actor)) - .route("/notes/{id}", get(note)) .with_state(state) } async fn healthz() -> StatusCode { StatusCode::NO_CONTENT } + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use tower::ServiceExt; + + use crate::{build_router, config::test_config}; + + #[tokio::test] + async fn returns_health_check() { + let app = build_router(test_config()); + + let response = app + .oneshot( + Request::builder() + .uri("/healthz") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + } +} diff --git a/crates/feder-runtime-server/src/config.rs b/crates/feder-runtime-server/src/config.rs index 9039c9d..5075e3b 100644 --- a/crates/feder-runtime-server/src/config.rs +++ b/crates/feder-runtime-server/src/config.rs @@ -23,34 +23,23 @@ pub struct RuntimeConfig { pub inbox: Iri, pub outbox: Iri, pub username: String, - pub preferred_username: String, pub handle_host: String, - // TODO(#25): Replace this seeded preview note with durable runtime storage. - pub note_id: Iri, } -impl RuntimeConfig { - pub fn default_local() -> Self { - Self { - actor_id: "http://127.0.0.1:3000/users/alice" - .parse() - .expect("valid default actor IRI"), - inbox: "http://127.0.0.1:3000/users/alice/inbox" - .parse() - .expect("valid default inbox IRI"), - outbox: "http://127.0.0.1:3000/users/alice/outbox" - .parse() - .expect("valid default outbox IRI"), - bind: "127.0.0.1:3000" - .parse() - .expect("valid default bind address"), - username: "alice".to_string(), - preferred_username: "alice".to_string(), - handle_host: "127.0.0.1:3000".to_string(), - // TODO(#25): Replace this seeded preview note with durable runtime storage. - note_id: "http://127.0.0.1:3000/notes/1" - .parse() - .expect("valid default note IRI"), - } +#[cfg(test)] +pub(crate) fn test_config() -> RuntimeConfig { + RuntimeConfig { + actor_id: "http://127.0.0.1:3000/users/alice" + .parse() + .expect("valid actor IRI"), + inbox: "http://127.0.0.1:3000/users/alice/inbox" + .parse() + .expect("valid inbox IRI"), + outbox: "http://127.0.0.1:3000/users/alice/outbox" + .parse() + .expect("valid outbox IRI"), + bind: "127.0.0.1:3000".parse().expect("valid bind address"), + username: "alice".to_string(), + handle_host: "127.0.0.1:3000".to_string(), } } diff --git a/crates/feder-runtime-server/src/lib.rs b/crates/feder-runtime-server/src/lib.rs index 58c1a85..88c2db8 100644 --- a/crates/feder-runtime-server/src/lib.rs +++ b/crates/feder-runtime-server/src/lib.rs @@ -17,5 +17,8 @@ pub mod actor; pub mod app; pub mod config; pub mod error; -pub mod note; pub mod webfinger; + +pub use app::{AppState, build_router}; +pub use config::RuntimeConfig; +pub use error::Error; diff --git a/crates/feder-runtime-server/src/note.rs b/crates/feder-runtime-server/src/note.rs deleted file mode 100644 index 7fa92ef..0000000 --- a/crates/feder-runtime-server/src/note.rs +++ /dev/null @@ -1,107 +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::{StatusCode, header}, - response::{IntoResponse, Response}, -}; - -use crate::app::AppState; - -pub async fn note( - State(app_state): State, - Path(id): Path, -) -> Result { - // TODO(#25): Replace this seeded preview note with durable runtime storage. - let expected_suffix = format!("/notes/{id}"); - let note = app_state - .notes - .iter() - .find(|note| note.id.as_str().ends_with(&expected_suffix)) - .cloned() - .ok_or(StatusCode::NOT_FOUND)?; - - Ok(( - [(header::CONTENT_TYPE, "application/activity+json")], - Json(note), - ) - .into_response()) -} - -#[cfg(test)] -mod tests { - use axum::{ - body::{Body, to_bytes}, - http::{Request, StatusCode, header}, - }; - use serde_json::Value; - use tower::ServiceExt; - - use crate::{app::build_router, config::RuntimeConfig}; - - #[tokio::test] - async fn returns_public_note() { - let app = build_router(&RuntimeConfig::default_local()); - - let response = app - .oneshot( - Request::builder() - .uri("/notes/1") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response.headers().get(header::CONTENT_TYPE).unwrap(), - "application/activity+json" - ); - - let body = to_bytes(response.into_body(), 2048) - .await - .expect("read response body"); - let json: Value = serde_json::from_slice(&body).expect("valid json"); - - assert_eq!(json["@context"], "https://www.w3.org/ns/activitystreams"); - assert_eq!(json["type"], "Note"); - assert_eq!(json["id"], "http://127.0.0.1:3000/notes/1"); - assert_eq!(json["attributedTo"], "http://127.0.0.1:3000/users/alice"); - assert_eq!( - json["content"], - "Hello, World! This is Feder, a portable AP core for many runtimes." - ); - } - - #[tokio::test] - async fn rejects_unknown_note() { - let app = build_router(&RuntimeConfig::default_local()); - - let response = app - .oneshot( - Request::builder() - .uri("/notes/missing") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } -} diff --git a/crates/feder-runtime-server/src/webfinger.rs b/crates/feder-runtime-server/src/webfinger.rs index 5eeee15..9d4e5cf 100644 --- a/crates/feder-runtime-server/src/webfinger.rs +++ b/crates/feder-runtime-server/src/webfinger.rs @@ -82,13 +82,13 @@ mod tests { use serde_json::Value; use tower::ServiceExt; - use crate::{app::build_router, config::RuntimeConfig}; + use crate::{build_router, config::test_config}; const WEBFINGER_PATH: &str = "/.well-known/webfinger?resource=acct:alice@127.0.0.1:3000"; #[tokio::test] async fn returns_webfinger_descriptor_for_local_actor() { - let app = build_router(&RuntimeConfig::default_local()); + let app = build_router(test_config()); let response = app .oneshot( @@ -123,7 +123,7 @@ mod tests { #[tokio::test] async fn rejects_missing_resource() { - let app = build_router(&RuntimeConfig::default_local()); + let app = build_router(test_config()); let response = app .oneshot( @@ -140,7 +140,7 @@ mod tests { #[tokio::test] async fn rejects_non_local_actor_resource() { - let app = build_router(&RuntimeConfig::default_local()); + let app = build_router(test_config()); let response = app .oneshot( diff --git a/examples/single-user-server/Cargo.toml b/examples/single-user-server/Cargo.toml new file mode 100644 index 0000000..5cac0c3 --- /dev/null +++ b/examples/single-user-server/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "single-user-server" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +axum = "0.8" +feder-runtime-server = { path = "../../crates/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/single-user-server/README.md b/examples/single-user-server/README.md new file mode 100644 index 0000000..8c446c0 --- /dev/null +++ b/examples/single-user-server/README.md @@ -0,0 +1,49 @@ +Single-User Server Example +========================== + +Demo app using `feder-runtime-server` with one hardcoded local actor. + +Run +--- + +On Unix shells: + +~~~~ sh +RUST_LOG=info cargo run -p single-user-server +~~~~ + +On PowerShell: + +~~~~ powershell +$env:RUST_LOG = "info"; cargo run -p single-user-server +~~~~ + +On cmd.exe: + +~~~~ bat +set RUST_LOG=info && cargo run -p single-user-server +~~~~ + +The demo actor is: + +~~~~ text +http://127.0.0.1:3000/users/alice +~~~~ + +The server listens on: + +~~~~ text +127.0.0.1:3000 +~~~~ + +Check the process: + +~~~~ sh +curl -i http://127.0.0.1:3000/healthz +~~~~ + +Expected response: + +~~~~ text +HTTP/1.1 204 No Content +~~~~ diff --git a/crates/feder-runtime-server/src/main.rs b/examples/single-user-server/src/main.rs similarity index 50% rename from crates/feder-runtime-server/src/main.rs rename to examples/single-user-server/src/main.rs index a13027b..0635dd9 100644 --- a/crates/feder-runtime-server/src/main.rs +++ b/examples/single-user-server/src/main.rs @@ -13,7 +13,26 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use feder_runtime_server::{app::build_router, config::RuntimeConfig, error::Error}; +use feder_runtime_server::{Error, RuntimeConfig, build_router}; + +fn default_local() -> RuntimeConfig { + RuntimeConfig { + bind: "127.0.0.1:3000" + .parse() + .expect("valid default bind address"), + actor_id: "http://127.0.0.1:3000/users/alice" + .parse() + .expect("valid default actor IRI"), + inbox: "http://127.0.0.1:3000/users/alice/inbox" + .parse() + .expect("valid default inbox IRI"), + outbox: "http://127.0.0.1:3000/users/alice/outbox" + .parse() + .expect("valid default outbox IRI"), + username: "alice".to_string(), + handle_host: "127.0.0.1:3000".to_string(), + } +} #[tokio::main] async fn main() -> Result<(), Error> { @@ -21,12 +40,14 @@ async fn main() -> Result<(), Error> { .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); - let config = RuntimeConfig::default_local(); - let app = build_router(&config); + let config = default_local(); + let bind = config.bind; + let actor_id = config.actor_id.clone(); + let app = build_router(config); - tracing::info!(bind = %&config.bind, actor = %&config.actor_id, "starting Feder runtime"); + tracing::info!(bind = %bind, actor = %actor_id, "starting Feder single-user example"); - let listener = tokio::net::TcpListener::bind(config.bind) + let listener = tokio::net::TcpListener::bind(bind) .await .map_err(Error::Bind)?; axum::serve(listener, app).await.map_err(Error::Serve)?; From 4c463102fb4fec0ad5d0fad83ac06107932de016 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 7 Jul 2026 02:52:32 +0900 Subject: [PATCH 2/8] Add HTTP inbox route skeleton --- crates/feder-runtime-server/README.md | 2 + crates/feder-runtime-server/src/app.rs | 1 + crates/feder-runtime-server/src/inbox.rs | 64 ++++++++++++++++++++++++ crates/feder-runtime-server/src/lib.rs | 1 + 4 files changed, 68 insertions(+) create mode 100644 crates/feder-runtime-server/src/inbox.rs diff --git a/crates/feder-runtime-server/README.md b/crates/feder-runtime-server/README.md index fcaa759..3d8a1a0 100644 --- a/crates/feder-runtime-server/README.md +++ b/crates/feder-runtime-server/README.md @@ -12,6 +12,7 @@ handle hosts. ActivityPub inbox handling, storage, signing, and delivery are intentionally left to later issues. + Example ------- @@ -36,6 +37,7 @@ let config = RuntimeConfig { let app = build_router(config); ~~~~ + Demo ---- diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs index 9185d0d..7826647 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -54,6 +54,7 @@ pub fn build_router(config: RuntimeConfig) -> Router { .route("/healthz", get(healthz)) .route("/.well-known/webfinger", get(webfinger)) .route("/users/{username}", get(actor)) + .route("/users/{identifier}/inbox", post(inbox)) .with_state(state) } diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs new file mode 100644 index 0000000..1d26ff0 --- /dev/null +++ b/crates/feder-runtime-server/src/inbox.rs @@ -0,0 +1,64 @@ +// 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::Bytes, + extract::{Path, State}, + http::{HeaderMap, Method, Request, StatusCode, Uri, header::CONTENT_TYPE}, + response::{IntoResponse, Response}, +}; + +use crate::app::AppState; + +pub struct InboxRequest { + pub username: String, + pub headers: HeaderMap, + pub method: Method, + pub uri: Uri, + pub body: Bytes, +} + +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()) + .unwrap_or(""); + + if !content_type.starts_with("application/activity+json") + && !content_type.starts_with("application/ld+json") + { + return Err(StatusCode::UNSUPPORTED_MEDIA_TYPE); + } + + let req = InboxRequest { + username, + headers, + method, + uri, + body, + }; + + Ok(StatusCode::ACCEPTED.into_response()) +} diff --git a/crates/feder-runtime-server/src/lib.rs b/crates/feder-runtime-server/src/lib.rs index 88c2db8..2caca7c 100644 --- a/crates/feder-runtime-server/src/lib.rs +++ b/crates/feder-runtime-server/src/lib.rs @@ -17,6 +17,7 @@ pub mod actor; pub mod app; pub mod config; pub mod error; +pub mod inbox; pub mod webfinger; pub use app::{AppState, build_router}; From 4eab2154b510c9cfe22772f713681f9c5c7db093 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 7 Jul 2026 03:02:09 +0900 Subject: [PATCH 3/8] Set default body limit to 1MB --- crates/feder-runtime-server/src/app.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs index 7826647..45d3e64 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -18,7 +18,7 @@ use std::sync::{Arc, Mutex}; use crate::actor::actor; use crate::config::RuntimeConfig; use crate::webfinger::webfinger; -use axum::{Router, http::StatusCode, routing::get}; +use axum::{Router, extract::DefaultBodyLimit, http::StatusCode, routing::get}; use feder_core::{FederConfig, FederCore}; use feder_vocab::Actor; @@ -55,6 +55,7 @@ pub fn build_router(config: RuntimeConfig) -> Router { .route("/.well-known/webfinger", get(webfinger)) .route("/users/{username}", get(actor)) .route("/users/{identifier}/inbox", post(inbox)) + .layer(DefaultBodyLimit::max(1_048_576)) .with_state(state) } From 15c0b73c38ba22a7b6a93d719f6a9d316524f96c Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 7 Jul 2026 13:27:20 +0900 Subject: [PATCH 4/8] Handle received follow acitivity --- Cargo.lock | 1 + crates/feder-core/src/lib.rs | 6 ++++ crates/feder-runtime-server/Cargo.toml | 2 ++ crates/feder-runtime-server/src/app.rs | 7 +++-- crates/feder-runtime-server/src/inbox.rs | 39 +++++++++++++++++++++++- 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ec9fc09..1b08839 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,6 +95,7 @@ dependencies = [ "axum", "feder-core", "feder-vocab", + "percent-encoding", "serde", "serde_json", "thiserror", diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 4808aea..3e59dfa 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -293,6 +293,12 @@ pub struct UserCreateNote { pub published: Option, } +impl Input { + pub fn received_follow(follow: vocab::Follow, accept_id: vocab::Iri) -> Self { + Self::ReceivedFollow(ReceivedFollow { follow, accept_id }) + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct Follower { pub follower: vocab::Iri, diff --git a/crates/feder-runtime-server/Cargo.toml b/crates/feder-runtime-server/Cargo.toml index de07bb4..8ab56d1 100644 --- a/crates/feder-runtime-server/Cargo.toml +++ b/crates/feder-runtime-server/Cargo.toml @@ -10,6 +10,8 @@ feder-vocab = { path = "../feder-vocab" } axum = "0.8" serde.workspace = true thiserror = "2" +serde_json.workspace = true +percent-encoding = "2.3.2" [dev-dependencies] serde_json.workspace = true diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs index 45d3e64..d26f7aa 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -15,9 +15,10 @@ use std::sync::{Arc, Mutex}; -use crate::actor::actor; use crate::config::RuntimeConfig; use crate::webfinger::webfinger; +use crate::{actor::actor, inbox::inbox}; +use axum::routing::post; use axum::{Router, extract::DefaultBodyLimit, http::StatusCode, routing::get}; use feder_core::{FederConfig, FederCore}; use feder_vocab::Actor; @@ -54,8 +55,8 @@ pub fn build_router(config: RuntimeConfig) -> Router { .route("/healthz", get(healthz)) .route("/.well-known/webfinger", get(webfinger)) .route("/users/{username}", get(actor)) - .route("/users/{identifier}/inbox", post(inbox)) - .layer(DefaultBodyLimit::max(1_048_576)) + .route("/users/{username}/inbox", post(inbox)) + .layer(DefaultBodyLimit::max(1_048_576)) .with_state(state) } diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index 1d26ff0..f3231ad 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -16,10 +16,14 @@ use axum::{ body::Bytes, extract::{Path, State}, - http::{HeaderMap, Method, Request, StatusCode, Uri, header::CONTENT_TYPE}, + http::{HeaderMap, Method, StatusCode, Uri, header::CONTENT_TYPE}, response::{IntoResponse, Response}, }; +use feder_core::Input; +use feder_vocab::Follow; +use serde_json::{Value, from_slice, from_value}; + use crate::app::AppState; pub struct InboxRequest { @@ -30,6 +34,20 @@ pub struct InboxRequest { 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) +} + pub async fn inbox( State(app_state): State, Path(username): Path, @@ -60,5 +78,24 @@ pub async fn inbox( body, }; + let value: Value = from_slice(&req.body).map_err(|_| StatusCode::BAD_REQUEST)?; + + let activity_type = value.get("type").and_then(|value| value.as_str()); + + // Unsupported activity types will be ignored + if activity_type != Some("Follow") { + return Ok(StatusCode::ACCEPTED.into_response()); + } + let follow: Follow = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; + let accept_id = accept_id_for_follow(&app_state.local_actor.id, &follow.id)?; + let input = Input::received_follow(follow, accept_id); + + let mut core = app_state + .core + .lock() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let _result = core.handle(input); + Ok(StatusCode::ACCEPTED.into_response()) } From 22ceb3fc81ef196ea36d671861b1d3fd0d35a775 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 7 Jul 2026 14:13:07 +0900 Subject: [PATCH 5/8] Add inbox follow core and rejection tests Covers invalid inbox targets, unsupported content types, malformed JSON, unsupported activities, and oversized request bodies. Assisted-by: Codex:gpt-5.5 --- crates/feder-runtime-server/src/inbox.rs | 215 +++++++++++++++++++++++ 1 file changed, 215 insertions(+) diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index f3231ad..19c26ef 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -99,3 +99,218 @@ pub async fn inbox( Ok(StatusCode::ACCEPTED.into_response()) } + +#[cfg(test)] +mod tests { + use axum::{ + body::{Body, Bytes}, + extract::Path, + http::{HeaderMap, Method, Request, StatusCode, Uri, header::CONTENT_TYPE}, + response::Response, + }; + use serde_json::json; + use tower::ServiceExt; + + use crate::{app::AppState, build_router, config::test_config}; + + use super::inbox; + + fn activity_json_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, "application/activity+json".parse().unwrap()); + headers + } + + fn follow_body() -> Bytes { + Bytes::from( + serde_json::to_vec(&json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Follow", + "id": "https://remote.example/activities/follow-1", + "actor": { + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Person", + "id": "https://remote.example/users/bob", + "inbox": "https://remote.example/users/bob/inbox", + "outbox": "https://remote.example/users/bob/outbox" + }, + "object": "http://127.0.0.1:3000/users/alice" + })) + .expect("serialize follow"), + ) + } + + async fn post_inbox( + app_state: AppState, + username: &str, + headers: HeaderMap, + body: Bytes, + ) -> Result { + inbox( + axum::extract::State(app_state), + Path(username.to_string()), + headers, + Method::POST, + Uri::from_static("/users/alice/inbox"), + body, + ) + .await + } + + #[tokio::test] + async fn valid_follow_reaches_core() { + let app_state = AppState::from_config(test_config()); + + let response = post_inbox( + app_state.clone(), + "alice", + activity_json_headers(), + follow_body(), + ) + .await + .expect("accepted follow"); + + assert_eq!(response.status(), StatusCode::ACCEPTED); + + let core = app_state.core.lock().expect("core lock"); + assert_eq!(core.state().followers().len(), 1); + assert_eq!( + core.state().followers()[0].follower.as_str(), + "https://remote.example/users/bob" + ); + assert_eq!( + core.state().followers()[0].following.as_str(), + "http://127.0.0.1:3000/users/alice" + ); + assert_eq!(core.state().delivery_targets().len(), 1); + assert_eq!( + core.state().delivery_targets()[0].inbox.as_str(), + "https://remote.example/users/bob/inbox" + ); + } + + #[tokio::test] + async fn rejects_unknown_inbox_actor() { + let app_state = AppState::from_config(test_config()); + + let error = post_inbox( + app_state.clone(), + "bob", + activity_json_headers(), + follow_body(), + ) + .await + .expect_err("unknown inbox actor should be rejected"); + + assert_eq!(error, StatusCode::NOT_FOUND); + assert!( + app_state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); + } + + #[tokio::test] + async fn rejects_unsupported_content_type() { + let app_state = AppState::from_config(test_config()); + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, "application/json".parse().unwrap()); + + let error = post_inbox(app_state.clone(), "alice", headers, follow_body()) + .await + .expect_err("unsupported content type should be rejected"); + + assert_eq!(error, StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert!( + app_state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); + } + + #[tokio::test] + async fn rejects_malformed_json() { + let app_state = AppState::from_config(test_config()); + + let error = post_inbox( + app_state.clone(), + "alice", + activity_json_headers(), + Bytes::from_static(b"{not json"), + ) + .await + .expect_err("malformed json should be rejected"); + + assert_eq!(error, StatusCode::BAD_REQUEST); + assert!( + app_state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); + } + + #[tokio::test] + async fn ignores_unsupported_activity_without_mutating_core() { + let app_state = AppState::from_config(test_config()); + let body = Bytes::from( + serde_json::to_vec(&json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Create", + "id": "https://remote.example/activities/create-1", + "actor": "https://remote.example/users/bob", + "object": { + "type": "Note", + "id": "https://remote.example/notes/1" + } + })) + .expect("serialize create"), + ); + + let response = post_inbox(app_state.clone(), "alice", activity_json_headers(), body) + .await + .expect("unsupported activity is accepted but ignored"); + + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert!( + app_state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); + } + + #[tokio::test] + async fn rejects_oversized_inbox_body() { + let app = build_router(test_config()); + let oversized_body = vec![b' '; 1_048_577]; + + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/users/alice/inbox") + .header(CONTENT_TYPE, "application/activity+json") + .body(Body::from(oversized_body)) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + } +} From 28f0957eb670c70a104700b66d86464718080411 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 7 Jul 2026 15:54:02 +0900 Subject: [PATCH 6/8] Add inbox unsigned auth policy Reject unsigned inbox requests before core handling when signed inbox auth is required, while keeping explicit insecure dev mode for tests and the single-user example. Assisted-by: Codex:gpt-5.5 --- crates/feder-runtime-server/src/app.rs | 4 ++- crates/feder-runtime-server/src/config.rs | 8 +++++ crates/feder-runtime-server/src/inbox.rs | 43 ++++++++++++++++++++++- crates/feder-runtime-server/src/lib.rs | 2 +- 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs index d26f7aa..953705c 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -15,7 +15,7 @@ use std::sync::{Arc, Mutex}; -use crate::config::RuntimeConfig; +use crate::config::{InboxAuthPolicy, RuntimeConfig}; use crate::webfinger::webfinger; use crate::{actor::actor, inbox::inbox}; use axum::routing::post; @@ -29,6 +29,7 @@ pub struct AppState { pub local_actor: Actor, pub username: String, pub handle_host: String, + pub inbox_auth_policy: InboxAuthPolicy, } impl AppState { @@ -44,6 +45,7 @@ impl AppState { local_actor: actor, username: config.username, handle_host: config.handle_host, + inbox_auth_policy: config.inbox_auth_policy, } } } diff --git a/crates/feder-runtime-server/src/config.rs b/crates/feder-runtime-server/src/config.rs index 5075e3b..59bf8c1 100644 --- a/crates/feder-runtime-server/src/config.rs +++ b/crates/feder-runtime-server/src/config.rs @@ -17,6 +17,12 @@ use std::net::SocketAddr; use feder_vocab::Iri; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InboxAuthPolicy { + RequireSigned, + AllowUnsignedInsecureDev, +} + pub struct RuntimeConfig { pub bind: SocketAddr, pub actor_id: Iri, @@ -24,6 +30,7 @@ pub struct RuntimeConfig { pub outbox: Iri, pub username: String, pub handle_host: String, + pub inbox_auth_policy: InboxAuthPolicy, } #[cfg(test)] @@ -41,5 +48,6 @@ pub(crate) fn test_config() -> RuntimeConfig { 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, } } diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs index 19c26ef..6416d79 100644 --- a/crates/feder-runtime-server/src/inbox.rs +++ b/crates/feder-runtime-server/src/inbox.rs @@ -25,6 +25,7 @@ use feder_vocab::Follow; use serde_json::{Value, from_slice, from_value}; use crate::app::AppState; +use crate::config::InboxAuthPolicy; pub struct InboxRequest { pub username: String, @@ -48,6 +49,13 @@ fn accept_id_for_follow( .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) } +fn verify_inbox_request(app_state: &AppState, _req: &InboxRequest) -> Result<(), StatusCode> { + match app_state.inbox_auth_policy { + InboxAuthPolicy::AllowUnsignedInsecureDev => Ok(()), + InboxAuthPolicy::RequireSigned => Err(StatusCode::UNAUTHORIZED), + } +} + pub async fn inbox( State(app_state): State, Path(username): Path, @@ -78,6 +86,8 @@ pub async fn inbox( body, }; + verify_inbox_request(&app_state, &req)?; + let value: Value = from_slice(&req.body).map_err(|_| StatusCode::BAD_REQUEST)?; let activity_type = value.get("type").and_then(|value| value.as_str()); @@ -111,7 +121,11 @@ mod tests { use serde_json::json; use tower::ServiceExt; - use crate::{app::AppState, build_router, config::test_config}; + use crate::{ + app::AppState, + build_router, + config::{InboxAuthPolicy, test_config}, + }; use super::inbox; @@ -189,6 +203,33 @@ mod tests { ); } + #[tokio::test] + async fn require_signed_rejects_unsigned_follow_before_core() { + let mut config = test_config(); + config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; + let app_state = AppState::from_config(config); + + let error = post_inbox( + app_state.clone(), + "alice", + activity_json_headers(), + follow_body(), + ) + .await + .expect_err("unsigned follow should be rejected"); + + assert_eq!(error, StatusCode::UNAUTHORIZED); + assert!( + app_state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); + } + #[tokio::test] async fn rejects_unknown_inbox_actor() { let app_state = AppState::from_config(test_config()); diff --git a/crates/feder-runtime-server/src/lib.rs b/crates/feder-runtime-server/src/lib.rs index 2caca7c..952cfb4 100644 --- a/crates/feder-runtime-server/src/lib.rs +++ b/crates/feder-runtime-server/src/lib.rs @@ -21,5 +21,5 @@ pub mod inbox; pub mod webfinger; pub use app::{AppState, build_router}; -pub use config::RuntimeConfig; +pub use config::{InboxAuthPolicy, RuntimeConfig}; pub use error::Error; From d5428f865a8c0552ea63b5a9aa2a8657bf59a06c Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 7 Jul 2026 22:38:01 +0900 Subject: [PATCH 7/8] Set the demo server to explicit insecure unsigned inbox mode after adding the runtime inbox auth policy. Assisted-by: Codex:gpt-5.5 --- examples/single-user-server/README.md | 1 + examples/single-user-server/src/main.rs | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/single-user-server/README.md b/examples/single-user-server/README.md index 8c446c0..b0a3297 100644 --- a/examples/single-user-server/README.md +++ b/examples/single-user-server/README.md @@ -3,6 +3,7 @@ Single-User Server Example Demo app using `feder-runtime-server` with one hardcoded local actor. + Run --- diff --git a/examples/single-user-server/src/main.rs b/examples/single-user-server/src/main.rs index 0635dd9..0e44006 100644 --- a/examples/single-user-server/src/main.rs +++ b/examples/single-user-server/src/main.rs @@ -13,7 +13,7 @@ // 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, RuntimeConfig, build_router}; +use feder_runtime_server::{Error, InboxAuthPolicy, RuntimeConfig, build_router}; fn default_local() -> RuntimeConfig { RuntimeConfig { @@ -31,6 +31,7 @@ fn default_local() -> RuntimeConfig { .expect("valid default outbox IRI"), username: "alice".to_string(), handle_host: "127.0.0.1:3000".to_string(), + inbox_auth_policy: InboxAuthPolicy::AllowUnsignedInsecureDev, } } From 30939a62be654250eed42e97eb323ff5979ae010 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 8 Jul 2026 00:08:01 +0900 Subject: [PATCH 8/8] Fix document since inbox handling is implemented --- crates/feder-runtime-server/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/feder-runtime-server/README.md b/crates/feder-runtime-server/README.md index 3d8a1a0..0ffdebc 100644 --- a/crates/feder-runtime-server/README.md +++ b/crates/feder-runtime-server/README.md @@ -9,15 +9,15 @@ It provides a health check endpoint, WebFinger discovery, and a local actor route. The caller chooses concrete bind addresses, actor IRIs, usernames, and handle hosts. -ActivityPub inbox handling, storage, signing, and delivery are intentionally -left to later issues. +ActivityPub inbox handling for supported Follow activities is included. Storage, +signature verification, and delivery are intentionally left to later issues. Example ------- ~~~~ rust -use feder_runtime_server::{RuntimeConfig, build_router}; +use feder_runtime_server::{InboxAuthPolicy, RuntimeConfig, build_router}; let config = RuntimeConfig { bind: "127.0.0.1:3000".parse().expect("valid bind address"), @@ -32,6 +32,7 @@ let config = RuntimeConfig { .expect("valid outbox IRI"), username: "alice".to_string(), handle_host: "127.0.0.1:3000".to_string(), + inbox_auth_policy: InboxAuthPolicy::AllowUnsignedInsecureDev, }; let app = build_router(config);