RFC 0002 — Fediverse: WebFinger + Acteur + NodeInfo (couche découverte)
- Auteur : EBO (rustwarden)
- Date : 2026-05-09
- Statut : Accepted (auto-revue mainteneur unique 2026-05-09, score 29/35)
- Apps consommatrices concernées : toute app utilisant
rustwarden-coreavec la featureexperimental-fediversequi veut être découvrable depuis le Fediverse (résolution de handle@user@domain, lecture de profil ActivityPub, métadonnées d'instance). - Plan associé :
docs/plans/2026-05-09-fediverse-identity-federation.md, Phase 1. - Dépendance : RFC 0001 (
FediverseConfig/with_fediverse()). - Dépendance partielle : RFC 0005 (
actor-key-storage) — l'acteur exposepublicKeycommeOption<...>; tant que 0005 n'est pas livrée, le champ vautNoneet l'acteur n'est pas réellement fédérable.
Summary
Exposer trois endpoints HTTP côté rustwarden-core qui rendent une instance Rustwarden découvrable sur le Fediverse : WebFinger (résolution @user@domain → URL d'acteur), Acteur ActivityPub (GET /users/{u} avec négociation de contenu Accept), et NodeInfo 2.1 (métadonnées d'instance). Aucune écriture, aucune signature ; uniquement de la lecture publique anonyme. Routes câblées dans le builder via with_fediverse() (RFC 0001).
Motivation
- Sans WebFinger, aucun client Fediverse externe ne peut résoudre
@alice@b.tldvers son URL d'acteur — la résolution de handles est universellement standardisée par RFC 7033. - Sans endpoint Acteur au format ActivityPub, Mastodon/GoToSocial/Misskey/Lemmy ne peuvent pas afficher le profil ni l'inbox cible pour livrer une activité.
- Sans NodeInfo, les annuaires Fediverse (fediverse.observer, masto.host, fedidb) ne peuvent pas indexer l'instance.
Conséquence si on ne fait rien : Phase 1 du plan inexécutable. C'est l'étape « être trouvable » sans laquelle les RFCs suivantes (auth, invitations, lifecycle) sont sans objet.
Délai : pas d'urgence sécurité. Bloque la suite.
Non-goals
Cette RFC ne couvre pas :
- La génération et le stockage des clés RSA d'acteurs → RFC 0005 (le champ
publicKeyest exposé commeOption<...>ici, rempli quand 0005 atterrit). - L'inbox (
POST /users/{u}/inbox,POST /inbox) → RFC 0006 (couche auth) et RFC 0011 (worker outbound + inbox dispatch). - L'outbox paginée (
GET /users/{u}/outbox) — exposée comme collection vide (totalItems: 0) à ce stade ; la pagination réelle viendra avec les flows d'invitation/auth qui produisent des activités locales. - Les collections
followers/following(renvoyées comme collections vides ; remplies par RFC 0009). - La table
remote_identitieset la résolution de handles distants (uniquement les handles locaux sont résolus ici) → RFC 0003. - Le toggle runtime
app_settings.fediverse_enabled→ RFC 0004 (en attendant, les routes sont toujours montées siwith_fediverse()est appelé). - L'authorized fetch (signatures sur GET) → RFC 0010 (production hardening).
API proposée
Module fediverse (création)
crates/rustwarden-core/src/fediverse/mod.rs (nouveau, cfg(feature = "experimental-fediverse")) :
//! ActivityPub identity federation primitives (RFC 0001+). pub mod webfinger; pub mod nodeinfo; pub mod actor; pub mod types; pub mod routes; pub mod error;
Types JSON (fediverse::types)
crates/rustwarden-core/src/fediverse/types.rs :
use serde::{Deserialize, Serialize}; use url::Url; /// ActivityPub Person actor (W3C ActivityStreams 2.0 + Security V1). #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PersonJson { #[serde(rename = "@context")] pub context: serde_json::Value, #[serde(rename = "type")] pub kind: String, // "Person" pub id: Url, pub preferred_username: String, #[serde(skip_serializing_if = "Option::is_none")] pub name: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub summary: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub url: Option<Url>, pub inbox: Url, pub outbox: Url, pub followers: Url, pub following: Url, /// Mastodon-compat shared inbox; always present once `with_fediverse()` /// is wired (cf. RFC 0011). #[serde(skip_serializing_if = "Option::is_none")] pub endpoints: Option<EndpointsJson>, /// Filled by RFC 0005 (`actor-key-storage`). `None` until that RFC lands. #[serde(skip_serializing_if = "Option::is_none")] pub public_key: Option<PublicKeyJson>, #[serde(default)] pub manually_approves_followers: bool, #[serde(default = "default_true")] pub discoverable: bool, } fn default_true() -> bool { true } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PublicKeyJson { pub id: Url, // "{actor_id}#main-key" pub owner: Url, // actor_id pub public_key_pem: String, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EndpointsJson { pub shared_inbox: Url, } /// WebFinger JRD (RFC 7033). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebFingerResponse { pub subject: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub aliases: Vec<String>, pub links: Vec<WebFingerLink>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebFingerLink { pub rel: String, #[serde(rename = "type", skip_serializing_if = "Option::is_none")] pub media_type: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub href: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub template: Option<String>, } /// NodeInfo discovery document (`/.well-known/nodeinfo`). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NodeInfoDiscovery { pub links: Vec<NodeInfoLink>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NodeInfoLink { pub rel: String, pub href: String, } /// NodeInfo 2.1 (`/nodeinfo/2.1`). #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NodeInfo { pub version: String, // "2.1" pub software: NodeInfoSoftware, pub protocols: Vec<String>, // ["activitypub"] pub services: NodeInfoServices, pub usage: NodeInfoUsage, pub open_registrations: bool, #[serde(default)] pub metadata: serde_json::Value, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NodeInfoSoftware { pub name: String, // "rustwarden" pub version: String, // CARGO_PKG_VERSION #[serde(skip_serializing_if = "Option::is_none")] pub repository: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub homepage: Option<String>, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct NodeInfoServices { #[serde(default)] pub inbound: Vec<String>, #[serde(default)] pub outbound: Vec<String>, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NodeInfoUsage { pub users: NodeInfoUsageUsers, pub local_posts: u64, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NodeInfoUsageUsers { pub total: u64, pub active_month: u64, pub active_halfyear: u64, }
Trait LocalUserResolver
Pour découpler les handlers de SeaORM dans les tests, on introduit un trait minimal. La RFC 0003 fournira l'implémentation RemoteIdentityService ; la RFC 0005 enrichira UserSummary avec ap_public_key_pem. Pour l'instant :
use async_trait::async_trait; use uuid::Uuid; /// Minimal projection of a local user usable to render WebFinger / Person JSON. /// Will be extended by RFC 0005 (`actor-key-storage`) with `ap_public_key_pem`. #[derive(Debug, Clone)] pub struct UserSummary { pub id: Uuid, pub username: String, pub display_name: Option<String>, pub bio: Option<String>, /// `None` until RFC 0005 lands. pub ap_public_key_pem: Option<String>, } #[async_trait] pub trait LocalUserResolver: Send + Sync { /// Look up a local user by username. Returns `None` if no such user /// exists or if the user is soft-deleted / disabled. async fn find_local_user( &self, username: &str, ) -> Result<Option<UserSummary>, crate::error::AppError>; }
Une implémentation par défaut adossée à SeaORM est fournie : SeaOrmLocalUserResolver(DatabaseConnection). Les tests peuvent injecter un MockResolver qui retourne des UserSummary figés.
Handlers Axum
Routes câblées par fediverse::routes::router(state) :
| Méthode | Route | Handler | Notes |
|---|---|---|---|
| GET | /.well-known/webfinger | webfinger::handle | Query ?resource=acct:user@domain |
| GET | /.well-known/nodeinfo | nodeinfo::discovery | Constante : un seul lien vers 2.1 |
| GET | /nodeinfo/2.1 | nodeinfo::detail | Métadonnées agrégées (count users) |
| GET | /users/{username} | actor::handle | Négociation Accept |
actor::handle lit Accept :
application/activity+jsonouapplication/ld+json→ JSONPersonJson.text/html→ délégué au handler UI s'il est monté ; sinon HTTP 406 Not Acceptable.- Aucun header → comportement default : si UI montée, HTML ; sinon AP JSON (la convention Mastodon est de retourner AP JSON par défaut quand pas d'
Acceptexplicite).
Wiring dans with_fediverse()
crates/rustwarden-core/src/app.rs (modification) :
// Dans build() : #[cfg(feature = "experimental-fediverse")] if let Some(fediverse_config) = self.fediverse_config.as_ref() { let fediverse_router = crate::fediverse::routes::router(fediverse_config.clone(), db.clone()); app = app.merge(fediverse_router); tracing::info!( "ActivityPub federation routes mounted on domain={}", fediverse_config.domain() ); }
Analyse de compatibilité
- API existante non modifiée → non-breaking.
- API existante étendue avec défaut → non-breaking : nouveau module gated par feature.
- API existante modifiée ou supprimée → non.
-
Nouveau comportement par défaut → routes ajoutées uniquement si
with_fediverse()est appelé. Les apps qui activent la feature mais n'appellent pas la méthode ne voient aucune route ajoutée. -
Changement de schéma DB → non (RFC 0005 introduira les colonnes AP sur
users). - Nouveau format sérialisé → oui (PersonJson, WebFinger JRD, NodeInfo). Format public consommé par tiers, donc breaking change si modifié plus tard. Pin SemVer strict sur les noms de champs camelCase dès le départ.
Alternatives considérées
Alternative A (recommandée) — Routes côté rustwarden-core + trait LocalUserResolver
Routes natives dans le crate core, découplées de SeaORM via un trait. Câblage automatique par with_fediverse().
Pros :
- Tests unitaires faciles (mock du resolver).
- L'app consommatrice n'a rien à coder pour exposer la découverte.
- Pattern aligné sur les autres modules (
auth,services).
Cons :
- 1 trait de plus dans la surface publique (mineur).
Alternative B — Routes côté rustwarden-ui uniquement
Servir WebFinger/Acteur/NodeInfo depuis rustwarden-ui.
Pros :
- Une apps API-only (sans UI) n'aurait pas ces routes (sympa pour les services internes).
Cons :
- Casse le contrat «
rustwarden-coreexploitable headless » du plan §4.1. Les apps SPA qui veulent être discoverable dans le Fediverse seraient forcées de tirerrustwarden-ui. - WebFinger et NodeInfo n'ont rien de UI — ce sont des endpoints de découverte JSON.
- Rejeté.
Alternative C — Acteur dans rustwarden-core, mais WebFinger dans une crate à part
Séparer la couche découverte AP en un crate rustwarden-fediverse distinct.
Pros :
- Encore plus modulaire.
Cons :
- Surcoût d'un crate supplémentaire pour ~1500 lignes.
- Aujourd'hui un seul consommateur (
rustwarden-corelui-même via la feature). Le surcoût n'est pas justifié. - Si demain un cas d'usage l'impose (mainteneur séparé), facile de migrer plus tard.
- Rejeté pour l'instant.
Alternative D (ne rien faire)
Demander aux apps consommatrices de coder elles-mêmes les routes.
Cons :
- Chaque app ré-implémenterait WebFinger/Acteur/NodeInfo avec risque d'incompatibilité Mastodon.
- Annule l'intérêt de la fédération « clé en main » du framework.
- Rejeté.
Testing plan
Tests unitaires (à écrire AVANT l'implémentation — RED phase)
crates/rustwarden-core/src/fediverse/webfinger.rs#tests :
- Happy path —
webfinger_returns_jrd_for_existing_local_user: resolver mock renvoie un user, vérifie la réponse JRD avecsubject,aliases,links[rel=self]pointant vershttps://{domain}/users/{username}. - Erreur — user inconnu —
webfinger_returns_404_for_unknown_user: resolver renvoieNone→ HTTP 404. - Erreur — domaine inconnu —
webfinger_returns_404_when_domain_mismatches: resource demande@user@other.tldalors queFEDIVERSE_DOMAIN=local.tld→ 404. - Erreur — resource malformée —
webfinger_returns_400_for_missing_resource_param,..._for_resource_without_acct_prefix,..._for_resource_without_at_separator(3 sous-cas). - CORS —
webfinger_response_has_cors_star: headerAccess-Control-Allow-Origin: *présent (RFC 7033 §3.2 + obligation Mastodon).
crates/rustwarden-core/src/fediverse/actor.rs#tests :
- Happy path AP JSON —
actor_returns_person_json_with_ap_accept:GET /users/aliceavecAccept: application/activity+json→ 200,Content-Type: application/activity+json, JSON Person valide (id, preferredUsername, inbox, outbox, followers, following, endpoints.shared_inbox).public_keyestNone(RFC 0005 pendante). - Happy path LD JSON —
actor_returns_person_json_with_ld_json_accept: variante avecAccept: application/ld+json; profile="..."→ 200 idem. - Erreur — user inconnu —
actor_returns_404_for_unknown_user: 404. - Negotiation — HTML sans UI montée —
actor_returns_406_for_html_when_no_ui_handler:Accept: text/htmlmais pas de UI → 406 Not Acceptable. - Default Accept —
actor_default_accept_returns_ap_json: aucunAccept→ AP JSON (convention Mastodon).
crates/rustwarden-core/src/fediverse/nodeinfo.rs#tests :
- Discovery —
nodeinfo_discovery_links_to_2_1:GET /.well-known/nodeinfo→ JSON aveclinks[0].rel == "http://nodeinfo.diaspora.software/ns/schema/2.1",links[0].href == "https://{domain}/nodeinfo/2.1". - Detail —
nodeinfo_detail_returns_software_metadata:GET /nodeinfo/2.1→ JSON avecsoftware.name = "rustwarden",software.version = CARGO_PKG_VERSION,protocols = ["activitypub"], usage stats (mocked). - Usage stats —
nodeinfo_detail_counts_users_via_resolver: resolver fournit total/active_month/active_halfyear, vérifier qu'ils apparaissent dans la réponse.
Doctests
- 1 sur
PersonJson(montrer la structure typique). - 1 sur
webfinger::handle(rust,ignore — nécessite un Axum context).
Tests d'intégration
tests/integration/fediverse_discovery.rs(#[ignore], lance le binaire avec DB Docker) :curl -H 'Accept: application/activity+json' /users/admin→ JSON valide. WebFinger :curl '.well-known/webfinger?resource=acct:admin@local.tld'→ JRD valide.- À ajouter quand RFC 0005 atterrira : test « Mastodon Docker résout
@admin@local.tld».
Quality gates
Mêmes que RFC 0001 :
cargo build --workspace(sans feature) → OKcargo build --workspace --features rustwarden-core/experimental-fediverse,rustwarden-ui/experimental-fediverse→ OKcargo test --workspace --features ... -- --test-threads=1→ tous verts (171 + 13 = 184 tests)cargo clippy --workspace --all-targets --features ... -- -D warnings→ 0cargo fmt --check→ OKcargo doc --no-deps -p rustwarden-core --features experimental-fediverse→ 0 warning rustdoc- Coverage cible : ≥ 90 % sur
webfinger,nodeinfo,actor(composants exposés publiquement, surface réduite).
Migration guide
### Added (post RFC 0002) - HTTP routes (added by `with_fediverse()`): - `GET /.well-known/webfinger` (CORS `*`) - `GET /.well-known/nodeinfo` - `GET /nodeinfo/2.1` - `GET /users/{username}` (content negotiation: AP JSON or HTML if UI mounted) - Public types: `rustwarden_core::fediverse::types::{PersonJson, PublicKeyJson, WebFingerResponse, NodeInfo, ...}` - Trait: `rustwarden_core::fediverse::LocalUserResolver` (default impl `SeaOrmLocalUserResolver` provided). ### Migration steps For consumers already on `with_fediverse()` (RFC 0001): nothing to do — the new routes are mounted automatically when `with_fediverse()` is called and the config is valid. Verify your reverse proxy forwards `/.well-known/webfinger` and `/.well-known/nodeinfo` to the Rustwarden process (some proxies block `.well-known` paths by default). For consumers wiring custom routes that conflict with `/users/{username}`: this RFC's actor handler delegates to the UI handler when `Accept: text/html`, so existing HTML profile pages should keep working when both are mounted.
Open questions
-
Comportement par défaut sans
Accept— choix : AP JSON (convention Mastodon) ou redirection vers HTML (UX humaine) ? Recommandation : AP JSON par défaut, sauf si la feature UI est compilée → délégation HTML. À ratifier. -
open_registrationsdans NodeInfo — exposer la valeur deapp_settings.allow_signup? Recommandation : oui, dériver dynamiquement. -
active_month/active_halfyear— basés surusers.last_login_at(déjà présent). Confirmer le critère exact (≥ 1 login dans la fenêtre). -
@contextJSON-LD — émettre["activitystreams", "security/v1", { "toot": ... }](étendu Mastodon) dès le départ ou démarrer minimal ? Recommandation : étendu pour interop maximale.
Tracking issue
À créer sur le repo upstream avec le label rfc une fois la RFC en Under Review.
Score d'évaluation (auto-revue 2026-05-09, mainteneur unique)
| Critère | Note | Commentaire |
|---|---|---|
| Généralité | 5 | Sert toute app héritière qui veut être discoverable Fediverse. AP/NodeInfo sont les standards de facto. |
| Stabilité API | 4 | Additif via feature flag, MAIS nouveau format sérialisé public → engagement SemVer strict sur les noms camelCase. Atténuation : feature experimental-* permet du breaking pré-stabilisation. |
| Performance | 5 | Routes JSON pures, lecture seule, négligeable. NodeInfo cache optionnel à terme. |
| Sécurité | 5 | Read-only public anonyme. Pas de surface d'attaque nouvelle. CORS * sur WebFinger (par spec RFC 7033). |
| Complexité d'implémentation | 3 | ~400-500 lignes (4 handlers + types serde + trait resolver + ~13 tests). Touche app.rs pour le wiring. Patterns Axum/serde standards. |
| Charge de maintenance | 3 | Formats AP/NodeInfo stables ; quelques évolutions Mastodon à surveiller (champs discoverable, indexable, featured...). Trait LocalUserResolver à maintenir. |
| Documentation | 4 | Rustdoc EN sur tous les pub, doctests, CHANGELOG, migration guide explicite. |
| Total | 29/35 | ≥ 25 → Accepted |
GitRust