diff --git a/Cargo.lock b/Cargo.lock
index ccab5e7..e730504 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -95,8 +95,11 @@ dependencies = [
"axum",
"feder-core",
"feder-vocab",
+ "serde",
+ "serde_json",
"thiserror",
"tokio",
+ "tower",
"tracing",
"tracing-subscriber",
]
diff --git a/Cargo.toml b/Cargo.toml
index b7439be..f889890 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,6 +15,7 @@ license = "AGPL-3.0-only"
iri-string = { version = "0.7.12", default-features = false, features = ["alloc", "serde"] }
serde = { version = "1.0.219", default-features = false, features = ["alloc", "derive"] }
serde_json = "1.0.140"
+tower = { version = "0.5", features = ["util"]}
[workspace.lints.rust]
warnings = "deny"
diff --git a/crates/feder-runtime-server/Cargo.toml b/crates/feder-runtime-server/Cargo.toml
index e9621d3..d32af2b 100644
--- a/crates/feder-runtime-server/Cargo.toml
+++ b/crates/feder-runtime-server/Cargo.toml
@@ -12,6 +12,11 @@ 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
+
+[dev-dependencies]
+serde_json.workspace = true
+tower.workspace = true
[lints]
workspace = true
diff --git a/crates/feder-runtime-server/src/actor.rs b/crates/feder-runtime-server/src/actor.rs
new file mode 100644
index 0000000..daf6028
--- /dev/null
+++ b/crates/feder-runtime-server/src/actor.rs
@@ -0,0 +1,102 @@
+// 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 actor(
+ State(app_state): State,
+ Path(username): Path,
+) -> Result {
+ if username != app_state.username {
+ return Err(StatusCode::NOT_FOUND);
+ }
+ let local_actor = app_state.local_actor.clone();
+
+ Ok((
+ [(header::CONTENT_TYPE, "application/activity+json")],
+ Json(local_actor),
+ )
+ .into_response())
+}
+
+#[cfg(test)]
+mod tests {
+ use axum::{
+ body::{Body, to_bytes},
+ http::{Request, StatusCode, header},
+ };
+ use serde_json::Value;
+ use tower::ServiceExt;
+
+ use crate::{app::build_router, config::RuntimeConfig};
+
+ #[tokio::test]
+ async fn returns_local_actor() {
+ let app = build_router(&RuntimeConfig::default_local());
+
+ let response = app
+ .oneshot(
+ Request::builder()
+ .uri("/users/alice")
+ .body(Body::empty())
+ .expect("valid request"),
+ )
+ .await
+ .expect("response");
+
+ assert_eq!(response.status(), StatusCode::OK);
+ assert_eq!(
+ response.headers().get(header::CONTENT_TYPE).unwrap(),
+ "application/activity+json"
+ );
+
+ let body = to_bytes(response.into_body(), 2048)
+ .await
+ .expect("read response body");
+ let json: Value = serde_json::from_slice(&body).expect("valid json");
+
+ assert_eq!(json["@context"], "https://www.w3.org/ns/activitystreams");
+ assert_eq!(json["type"], "Person");
+ assert_eq!(json["id"], "http://127.0.0.1:3000/users/alice");
+ assert_eq!(json["inbox"], "http://127.0.0.1:3000/users/alice/inbox");
+ assert_eq!(json["outbox"], "http://127.0.0.1:3000/users/alice/outbox");
+ assert_eq!(json["preferredUsername"], "alice");
+ assert_eq!(json["name"], "alice");
+ }
+
+ #[tokio::test]
+ async fn rejects_unknown_actor() {
+ let app = build_router(&RuntimeConfig::default_local());
+
+ let response = app
+ .oneshot(
+ Request::builder()
+ .uri("/users/bob")
+ .body(Body::empty())
+ .expect("valid request"),
+ )
+ .await
+ .expect("response");
+
+ assert_eq!(response.status(), StatusCode::NOT_FOUND);
+ }
+}
diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs
index bff8682..fa591b9 100644
--- a/crates/feder-runtime-server/src/app.rs
+++ b/crates/feder-runtime-server/src/app.rs
@@ -16,27 +16,47 @@
use std::sync::{Arc, Mutex};
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;
+use feder_vocab::{Actor, Note, Reference};
#[derive(Clone)]
pub struct AppState {
pub core: Arc>,
+ 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 actor = Actor::person(
+ let mut actor = Actor::person(
config.actor_id.clone(),
config.inbox.clone(),
config.outbox.clone(),
);
+ actor.preferred_username = Some(config.preferred_username.clone());
+ actor.name = Some(config.username.clone());
- let core = FederCore::new(FederConfig::new(actor));
+ // 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,
}
}
}
@@ -46,6 +66,9 @@ pub fn build_router(config: &RuntimeConfig) -> Router {
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)
}
diff --git a/crates/feder-runtime-server/src/config.rs b/crates/feder-runtime-server/src/config.rs
index d0bd2bd..9039c9d 100644
--- a/crates/feder-runtime-server/src/config.rs
+++ b/crates/feder-runtime-server/src/config.rs
@@ -22,6 +22,11 @@ pub struct RuntimeConfig {
pub actor_id: Iri,
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 {
@@ -39,6 +44,13 @@ impl RuntimeConfig {
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"),
}
}
}
diff --git a/crates/feder-runtime-server/src/lib.rs b/crates/feder-runtime-server/src/lib.rs
index dcf01c5..58c1a85 100644
--- a/crates/feder-runtime-server/src/lib.rs
+++ b/crates/feder-runtime-server/src/lib.rs
@@ -13,6 +13,9 @@
// 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 note;
+pub mod webfinger;
diff --git a/crates/feder-runtime-server/src/note.rs b/crates/feder-runtime-server/src/note.rs
new file mode 100644
index 0000000..7fa92ef
--- /dev/null
+++ b/crates/feder-runtime-server/src/note.rs
@@ -0,0 +1,107 @@
+// 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
new file mode 100644
index 0000000..5eeee15
--- /dev/null
+++ b/crates/feder-runtime-server/src/webfinger.rs
@@ -0,0 +1,157 @@
+// 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())
+}
+
+#[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};
+
+ 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 response = app
+ .oneshot(
+ Request::builder()
+ .uri(WEBFINGER_PATH)
+ .body(Body::empty())
+ .expect("valid request"),
+ )
+ .await
+ .expect("response");
+
+ assert_eq!(response.status(), StatusCode::OK);
+ assert_eq!(
+ response.headers().get(header::CONTENT_TYPE).unwrap(),
+ "application/jrd+json"
+ );
+
+ let body = to_bytes(response.into_body(), 1024)
+ .await
+ .expect("read response body");
+ let json: Value = serde_json::from_slice(&body).expect("valid json");
+
+ assert_eq!(json["subject"], "acct:alice@127.0.0.1:3000");
+ assert_eq!(json["aliases"][0], "http://127.0.0.1:3000/users/alice");
+ assert_eq!(json["links"][0]["rel"], "self");
+ assert_eq!(json["links"][0]["type"], "application/activity+json");
+ assert_eq!(
+ json["links"][0]["href"],
+ "http://127.0.0.1:3000/users/alice"
+ );
+ }
+
+ #[tokio::test]
+ async fn rejects_missing_resource() {
+ let app = build_router(&RuntimeConfig::default_local());
+
+ let response = app
+ .oneshot(
+ Request::builder()
+ .uri("/.well-known/webfinger")
+ .body(Body::empty())
+ .expect("valid request"),
+ )
+ .await
+ .expect("response");
+
+ assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+ }
+
+ #[tokio::test]
+ async fn rejects_non_local_actor_resource() {
+ let app = build_router(&RuntimeConfig::default_local());
+
+ 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/mise.toml b/mise.toml
index 8da167b..966f131 100644
--- a/mise.toml
+++ b/mise.toml
@@ -23,3 +23,7 @@ run = [
[tasks.fmt]
description = "Format code and docs"
run = ["cargo fmt", "hongdown --write", "mise fmt"]
+
+[tasks.test]
+description = "Run the Rust tests"
+run = "cargo test"