use agent_feed_auth_github::browser_sign_in_url;
use agent_feed_core::HeadlineImage;
use agent_feed_directory::{
DirectoryError, FeedDirectoryEntry, GithubDiscoveryTicket, RemoteHeadlineView, RemoteUserRoute,
};
use agent_feed_p2p_proto::{ProtocolCompatibility, Signed, StoryCapsule};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeSet, VecDeque};
use time::OffsetDateTime;
pub const BROWSER_LIBP2P_PROTOCOLS: &[&str] = &[
"browser_wasm_peer",
"signed_browser_seed",
"identify",
"ping",
"gossipsub",
"relay_client",
"edge_live_sse",
"https_snapshot_fallback",
];
pub const BROWSER_LIBP2P_TRANSPORTS: &[&str] = &[
"webrtc_direct",
"webtransport",
"websocket",
"relay",
"https",
];
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct BrowserLibp2pTransportStatus {
pub available: bool,
pub live_default: bool,
pub reason: &'static str,
pub protocols: &'static [&'static str],
pub transports: &'static [&'static str],
}
#[must_use]
pub fn browser_libp2p_transport_status() -> BrowserLibp2pTransportStatus {
BrowserLibp2pTransportStatus {
available: cfg!(target_arch = "wasm32"),
live_default: false,
reason: browser_libp2p_transport_reason(),
protocols: BROWSER_LIBP2P_PROTOCOLS,
transports: BROWSER_LIBP2P_TRANSPORTS,
}
}
#[must_use]
fn browser_libp2p_transport_reason() -> &'static str {
if cfg!(target_arch = "wasm32") {
"browser libp2p is embedded as a wasm peer; it starts only when a route has browser-dialable bootstrap peers, with edge sse/snapshot fallback explicit"
} else {
"browser libp2p runs inside the shipped wasm browser bundle; native binaries report the bundle contract but cannot instantiate browser transports"
}
}
#[cfg(target_arch = "wasm32")]
pub fn build_browser_libp2p_transport_smoke() -> Result<BrowserLibp2pTransportStatus, String> {
use libp2p::{Transport, core::upgrade::Version};
let keypair = libp2p::identity::Keypair::generate_ed25519();
let _webrtc =
libp2p::webrtc_websys::Transport::new(libp2p::webrtc_websys::Config::new(&keypair)).boxed();
let _webtransport = libp2p::webtransport_websys::Transport::new(
libp2p::webtransport_websys::Config::new(&keypair),
)
.boxed();
let _websocket = libp2p::websocket_websys::Transport::default()
.upgrade(Version::V1)
.authenticate(libp2p::noise::Config::new(&keypair).map_err(|error| error.to_string())?)
.multiplex(libp2p::yamux::Config::default())
.boxed();
Ok(browser_libp2p_transport_status())
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BrowserPeerStartConfig {
pub network_id: String,
#[serde(default)]
pub bootstrap_peers: Vec<String>,
#[serde(default)]
pub feed_ids: Vec<String>,
#[serde(default)]
pub route_signature: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BrowserPeerJoinPlan {
pub network_configured: bool,
pub feed_count: usize,
pub bootstrap_peer_count: usize,
pub dialable_bootstrap_peer_count: usize,
pub ignored_bootstrap_peer_count: usize,
pub ready_to_dial: bool,
pub initial_state: String,
pub warnings: Vec<String>,
}
#[must_use]
pub fn browser_peer_join_plan(config: &BrowserPeerStartConfig) -> BrowserPeerJoinPlan {
let network_configured = !config.network_id.trim().is_empty();
let feed_count = config
.feed_ids
.iter()
.map(|feed_id| feed_id.trim())
.filter(|feed_id| !feed_id.is_empty())
.collect::<BTreeSet<_>>()
.len();
let bootstrap_peer_count = config
.bootstrap_peers
.iter()
.map(|peer| peer.trim())
.filter(|peer| !peer.is_empty())
.collect::<BTreeSet<_>>()
.len();
let dialable_bootstrap_peer_count =
browser_dialable_bootstrap_peers(&config.bootstrap_peers).len();
let ignored_bootstrap_peer_count =
bootstrap_peer_count.saturating_sub(dialable_bootstrap_peer_count);
let ready_to_dial = network_configured && feed_count > 0 && dialable_bootstrap_peer_count > 0;
let initial_state = if !network_configured {
"invalid-config"
} else if feed_count == 0 {
"waiting-for-feeds"
} else if dialable_bootstrap_peer_count == 0 {
"waiting-for-bootstrap"
} else {
"dialing"
}
.to_string();
let mut warnings = Vec::new();
if !network_configured {
warnings.push("network id is required".to_string());
}
if feed_count == 0 {
warnings.push("no feed topics selected".to_string());
}
if bootstrap_peer_count == 0 {
warnings.push("no bootstrap peers supplied".to_string());
} else if dialable_bootstrap_peer_count == 0 {
warnings.push("no browser-dialable bootstrap peers supplied".to_string());
} else if ignored_bootstrap_peer_count > 0 {
warnings.push(format!(
"{ignored_bootstrap_peer_count} non-browser bootstrap peers ignored"
));
}
BrowserPeerJoinPlan {
network_configured,
feed_count,
bootstrap_peer_count,
dialable_bootstrap_peer_count,
ignored_bootstrap_peer_count,
ready_to_dial,
initial_state,
warnings,
}
}
#[must_use]
pub fn browser_dialable_bootstrap_peers(peers: &[String]) -> Vec<String> {
peers
.iter()
.map(|peer| peer.trim())
.filter(|peer| !peer.is_empty())
.filter(|peer| is_browser_dialable_multiaddr(peer))
.map(ToString::to_string)
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
#[must_use]
pub fn is_browser_dialable_multiaddr(peer: &str) -> bool {
let peer = peer.to_ascii_lowercase();
let websocket = (peer.contains("/ws") || peer.contains("/wss")) && peer.contains("/p2p/");
let certified_browser_transport = (peer.contains("/webrtc-direct")
|| peer.contains("/webtransport"))
&& peer.contains("/certhash/")
&& peer.contains("/p2p/");
let relayed = peer.contains("/p2p-circuit") && peer.contains("/p2p/");
websocket || certified_browser_transport || relayed
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BrowserPeerRuntimeStatus {
pub embedded: bool,
pub transport_available: bool,
pub direct_live: bool,
pub state: String,
pub peer_id: Option<String>,
pub network_id: Option<String>,
pub subscribed_topics: usize,
pub dialed_peers: usize,
pub received_capsules: usize,
pub last_error: Option<String>,
pub route_signature: Option<String>,
pub protocols: Vec<String>,
pub transports: Vec<String>,
}
impl Default for BrowserPeerRuntimeStatus {
fn default() -> Self {
Self {
embedded: cfg!(target_arch = "wasm32"),
transport_available: cfg!(target_arch = "wasm32"),
direct_live: false,
state: "not-started".to_string(),
peer_id: None,
network_id: None,
subscribed_topics: 0,
dialed_peers: 0,
received_capsules: 0,
last_error: None,
route_signature: None,
protocols: BROWSER_LIBP2P_PROTOCOLS
.iter()
.map(ToString::to_string)
.collect(),
transports: BROWSER_LIBP2P_TRANSPORTS
.iter()
.map(ToString::to_string)
.collect(),
}
}
}
#[must_use]
pub fn browser_peer_status_json() -> String {
serde_json::to_string(&BrowserPeerRuntimeStatus::default()).unwrap_or_else(|_| "{}".to_string())
}
#[cfg(target_arch = "wasm32")]
mod wasm_peer {
use super::{
BrowserPeerRuntimeStatus, BrowserPeerStartConfig, RemoteFeedHeadline,
browser_dialable_bootstrap_peers, browser_peer_join_plan,
};
use agent_feed_p2p_proto::{Signed, StoryCapsule, feed_topic, topic_allows_story_capsules};
use libp2p::{
Multiaddr, Swarm, SwarmBuilder, Transport,
core::upgrade::Version,
futures::StreamExt,
gossipsub, identify, noise, ping, relay,
swarm::{NetworkBehaviour, SwarmEvent},
yamux,
};
use serde_json::json;
use std::{cell::RefCell, collections::BTreeMap};
use wasm_bindgen::prelude::*;
thread_local! {
static STATUS: RefCell<BrowserPeerRuntimeStatus> = RefCell::new(BrowserPeerRuntimeStatus::default());
}
#[wasm_bindgen]
pub fn agent_feed_browser_peer_status_json() -> String {
STATUS.with(|status| {
serde_json::to_string(&*status.borrow()).unwrap_or_else(|_| "{}".to_string())
})
}
#[wasm_bindgen]
pub fn agent_feed_browser_peer_start(config_json: &str) -> Result<String, JsValue> {
let config: BrowserPeerStartConfig = serde_json::from_str(config_json)
.map_err(|error| JsValue::from_str(&error.to_string()))?;
let join_plan = browser_peer_join_plan(&config);
if !join_plan.network_configured {
return Err(JsValue::from_str("browser p2p network_id is required"));
}
let mut swarm = build_browser_story_swarm()
.map_err(|error| JsValue::from_str(&format!("browser p2p swarm failed: {error}")))?;
let peer_id = swarm.local_peer_id().to_string();
let mut subscriptions = BTreeMap::new();
for feed_id in &config.feed_ids {
let topic_path = feed_topic(&config.network_id, feed_id);
swarm
.behaviour_mut()
.gossipsub
.subscribe(&gossipsub::IdentTopic::new(topic_path.clone()))
.map_err(|error| {
JsValue::from_str(&format!("browser p2p subscribe failed: {error}"))
})?;
subscriptions.insert(topic_path, feed_id.clone());
}
let mut dialed = 0_usize;
let mut dial_errors = Vec::new();
for peer in browser_dialable_bootstrap_peers(&config.bootstrap_peers) {
match peer.parse::<Multiaddr>() {
Ok(addr) => match swarm.dial(addr) {
Ok(()) => dialed += 1,
Err(error) => dial_errors.push(error.to_string()),
},
Err(error) => dial_errors.push(format!("{peer}: {error}")),
}
}
update_status(|status| {
status.embedded = true;
status.transport_available = true;
status.direct_live = false;
status.state = if dialed > 0 {
"dialing".to_string()
} else {
join_plan.initial_state.clone()
};
status.peer_id = Some(peer_id.clone());
status.network_id = Some(config.network_id.clone());
status.subscribed_topics = subscriptions.len();
status.dialed_peers = dialed;
status.received_capsules = 0;
status.last_error = dial_errors
.first()
.cloned()
.or_else(|| join_plan.warnings.first().cloned());
status.route_signature = config.route_signature.clone();
});
dispatch_status();
wasm_bindgen_futures::spawn_local(run_browser_story_swarm(swarm, config, subscriptions));
Ok(agent_feed_browser_peer_status_json())
}
#[derive(NetworkBehaviour)]
#[behaviour(to_swarm = "BrowserStoryBehaviourEvent")]
struct BrowserStoryBehaviour {
gossipsub: gossipsub::Behaviour,
identify: identify::Behaviour,
ping: ping::Behaviour,
relay_client: relay::client::Behaviour,
}
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
enum BrowserStoryBehaviourEvent {
Gossipsub(gossipsub::Event),
Identify,
Ping,
RelayClient,
}
impl From<gossipsub::Event> for BrowserStoryBehaviourEvent {
fn from(value: gossipsub::Event) -> Self {
Self::Gossipsub(value)
}
}
impl From<identify::Event> for BrowserStoryBehaviourEvent {
fn from(_value: identify::Event) -> Self {
Self::Identify
}
}
impl From<ping::Event> for BrowserStoryBehaviourEvent {
fn from(_value: ping::Event) -> Self {
Self::Ping
}
}
impl From<relay::client::Event> for BrowserStoryBehaviourEvent {
fn from(_value: relay::client::Event) -> Self {
Self::RelayClient
}
}
type BrowserStorySwarm = Swarm<BrowserStoryBehaviour>;
fn build_browser_story_swarm() -> Result<BrowserStorySwarm, String> {
Ok(SwarmBuilder::with_new_identity()
.with_wasm_bindgen()
.with_other_transport(|keypair| {
libp2p::webrtc_websys::Transport::new(libp2p::webrtc_websys::Config::new(keypair))
})
.map_err(|error| error.to_string())?
.with_other_transport(|keypair| {
libp2p::webtransport_websys::Transport::new(
libp2p::webtransport_websys::Config::new(keypair),
)
})
.map_err(|error| error.to_string())?
.with_other_transport(|keypair| {
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(
libp2p::websocket_websys::Transport::default()
.upgrade(Version::V1)
.authenticate(noise::Config::new(keypair).map_err(|error| {
Box::new(error) as Box<dyn std::error::Error + Send + Sync>
})?)
.multiplex(yamux::Config::default())
.boxed(),
)
})
.map_err(|error| error.to_string())?
.with_relay_client(noise::Config::new, yamux::Config::default)
.map_err(|error| error.to_string())?
.with_behaviour(move |keypair, relay_client| {
let behaviour_keypair = keypair.clone();
let identify_public_key = keypair.public();
let gossip_config = gossipsub::ConfigBuilder::default()
.validation_mode(gossipsub::ValidationMode::Strict)
.message_id_fn(|message| {
gossipsub::MessageId::from(story_message_id(&message.data))
})
.build()
.expect("static gossipsub config builds");
let gossipsub = gossipsub::Behaviour::new(
gossipsub::MessageAuthenticity::Signed(behaviour_keypair),
gossip_config,
)
.expect("static gossipsub behaviour builds");
let identify = identify::Behaviour::new(identify::Config::new(
"/agent-feed/browser/1.0.0".to_string(),
identify_public_key,
));
BrowserStoryBehaviour {
gossipsub,
identify,
ping: ping::Behaviour::default(),
relay_client,
}
})
.map_err(|error| error.to_string())?
.build())
}
async fn run_browser_story_swarm(
mut swarm: BrowserStorySwarm,
config: BrowserPeerStartConfig,
subscriptions: BTreeMap<String, String>,
) {
update_status(|status| status.state = "joining".to_string());
dispatch_status();
loop {
match swarm.select_next_some().await {
SwarmEvent::ConnectionEstablished { peer_id, .. } => {
swarm.behaviour_mut().gossipsub.add_explicit_peer(&peer_id);
update_status(|status| {
status.state = "live".to_string();
status.direct_live = true;
});
dispatch_status();
}
SwarmEvent::OutgoingConnectionError { error, .. } => {
update_status(|status| {
status.last_error = Some(error.to_string());
});
dispatch_status();
}
SwarmEvent::Behaviour(BrowserStoryBehaviourEvent::Gossipsub(
gossipsub::Event::Subscribed { peer_id, .. },
)) => {
swarm.behaviour_mut().gossipsub.add_explicit_peer(&peer_id);
update_status(|status| {
status.state = "subscribed".to_string();
});
dispatch_status();
}
SwarmEvent::Behaviour(BrowserStoryBehaviourEvent::Gossipsub(
gossipsub::Event::Message { message, .. },
)) => match handle_story_message(&config, &subscriptions, message) {
Ok(Some(headline)) => {
update_status(|status| {
status.state = "live".to_string();
status.received_capsules += 1;
status.last_error = None;
});
dispatch_headline(&config, &headline);
dispatch_status();
}
Ok(None) => {}
Err(error) => {
update_status(|status| status.last_error = Some(error));
dispatch_status();
}
},
_ => {}
}
}
}
fn handle_story_message(
config: &BrowserPeerStartConfig,
subscriptions: &BTreeMap<String, String>,
message: gossipsub::Message,
) -> Result<Option<RemoteFeedHeadline>, String> {
let signed: Signed<StoryCapsule> =
serde_json::from_slice(&message.data).map_err(|error| error.to_string())?;
if !signed.verify_capsule().map_err(|error| error.to_string())? {
return Err("invalid signed story capsule".to_string());
}
let topic = message.topic.to_string();
let Some(feed_id) = subscriptions.get(&topic) else {
return Ok(None);
};
if !topic_allows_story_capsules(&topic, &config.network_id, feed_id) {
return Err(format!("story capsule arrived on non-feed topic: {topic}"));
}
if signed.value.feed_id != *feed_id {
return Err("story capsule feed id did not match subscribed topic".to_string());
}
Ok(Some(RemoteFeedHeadline::from_signed_capsule(&signed)))
}
fn story_message_id(data: &[u8]) -> String {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
data.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
fn update_status(update: impl FnOnce(&mut BrowserPeerRuntimeStatus)) {
STATUS.with(|status| update(&mut status.borrow_mut()));
}
fn dispatch_status() {
let payload = STATUS.with(|status| {
serde_json::to_string(&*status.borrow()).unwrap_or_else(|_| "{}".to_string())
});
dispatch_json_event("agent-feed:p2p-status", &payload);
}
fn dispatch_headline(config: &BrowserPeerStartConfig, headline: &RemoteFeedHeadline) {
let payload = json!({
"type": "headline",
"source": "browser-libp2p",
"network_id": config.network_id,
"route_signature": config.route_signature,
"headline": headline,
})
.to_string();
dispatch_json_event("agent-feed:p2p-headline", &payload);
}
fn dispatch_json_event(name: &str, payload: &str) {
let Some(window) = web_sys::window() else {
return;
};
let init = web_sys::CustomEventInit::new();
init.set_detail(&JsValue::from_str(payload));
match web_sys::CustomEvent::new_with_event_init_dict(name, &init) {
Ok(event) => {
let _ = window.dispatch_event(&event);
}
Err(error) => web_sys::console::warn_1(&error),
}
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn agent_feed_browser_peer_status_json() -> String {
browser_peer_status_json()
}
#[cfg(not(target_arch = "wasm32"))]
pub fn agent_feed_browser_peer_start(_config_json: &str) -> Result<String, String> {
Err(browser_libp2p_transport_reason().to_string())
}
#[cfg(not(target_arch = "wasm32"))]
pub fn build_browser_libp2p_transport_smoke() -> Result<BrowserLibp2pTransportStatus, String> {
Err(browser_libp2p_transport_reason().to_string())
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RemoteFeedMode {
#[default]
Discovery,
Following,
Local,
}
impl RemoteFeedMode {
#[must_use]
pub fn from_query(query: &str) -> Self {
let params = QueryPairs::parse(query);
let explicit = params
.first("feed_mode")
.or_else(|| params.first("feedMode"))
.or_else(|| params.first("source"))
.unwrap_or_default();
if matches!(explicit, "local" | "loopback") {
Self::Local
} else if matches!(explicit, "subscribed" | "subscriptions" | "following")
|| params.has("subscriptions")
|| params.has("subscribed")
|| params
.first("following")
.is_some_and(|value| matches!(value, "1" | "true" | "on"))
{
Self::Following
} else {
Self::Discovery
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RemoteOperatingMode {
pub mode: RemoteFeedMode,
#[serde(alias = "subscription_targets")]
pub following_targets: Vec<String>,
}
impl RemoteOperatingMode {
#[must_use]
pub fn from_query(selection_syntax: impl Into<String>, query: &str) -> Self {
let selection_syntax = selection_syntax.into();
let params = QueryPairs::parse(query);
let targets = params
.first("subscriptions")
.or_else(|| params.first("subscribed"))
.or_else(|| params.first("following"))
.map(split_targets)
.unwrap_or_else(|| vec![selection_syntax]);
Self {
mode: RemoteFeedMode::from_query(query),
following_targets: targets,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RemoteRouteState {
#[default]
ParsingRoute,
ResolvingGithubLogin,
GithubUserNotFound,
GithubIdentityResolved,
LoadingBrowserSeed,
JoiningNetwork,
QueryingDirectory,
QueryingRendezvous,
QueryingKadProviders,
FeedsFound,
NoPublicFeeds,
AuthRequired,
RequestingSubscriptionGrant,
DialingPeer,
RequestingSnapshot,
SubscribedLive,
Live,
OfflineCached,
DegradedEdgeFallback,
VersionMismatch,
Failed,
}
impl RemoteRouteState {
#[must_use]
pub fn projection_copy(self) -> &'static str {
match self {
Self::ParsingRoute => "reading route",
Self::ResolvingGithubLogin => "resolving github identity",
Self::GithubUserNotFound => "github user not found",
Self::GithubIdentityResolved => "github identity found",
Self::LoadingBrowserSeed => "loading browser seed",
Self::JoiningNetwork => "joining network",
Self::QueryingDirectory => "searching mainnet",
Self::QueryingRendezvous => "querying rendezvous",
Self::QueryingKadProviders => "querying provider records",
Self::FeedsFound => "feeds found",
Self::NoPublicFeeds => "no visible settled story streams",
Self::AuthRequired => "requesting private feed access",
Self::RequestingSubscriptionGrant => "requesting subscription grant",
Self::DialingPeer => "dialing p2p peers",
Self::RequestingSnapshot => "requesting snapshot",
Self::SubscribedLive => "connected · waiting for first story",
Self::Live => "live",
Self::OfflineCached => "offline cached profile",
Self::DegradedEdgeFallback => "edge snapshot mode",
Self::VersionMismatch => "update your peer to the latest version",
Self::Failed => "unable to connect",
}
}
#[must_use]
pub fn is_terminal(self) -> bool {
matches!(
self,
Self::GithubUserNotFound
| Self::NoPublicFeeds
| Self::AuthRequired
| Self::Live
| Self::OfflineCached
| Self::DegradedEdgeFallback
| Self::VersionMismatch
| Self::Failed
)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RemoteRouteViewModel {
pub route: RemoteUserRoute,
pub operating_mode: RemoteFeedMode,
pub state: RemoteRouteState,
pub headline: String,
pub lines: Vec<String>,
pub ticket: Option<GithubDiscoveryTicket>,
pub sign_in: Option<BrowserSignInView>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BrowserSignInView {
pub provider: String,
pub label: String,
pub url: String,
pub interactive_only: bool,
}
impl BrowserSignInView {
#[must_use]
pub fn github(edge_base_url: &str, return_to: &str) -> Self {
Self {
provider: "github".to_string(),
label: "sign in with github".to_string(),
url: browser_sign_in_url(edge_base_url, return_to),
interactive_only: true,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RemoteFeedHeadline {
pub feed_id: String,
pub capsule_id: String,
pub seq: u64,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
pub publisher_label: String,
pub publisher_avatar: Option<String>,
pub feed_label: String,
pub headline: String,
pub deck: String,
pub lower_third: String,
pub chips: Vec<String>,
pub image: Option<HeadlineImage>,
pub verified: bool,
}
impl RemoteFeedHeadline {
#[must_use]
pub fn from_signed_capsule(capsule: &Signed<StoryCapsule>) -> Self {
let publisher = capsule.value.publisher.as_ref();
let publisher_label = publisher
.map(agent_feed_p2p_proto::PublisherIdentity::display_label)
.unwrap_or_else(|| "verified peer".to_string());
let publisher_avatar = publisher.and_then(|identity| identity.avatar.clone());
let feed_label = feed_label_from_id(&capsule.value.feed_id);
Self {
feed_id: capsule.value.feed_id.clone(),
capsule_id: capsule.value.capsule_id.clone(),
seq: capsule.value.seq,
created_at: capsule.value.created_at,
publisher_label,
publisher_avatar,
feed_label,
headline: capsule.value.headline.clone(),
deck: capsule.value.deck.clone(),
lower_third: capsule.value.lower_third.clone(),
chips: capsule.value.chips.clone(),
image: capsule.value.image.clone(),
verified: publisher.is_some_and(|identity| identity.verified),
}
}
pub fn from_entry_and_capsule(
entry: &FeedDirectoryEntry,
capsule: &Signed<StoryCapsule>,
) -> Result<Self, DirectoryError> {
let view = RemoteHeadlineView::from_entry_and_capsule(entry, capsule)?;
Ok(Self {
feed_id: view.feed_id,
capsule_id: capsule.value.capsule_id.clone(),
seq: capsule.value.seq,
created_at: capsule.value.created_at,
publisher_label: format!("@{}", view.publisher_login),
publisher_avatar: view.publisher_avatar,
feed_label: view.feed_label,
headline: view.headline,
deck: view.deck,
lower_third: view.lower_third,
chips: view.chips,
image: view.image,
verified: view.verified,
})
}
#[must_use]
pub fn timeline_key(&self) -> String {
format!("{}:{}", self.feed_id, self.capsule_id)
}
}
fn feed_label_from_id(feed_id: &str) -> String {
feed_id
.rsplit(['/', ':'])
.next()
.filter(|value| !value.is_empty())
.unwrap_or("feed")
.to_string()
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FeedTimeline {
pub selection_syntax: String,
pub capacity: usize,
pub items: VecDeque<RemoteFeedHeadline>,
#[serde(skip)]
seen: BTreeSet<String>,
}
impl FeedTimeline {
#[must_use]
pub fn new(selection_syntax: impl Into<String>, capacity: usize) -> Self {
Self {
selection_syntax: selection_syntax.into(),
capacity: capacity.max(1),
items: VecDeque::new(),
seen: BTreeSet::new(),
}
}
pub fn push(&mut self, item: RemoteFeedHeadline) -> bool {
let key = item.timeline_key();
if self.seen.contains(&key) {
return false;
}
self.seen.insert(key);
self.items.push_back(item);
while self.items.len() > self.capacity {
if let Some(evicted) = self.items.pop_front() {
self.seen.remove(&evicted.timeline_key());
}
}
true
}
#[must_use]
pub fn newest_first(&self) -> Vec<RemoteFeedHeadline> {
self.items.iter().rev().cloned().collect()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TimelineViewModel {
pub route: RemoteUserRoute,
pub selection_syntax: String,
pub wildcard: bool,
pub timeline: FeedTimeline,
}
impl TimelineViewModel {
#[must_use]
pub fn empty(route: RemoteUserRoute, capacity: usize) -> Self {
let selection_syntax = route.selection.route_syntax(&route.login);
let wildcard = route.selection.is_wildcard();
Self {
route,
timeline: FeedTimeline::new(selection_syntax.clone(), capacity),
selection_syntax,
wildcard,
}
}
pub fn push(&mut self, item: RemoteFeedHeadline) -> bool {
self.timeline.push(item)
}
}
impl RemoteRouteViewModel {
#[must_use]
pub fn waiting(route: RemoteUserRoute) -> Self {
Self {
headline: format!("@{}", route.login),
lines: vec![
"resolving github identity".to_string(),
format!("finding feeds on {}", route.network.network_id()),
"dialing p2p peers".to_string(),
"waiting for story capsules".to_string(),
],
route,
operating_mode: RemoteFeedMode::Discovery,
state: RemoteRouteState::ResolvingGithubLogin,
ticket: None,
sign_in: None,
}
}
#[must_use]
pub fn with_ticket(mut self, ticket: GithubDiscoveryTicket) -> Self {
let local = ProtocolCompatibility::current();
let ticket_status = local.status_with(&ticket.compatibility);
let incompatible_feed = ticket
.candidate_feeds
.iter()
.find(|feed| !local.is_compatible_with(&feed.compatibility));
self.state = if !ticket_status.compatible || incompatible_feed.is_some() {
RemoteRouteState::VersionMismatch
} else if ticket.candidate_feeds.is_empty() {
RemoteRouteState::NoPublicFeeds
} else {
RemoteRouteState::FeedsFound
};
self.headline = format!("@{}", ticket.profile.login);
self.lines = if self.state == RemoteRouteState::VersionMismatch {
vec![
"feed protocol or data model changed".to_string(),
incompatible_feed
.map(|feed| local.status_with(&feed.compatibility).message)
.unwrap_or(ticket_status.message),
"update your peer to the latest version".to_string(),
]
} else {
vec![
"github identity found".to_string(),
format!("{} visible feeds", ticket.candidate_feeds.len()),
"connected · waiting for first story".to_string(),
]
};
self.ticket = Some(ticket);
self.sign_in = None;
self
}
#[must_use]
pub fn auth_required(mut self, edge_base_url: &str, return_to: &str) -> Self {
self.state = RemoteRouteState::AuthRequired;
self.lines = vec![
"github sign-in required".to_string(),
"private feeds need a signed browser session".to_string(),
"projection remains story-only after sign-in".to_string(),
];
self.sign_in = Some(BrowserSignInView::github(edge_base_url, return_to));
self
}
}
#[derive(Default)]
struct QueryPairs(Vec<(String, String)>);
impl QueryPairs {
fn parse(query: &str) -> Self {
let query = query.trim_start_matches('?');
Self(
query
.split('&')
.filter(|part| !part.is_empty())
.map(|part| {
part.split_once('=')
.map(|(key, value)| (key.to_string(), value.to_string()))
.unwrap_or_else(|| (part.to_string(), String::new()))
})
.collect(),
)
}
fn first(&self, key: &str) -> Option<&str> {
self.0
.iter()
.find(|(candidate, _)| candidate == key)
.map(|(_, value)| value.as_str())
}
fn has(&self, key: &str) -> bool {
self.0.iter().any(|(candidate, _)| candidate == key)
}
}
fn split_targets(value: &str) -> Vec<String> {
value
.split(',')
.map(str::trim)
.filter(|target| !target.is_empty())
.map(ToString::to_string)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use agent_feed_core::{EventKind, PrivacyClass, Severity};
use agent_feed_directory::{GithubPrincipal, GithubProfileView, SignedBrowserSeed};
use agent_feed_identity::{GithubLogin, GithubUserId};
use agent_feed_p2p_proto::{
FeedVisibility, ProtocolCompatibility, Signature, Signed, StoryCapsule,
};
use agent_feed_story::{CompiledStory, StoryFamily, StoryKey, StorySignalKind};
use time::OffsetDateTime;
fn public_entry() -> FeedDirectoryEntry {
let owner = GithubPrincipal {
github_user_id: GithubUserId::new(123),
current_login: "mosure".to_string(),
display_name: Some("mosure".to_string()),
avatar: Some("/avatar/github/123".to_string()),
verified_by: "edge".to_string(),
verified_at: OffsetDateTime::now_utc(),
};
FeedDirectoryEntry::new(
"agent-feed-mainnet",
"feed-workstation",
owner,
"peer-a",
"workstation",
FeedVisibility::Public,
1,
)
.sign("peer-a")
.expect("entry signs")
}
fn signed_headline(
entry: &FeedDirectoryEntry,
image: Option<HeadlineImage>,
) -> Signed<StoryCapsule> {
let story = CompiledStory {
key: StoryKey {
feed_id: Some("feed-workstation".to_string()),
agent: "codex".to_string(),
project_hash: Some("agent_feed".to_string()),
session_id: Some("session".to_string()),
turn_id: Some("turn".to_string()),
family: StoryFamily::Turn,
},
created_at: OffsetDateTime::now_utc(),
family: StoryFamily::Turn,
agent: "codex".to_string(),
project: Some("agent_feed".to_string()),
headline: "feed browser shows verified publisher context".to_string(),
deck: "remote headlines retain avatar, profile, and optional image metadata."
.to_string(),
lower_third: "agent_feed / turn / score 84 / redacted".to_string(),
chips: vec![
"agent_feed".to_string(),
"browser".to_string(),
"score 84".to_string(),
"redacted".to_string(),
],
severity: Severity::Notice,
score: 84,
context_score: 90,
privacy: PrivacyClass::Redacted,
evidence_event_ids: vec!["evt_browser_capsule".to_string()],
primary_signal: StorySignalKind::TaskCompletion,
suppressed_signals: vec![StorySignalKind::ValidationEvidence],
evidence_kinds: vec![EventKind::TurnComplete],
publish_reason: "browser test story".to_string(),
};
let mut capsule = StoryCapsule::from_story("feed-workstation", 1, "github:123", &story)
.expect("capsule builds")
.with_publisher(entry.publisher_identity())
.expect("publisher attaches");
capsule.image = image;
Signed::sign_capsule(capsule, "peer-a").expect("capsule signs")
}
#[test]
fn waiting_copy_is_stateful_not_spinner_text() {
let route = RemoteUserRoute::parse("/mosure", Some("all")).expect("route parses");
let model = RemoteRouteViewModel::waiting(route);
assert_eq!(model.state, RemoteRouteState::ResolvingGithubLogin);
assert_eq!(model.operating_mode, RemoteFeedMode::Discovery);
assert!(model.lines.iter().any(|line| line.contains("github")));
assert!(model.lines.iter().any(|line| line.contains("p2p")));
}
#[test]
fn feed_mode_query_separates_discovery_from_following() {
assert_eq!(
RemoteFeedMode::from_query("feed_mode=discovery"),
RemoteFeedMode::Discovery
);
assert_eq!(
RemoteFeedMode::from_query("feed_mode=subscribed"),
RemoteFeedMode::Following
);
assert_eq!(
RemoteFeedMode::from_query("feed_mode=local"),
RemoteFeedMode::Local
);
assert_eq!(
RemoteFeedMode::from_query("subscriptions=mosure/workstation"),
RemoteFeedMode::Following
);
}
#[test]
fn following_operating_mode_keeps_explicit_targets() {
let mode = RemoteOperatingMode::from_query(
"mosure/*",
"feed_mode=following&following=mosure/workstation,alice/release",
);
assert_eq!(mode.mode, RemoteFeedMode::Following);
assert_eq!(
mode.following_targets,
vec!["mosure/workstation", "alice/release"]
);
}
#[test]
fn projection_copy_hides_raw_network_errors() {
assert_eq!(
RemoteRouteState::QueryingKadProviders.projection_copy(),
"querying provider records"
);
assert_eq!(
RemoteRouteState::Failed.projection_copy(),
"unable to connect"
);
assert_eq!(
RemoteRouteState::VersionMismatch.projection_copy(),
"update your peer to the latest version"
);
}
#[test]
fn browser_transport_status_is_honest_for_native_test_builds() {
let status = browser_libp2p_transport_status();
assert!(!status.available);
assert!(!status.live_default);
assert!(status.reason.contains("wasm"));
assert!(status.protocols.contains(&"gossipsub"));
assert!(status.transports.contains(&"webrtc_direct"));
assert!(build_browser_libp2p_transport_smoke().is_err());
}
#[test]
fn browser_join_plan_requires_dialable_browser_bootstrap() {
let config = BrowserPeerStartConfig {
network_id: "agent-feed-mainnet".to_string(),
feed_ids: vec!["feed-workstation".to_string()],
bootstrap_peers: vec![
"/dns4/edge.feed.aberration.technology/tcp/7747".to_string(),
"/dns4/edge.feed.aberration.technology/udp/7747/quic-v1".to_string(),
"/dns4/edge.feed.aberration.technology/udp/443/webrtc-direct".to_string(),
],
route_signature: Some("canary".to_string()),
};
let plan = browser_peer_join_plan(&config);
assert_eq!(plan.feed_count, 1);
assert_eq!(plan.bootstrap_peer_count, 3);
assert_eq!(plan.dialable_bootstrap_peer_count, 0);
assert_eq!(plan.ignored_bootstrap_peer_count, 3);
assert!(!plan.ready_to_dial);
assert_eq!(plan.initial_state, "waiting-for-bootstrap");
assert!(
plan.warnings
.iter()
.any(|warning| warning.contains("browser-dialable"))
);
}
#[test]
fn browser_join_plan_accepts_wss_and_certified_browser_addrs() {
let peers = vec![
"/dns4/edge.feed.aberration.technology/tcp/443/wss/p2p/12D3KooWExample"
.to_string(),
"/dns4/edge.feed.aberration.technology/udp/443/webrtc-direct/certhash/uEiExample/p2p/12D3KooWExample"
.to_string(),
"/dns4/edge.feed.aberration.technology/udp/443/webrtc-direct".to_string(),
];
let config = BrowserPeerStartConfig {
network_id: "agent-feed-mainnet".to_string(),
feed_ids: vec!["feed-workstation".to_string()],
bootstrap_peers: peers,
route_signature: None,
};
let plan = browser_peer_join_plan(&config);
let dialable = browser_dialable_bootstrap_peers(&config.bootstrap_peers);
assert!(plan.ready_to_dial);
assert_eq!(plan.dialable_bootstrap_peer_count, 2);
assert_eq!(plan.ignored_bootstrap_peer_count, 1);
assert!(dialable.iter().any(|peer| peer.contains("/wss/")));
assert!(
dialable
.iter()
.any(|peer| peer.contains("/webrtc-direct/certhash/"))
);
assert!(!dialable.iter().any(|peer| peer.ends_with("/webrtc-direct")));
}
fn discovery_ticket(entry: FeedDirectoryEntry) -> GithubDiscoveryTicket {
GithubDiscoveryTicket {
network_id: "agent-feed-mainnet".to_string(),
compatibility: ProtocolCompatibility::current(),
requested_login: GithubLogin::parse(&entry.owner.current_login).expect("login parses"),
resolved_github_id: entry.owner.github_user_id,
profile: GithubProfileView {
login: entry.owner.current_login.clone(),
name: entry.owner.display_name.clone(),
avatar: entry.owner.avatar.clone(),
},
candidate_feeds: vec![entry],
bootstrap_peers: Vec::new(),
rendezvous_namespaces: Vec::new(),
provider_keys: Vec::new(),
browser_seed: SignedBrowserSeed::new(
"agent-feed-mainnet",
"https://edge.example",
Vec::new(),
"edge",
)
.expect("seed signs"),
issued_at: OffsetDateTime::now_utc(),
expires_at: OffsetDateTime::now_utc(),
signature: Signature::unsigned(),
}
}
#[test]
fn route_model_surfaces_version_mismatch() {
let route = RemoteUserRoute::parse("/mosure", Some("all")).expect("route parses");
let mut entry = public_entry();
entry.compatibility = ProtocolCompatibility::current().with_model_version(4, 4);
let model = RemoteRouteViewModel::waiting(route).with_ticket(discovery_ticket(entry));
assert_eq!(model.state, RemoteRouteState::VersionMismatch);
assert!(
model
.lines
.iter()
.any(|line| line.contains("update your peer"))
);
}
#[test]
fn remote_feed_headline_exposes_publisher_login_and_avatar() {
let entry = public_entry();
let signed = signed_headline(&entry, None);
let headline =
RemoteFeedHeadline::from_entry_and_capsule(&entry, &signed).expect("headline builds");
assert_eq!(headline.publisher_label, "@mosure");
assert_eq!(headline.feed_id, "feed-workstation");
assert!(headline.capsule_id.starts_with("cap_"));
assert_eq!(
headline.publisher_avatar.as_deref(),
Some("/avatar/github/123")
);
assert_eq!(headline.lower_third, "@mosure / workstation");
assert!(headline.verified);
}
#[test]
fn remote_feed_headline_exposes_optional_headline_image() {
let entry = public_entry();
let signed = signed_headline(
&entry,
Some(HeadlineImage::new(
"/assets/headlines/release.webp",
"abstract release signal",
"test",
)),
);
let headline =
RemoteFeedHeadline::from_entry_and_capsule(&entry, &signed).expect("headline builds");
assert_eq!(
headline.image.as_ref().map(|image| image.uri.as_str()),
Some("/assets/headlines/release.webp")
);
}
#[test]
fn auth_required_view_has_github_sign_in_url() {
let route = RemoteUserRoute::parse("/mosure", Some("all")).expect("route parses");
let model = RemoteRouteViewModel::waiting(route)
.auth_required("https://edge.example", "https://feed.example/mosure?all");
assert_eq!(model.state, RemoteRouteState::AuthRequired);
let sign_in = model.sign_in.expect("sign-in view exists");
assert_eq!(sign_in.provider, "github");
assert!(sign_in.interactive_only);
assert!(sign_in.url.starts_with("https://edge.example/auth/github?"));
assert!(sign_in.url.contains("client=feed-browser"));
}
#[test]
fn timeline_ring_buffer_keeps_recent_unique_headlines() {
let entry = public_entry();
let mut timeline = FeedTimeline::new("mosure/workstation", 2);
let first =
RemoteFeedHeadline::from_entry_and_capsule(&entry, &signed_headline(&entry, None))
.expect("first headline");
let duplicate = first.clone();
let mut second = first.clone();
second.capsule_id = "cap_second".to_string();
second.seq = 2;
let mut third = first.clone();
third.capsule_id = "cap_third".to_string();
third.seq = 3;
assert!(timeline.push(first));
assert!(!timeline.push(duplicate));
assert!(timeline.push(second));
assert!(timeline.push(third));
let newest = timeline.newest_first();
assert_eq!(newest.len(), 2);
assert_eq!(newest[0].capsule_id, "cap_third");
assert_eq!(newest[1].capsule_id, "cap_second");
}
#[test]
fn timeline_view_model_preserves_wildcard_selection() {
let route =
RemoteUserRoute::parse("/mosure/*", Some("view=timeline")).expect("route parses");
let model = TimelineViewModel::empty(route, 20);
assert_eq!(model.selection_syntax, "mosure/*");
assert!(model.wildcard);
assert_eq!(model.timeline.capacity, 20);
}
}