Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions crates/feder-runtime-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
102 changes: 102 additions & 0 deletions crates/feder-runtime-server/src/actor.rs
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

use axum::{
Json,
extract::{Path, State},
http::{StatusCode, header},
response::{IntoResponse, Response},
};

use crate::app::AppState;

pub async fn actor(
State(app_state): State<AppState>,
Path(username): Path<String>,
) -> Result<Response, StatusCode> {
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);
}
}
29 changes: 26 additions & 3 deletions crates/feder-runtime-server/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Mutex<FederCore>>,
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<Note>,
}

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,
}
}
}
Expand All @@ -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)
}

Expand Down
12 changes: 12 additions & 0 deletions crates/feder-runtime-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"),
}
}
}
3 changes: 3 additions & 0 deletions crates/feder-runtime-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

pub mod actor;
pub mod app;
pub mod config;
pub mod error;
pub mod note;
pub mod webfinger;
107 changes: 107 additions & 0 deletions crates/feder-runtime-server/src/note.rs
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

use axum::{
Json,
extract::{Path, State},
http::{StatusCode, header},
response::{IntoResponse, Response},
};

use crate::app::AppState;

pub async fn note(
State(app_state): State<AppState>,
Path(id): Path<String>,
) -> Result<Response, StatusCode> {
// 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())
}
Comment thread
sij411 marked this conversation as resolved.

#[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);
}
}
Loading
Loading