Skip to content

Commit bc17d43

Browse files
authored
Merge pull request #12 from link-assistant/issue-11-0f880923289a
feat: add ActivityPub ForgeFed actor
2 parents 7531de6 + f55530d commit bc17d43

9 files changed

Lines changed: 408 additions & 3 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
### Added
2+
- Expose a minimal ActivityPub and ForgeFed actor surface for the code task actor, including inbox, outbox, followers, public key metadata, and a problemsets Follow activity document.

src/activitypub.rs

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
//! Minimal `ActivityPub` / `ForgeFed` endpoint support.
2+
//!
3+
//! The router exposes a local service actor and accepts inbound activities so
4+
//! a ForgeFed-capable problem source can discover and address it.
5+
6+
use axum::extract::State;
7+
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
8+
use axum::response::{IntoResponse, Response};
9+
use serde_json::{json, Value};
10+
11+
use crate::proxy::AppState;
12+
13+
const ACTIVITY_JSON: &str = "application/activity+json";
14+
15+
/// Build the public actor representation for this router instance.
16+
#[must_use]
17+
pub fn actor_document(actor_base_url: &str, public_key_pem: &str) -> Value {
18+
let base = actor_base_url.trim_end_matches('/');
19+
json!({
20+
"@context": activitypub_context(),
21+
"id": format!("{base}/actor/code"),
22+
"type": "Service",
23+
"preferredUsername": "code",
24+
"name": "Link Assistant Code Router",
25+
"summary": "ActivityPub and ForgeFed service actor for coding problem federation",
26+
"inbox": format!("{base}/inbox/code"),
27+
"outbox": format!("{base}/outbox/code"),
28+
"followers": format!("{base}/actors/code/followers"),
29+
"aliases": [
30+
format!("urn:link-assistant:router:code"),
31+
format!("{base}/actor/code")
32+
],
33+
"publicKey": {
34+
"id": format!("{base}/actor/code#main-key"),
35+
"owner": format!("{base}/actor/code"),
36+
"publicKeyPem": public_key_pem
37+
}
38+
})
39+
}
40+
41+
/// Build an ordered collection for the local actor outbox.
42+
#[must_use]
43+
pub fn outbox_document(actor_base_url: &str) -> Value {
44+
let base = actor_base_url.trim_end_matches('/');
45+
json!({
46+
"@context": activitypub_context(),
47+
"id": format!("{base}/outbox/code"),
48+
"type": "OrderedCollection",
49+
"totalItems": 0,
50+
"orderedItems": []
51+
})
52+
}
53+
54+
/// Build an ordered collection for followers.
55+
#[must_use]
56+
pub fn followers_document(actor_base_url: &str) -> Value {
57+
let base = actor_base_url.trim_end_matches('/');
58+
json!({
59+
"@context": activitypub_context(),
60+
"id": format!("{base}/actors/code/followers"),
61+
"type": "OrderedCollection",
62+
"totalItems": 0,
63+
"orderedItems": []
64+
})
65+
}
66+
67+
/// Build a Follow activity targeting the public problemsets actor.
68+
#[must_use]
69+
pub fn follow_problemsets_activity(actor_base_url: &str) -> Value {
70+
let base = actor_base_url.trim_end_matches('/');
71+
json!({
72+
"@context": activitypub_context(),
73+
"id": format!("{base}/activities/follow-problemsets-code-001"),
74+
"type": "Follow",
75+
"actor": format!("{base}/actor/code"),
76+
"object": "https://problemsets.lefine.pro/actor/code",
77+
"to": ["https://problemsets.lefine.pro/actor/code"]
78+
})
79+
}
80+
81+
/// Validate the minimum fields required for an inbound `ActivityStreams` object.
82+
pub fn validate_activity(activity: &Value) -> Result<(), &'static str> {
83+
require_string(activity, "id")?;
84+
require_string(activity, "type")?;
85+
if activity.get("actor").and_then(Value::as_str).is_none()
86+
&& activity
87+
.get("attributedTo")
88+
.and_then(Value::as_str)
89+
.is_none()
90+
{
91+
return Err("activity must include actor or attributedTo");
92+
}
93+
Ok(())
94+
}
95+
96+
/// `GET /actor/code`.
97+
#[allow(clippy::unused_async)]
98+
pub async fn actor(State(state): State<AppState>) -> impl IntoResponse {
99+
activity_response(actor_document(
100+
&state.activitypub_actor_base_url,
101+
&state.activitypub_public_key_pem,
102+
))
103+
}
104+
105+
/// `GET /outbox/code`.
106+
#[allow(clippy::unused_async)]
107+
pub async fn outbox(State(state): State<AppState>) -> impl IntoResponse {
108+
activity_response(outbox_document(&state.activitypub_actor_base_url))
109+
}
110+
111+
/// `GET /actors/code/followers`.
112+
#[allow(clippy::unused_async)]
113+
pub async fn followers(State(state): State<AppState>) -> impl IntoResponse {
114+
activity_response(followers_document(&state.activitypub_actor_base_url))
115+
}
116+
117+
/// `GET /activities/follow-problemsets-code-001`.
118+
#[allow(clippy::unused_async)]
119+
pub async fn follow_problemsets(State(state): State<AppState>) -> impl IntoResponse {
120+
activity_response(follow_problemsets_activity(
121+
&state.activitypub_actor_base_url,
122+
))
123+
}
124+
125+
/// `POST /inbox/code`.
126+
#[allow(clippy::unused_async)]
127+
pub async fn inbox(axum::Json(activity): axum::Json<Value>) -> impl IntoResponse {
128+
match validate_activity(&activity) {
129+
Ok(()) => {
130+
let response = json!({
131+
"@context": activitypub_context(),
132+
"type": "Accept",
133+
"object": activity
134+
});
135+
(
136+
StatusCode::ACCEPTED,
137+
activity_headers(),
138+
axum::Json(response),
139+
)
140+
.into_response()
141+
}
142+
Err(message) => (
143+
StatusCode::BAD_REQUEST,
144+
axum::Json(json!({
145+
"error": "invalid_activity",
146+
"message": message
147+
})),
148+
)
149+
.into_response(),
150+
}
151+
}
152+
153+
fn activity_response(value: Value) -> Response {
154+
(StatusCode::OK, activity_headers(), axum::Json(value)).into_response()
155+
}
156+
157+
fn activity_headers() -> HeaderMap {
158+
let mut headers = HeaderMap::new();
159+
headers.insert(
160+
header::CONTENT_TYPE,
161+
HeaderValue::from_static(r"application/activity+json"),
162+
);
163+
headers.insert(header::ACCEPT, HeaderValue::from_static(ACTIVITY_JSON));
164+
headers
165+
}
166+
167+
fn activitypub_context() -> Value {
168+
json!([
169+
"https://www.w3.org/ns/activitystreams",
170+
"https://forgefed.org/ns",
171+
{
172+
"fep": "https://w3id.org/fep/ef61#",
173+
"aliases": "fep:aliases"
174+
}
175+
])
176+
}
177+
178+
fn require_string<'a>(activity: &'a Value, field: &'static str) -> Result<&'a str, &'static str> {
179+
activity
180+
.get(field)
181+
.and_then(Value::as_str)
182+
.filter(|s| !s.is_empty())
183+
.ok_or(match field {
184+
"id" => "activity id is required",
185+
"type" => "activity type is required",
186+
_ => "required field is missing",
187+
})
188+
}
189+
190+
#[cfg(test)]
191+
mod tests {
192+
use super::*;
193+
194+
const BASE: &str = "https://router.example";
195+
const KEY: &str = "-----BEGIN PUBLIC KEY-----\nabc\n-----END PUBLIC KEY-----";
196+
197+
#[test]
198+
fn actor_contains_required_activitypub_and_forgefed_metadata() {
199+
let doc = actor_document(BASE, KEY);
200+
201+
assert_eq!(doc["id"], "https://router.example/actor/code");
202+
assert_eq!(doc["type"], "Service");
203+
assert_eq!(doc["inbox"], "https://router.example/inbox/code");
204+
assert_eq!(doc["outbox"], "https://router.example/outbox/code");
205+
assert_eq!(
206+
doc["followers"],
207+
"https://router.example/actors/code/followers"
208+
);
209+
assert_eq!(doc["publicKey"]["owner"], doc["id"]);
210+
assert_eq!(doc["publicKey"]["publicKeyPem"], KEY);
211+
assert!(doc["@context"]
212+
.as_array()
213+
.expect("context array")
214+
.iter()
215+
.any(|item| item == "https://forgefed.org/ns"));
216+
assert!(doc["aliases"].as_array().expect("aliases").len() >= 2);
217+
}
218+
219+
#[test]
220+
fn follow_activity_targets_problemsets_actor() {
221+
let activity = follow_problemsets_activity(BASE);
222+
223+
assert_eq!(activity["type"], "Follow");
224+
assert_eq!(activity["actor"], "https://router.example/actor/code");
225+
assert_eq!(
226+
activity["object"],
227+
"https://problemsets.lefine.pro/actor/code"
228+
);
229+
assert_eq!(
230+
activity["to"][0],
231+
"https://problemsets.lefine.pro/actor/code"
232+
);
233+
}
234+
235+
#[test]
236+
fn inbound_activity_requires_core_fields() {
237+
let valid = json!({
238+
"id": "https://remote.example/activity/1",
239+
"type": "Create",
240+
"actor": "https://remote.example/actor"
241+
});
242+
assert!(validate_activity(&valid).is_ok());
243+
244+
let invalid = json!({
245+
"id": "https://remote.example/activity/1",
246+
"type": "Create"
247+
});
248+
assert_eq!(
249+
validate_activity(&invalid),
250+
Err("activity must include actor or attributedTo")
251+
);
252+
}
253+
}

src/cli.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ use clap::Subcommand;
2525
use lino_arguments::Parser as LinoParser;
2626

2727
use crate::config::{
28-
default_data_dir, ApiFormat, BuildArgs, Config, ConfigError, RoutingMode, StoragePolicy,
28+
default_activitypub_public_key_pem, default_data_dir, ApiFormat, BuildArgs, Config,
29+
ConfigError, RoutingMode, StoragePolicy,
2930
};
3031

3132
/// Top-level CLI parser.
@@ -89,6 +90,14 @@ pub struct Cli {
8990
#[arg(long, env = "CLAUDE_CLI_BIN", global = true)]
9091
pub claude_cli_bin: Option<PathBuf>,
9192

93+
/// Public base URL for the `ActivityPub` actor.
94+
#[arg(long, env = "ACTIVITYPUB_ACTOR_BASE_URL", global = true)]
95+
pub activitypub_actor_base_url: Option<String>,
96+
97+
/// Public key PEM advertised by the `ActivityPub` actor.
98+
#[arg(long, env = "ACTIVITYPUB_PUBLIC_KEY_PEM", global = true)]
99+
pub activitypub_public_key_pem: Option<String>,
100+
92101
/// Disable the OpenAI-compatible API surface.
93102
#[arg(long, env = "DISABLE_OPENAI_API", global = true)]
94103
pub disable_openai_api: bool,
@@ -179,6 +188,14 @@ impl Cli {
179188
RoutingMode::from_str_opt(&self.routing_mode).ok_or(ConfigError::InvalidRoutingMode)?;
180189
let storage_policy = StoragePolicy::from_str_opt(&self.storage_policy).unwrap_or_default();
181190
let data_dir = self.data_dir.clone().unwrap_or_else(default_data_dir);
191+
let activitypub_actor_base_url = self
192+
.activitypub_actor_base_url
193+
.clone()
194+
.unwrap_or_else(|| format!("http://{}:{}", self.host, self.port));
195+
let activitypub_public_key_pem = self
196+
.activitypub_public_key_pem
197+
.clone()
198+
.unwrap_or_else(default_activitypub_public_key_pem);
182199
Config::build(BuildArgs {
183200
host: &self.host,
184201
port: &port,
@@ -191,6 +208,8 @@ impl Cli {
191208
storage_policy,
192209
data_dir,
193210
claude_cli_bin: self.claude_cli_bin.clone(),
211+
activitypub_actor_base_url,
212+
activitypub_public_key_pem,
194213
enable_openai_api: !self.disable_openai_api,
195214
enable_anthropic_api: !self.disable_anthropic_api,
196215
enable_metrics: !self.disable_metrics,
@@ -220,6 +239,8 @@ mod tests {
220239
storage_policy: "memory".into(),
221240
data_dir: Some(std::path::PathBuf::from("/tmp/d")),
222241
claude_cli_bin: None,
242+
activitypub_actor_base_url: Some("https://router.example".into()),
243+
activitypub_public_key_pem: None,
223244
disable_openai_api: false,
224245
disable_anthropic_api: false,
225246
disable_metrics: false,
@@ -251,6 +272,8 @@ mod tests {
251272
storage_policy: "memory".into(),
252273
data_dir: None,
253274
claude_cli_bin: None,
275+
activitypub_actor_base_url: None,
276+
activitypub_public_key_pem: None,
254277
disable_openai_api: false,
255278
disable_anthropic_api: false,
256279
disable_metrics: false,

0 commit comments

Comments
 (0)