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
14 changes: 12 additions & 2 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 @@ -3,6 +3,7 @@ members = [
"crates/feder-core",
"crates/feder-vocab",
"crates/feder-runtime-server",
"examples/single-user-server",
]
resolver = "3"

Expand Down
6 changes: 6 additions & 0 deletions crates/feder-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,12 @@ pub struct UserCreateNote {
pub published: Option<String>,
}

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,
Expand Down
8 changes: 4 additions & 4 deletions crates/feder-runtime-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ 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"
serde_json.workspace = true
percent-encoding = "2.3.2"

[dev-dependencies]
serde_json.workspace = true
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tower.workspace = true

[lints]
Expand Down
83 changes: 39 additions & 44 deletions crates/feder-runtime-server/README.md
Original file line number Diff line number Diff line change
@@ -1,54 +1,49 @@
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
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 for supported Follow activities is included. Storage,
signature verification, and delivery are intentionally left to later issues.


Example
-------

~~~~ rust
use feder_runtime_server::{InboxAuthPolicy, 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(),
inbox_auth_policy: InboxAuthPolicy::AllowUnsignedInsecureDev,
};

let app = build_router(config);
~~~~
Comment thread
sij411 marked this conversation as resolved.

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
~~~~
Demo
----

The default local actor is:

~~~~ text
http://127.0.0.1:3000/users/alice
~~~~

The server listens on:

~~~~ 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
~~~~
6 changes: 3 additions & 3 deletions crates/feder-runtime-server/src/actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
68 changes: 43 additions & 25 deletions crates/feder-runtime-server/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,63 +15,81 @@

use std::sync::{Arc, Mutex};

use crate::config::RuntimeConfig;
use crate::config::{InboxAuthPolicy, RuntimeConfig};
use crate::webfinger::webfinger;
use crate::{actor::actor, note::note};
use axum::{Router, http::StatusCode, routing::get};
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, Note, Reference};
use feder_vocab::Actor;

#[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>,
pub inbox_auth_policy: InboxAuthPolicy,
}

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,
inbox_auth_policy: config.inbox_auth_policy,
}
}
}

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))
.route("/users/{username}/inbox", post(inbox))
.layer(DefaultBodyLimit::max(1_048_576))
.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);
}
}
49 changes: 23 additions & 26 deletions crates/feder-runtime-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,40 +17,37 @@ use std::net::SocketAddr;

use feder_vocab::Iri;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InboxAuthPolicy {
RequireSigned,
AllowUnsignedInsecureDev,
}
Comment thread
sij411 marked this conversation as resolved.

pub struct RuntimeConfig {
pub bind: SocketAddr,
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,
pub inbox_auth_policy: InboxAuthPolicy,
}

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(),
inbox_auth_policy: InboxAuthPolicy::AllowUnsignedInsecureDev,
}
}
Loading
Loading