Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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.

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 = "0.5"
Comment thread
sij411 marked this conversation as resolved.
Outdated

[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;
106 changes: 106 additions & 0 deletions crates/feder-runtime-server/src/note.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// 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 note = app_state
.notes
.iter()
.find(|note| note.id.as_str() == format!("http://{}/notes/{id}", app_state.handle_host))
Comment thread
sij411 marked this conversation as resolved.
Outdated
.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