0001-fediverse-config-and-builder.md 20675 octets

RFC 0001 — Fediverse: FediverseConfig + with_fediverse() builder

Summary

Introduire FediverseConfig (struct de configuration runtime de la fédération) et la méthode RustwardenBuilder::with_fediverse() qui active conditionnellement le module fediverse du framework, gardé derrière une nouvelle feature Cargo experimental-fediverse.

Motivation

Le plan d'intégration de la fédération d'identité (SSO cross-instance entre apps Rustwarden + invitations team par handle @user@domain) nécessite un point d'entrée unique côté builder. Sans ce point d'entrée :

  • Aucune des autres RFCs (0002–0011) ne peut atterrir : elles dépendent toutes du fait que le module fediverse soit activable de façon contrôlée.
  • Les apps consommatrices n'ont aucun moyen de configurer le domaine fédéré, le mode trust, le worker outbound, etc., sans dupliquer la logique de configuration dans leur propre code.
  • Les guidelines (HTTPS obligatoire en prod, blocage placeholder, validation domaine) ne sont pas centralisées.

Conséquence si on ne fait rien : Phase 1+ du plan inexécutable. Bloquant.

Délai : pas d'urgence sécurité ; cette RFC ouvre simplement la voie au travail planifié.

Non-goals

Cette RFC ne couvre pas :

  • Les routes HTTP de la fédération (WebFinger, NodeInfo, Acteur) → RFC 0002.
  • La table remote_identities et le service associé → RFC 0003.
  • Les tables fediverse_trusted_domains / fediverse_blocked_domains et le service trust → RFC 0004.
  • La génération de clés RSA et leur stockage chiffré → RFC 0005.
  • L'abstraction Identity et IdentityResolver → RFC 0008 (Phase 3).
  • Le worker outbound → RFC 0011.
  • Le toggle runtime app_settings.fediverse_enabled (intégré dans RFC 0004 puisqu'il vit avec les settings trust).

Cette RFC se concentre uniquement sur :

  1. La struct FediverseConfig et son chargement depuis l'environnement.
  2. La méthode builder with_fediverse().
  3. La feature Cargo experimental-fediverse et ses dépendances.

API proposée

Feature Cargo

crates/rustwarden-core/Cargo.toml :

[dependencies]
# ... (existing)
activitypub_federation = { version = "=0.7.0-beta.11", features = ["axum"], default-features = false, optional = true }
ammonia = { version = "4", optional = true }
url = { version = "2", features = ["serde"], optional = true }

[features]
default = []
redis-session = ["tower-sessions-redis-store"]
oauth = ["openidconnect", "reqwest"]
experimental-fediverse = ["dep:activitypub_federation", "dep:ammonia", "dep:url"]

crates/rustwarden-ui/Cargo.toml :

[features]
default = []
experimental-fediverse = ["rustwarden-core/experimental-fediverse"]

FediverseConfig

crates/rustwarden-core/src/config/fediverse.rs (nouveau fichier, gated par cfg(feature = "experimental-fediverse")) :

use std::time::Duration;
use url::Url;

/// Runtime configuration for ActivityPub identity federation.
///
/// This struct is loaded from environment variables at startup via
/// [`FediverseConfig::from_env`]. It is then injected into the Axum
/// application via [`crate::app::RustwardenBuilder::with_fediverse`].
///
/// # Example
///
/// ```rust,ignore
/// # // Doctest gated to the `experimental-fediverse` feature.
/// use rustwarden_core::config::FediverseConfig;
///
/// // SAFETY: doctest fixture; values are stable test constants.
/// unsafe {
///     std::env::set_var("FEDIVERSE_DOMAIN", "example.com");
/// }
///
/// let config = FediverseConfig::from_env().unwrap();
/// assert_eq!(config.domain(), "example.com");
/// assert_eq!(config.base_url().as_str(), "https://example.com/");
/// ```
#[derive(Debug, Clone)]
pub struct FediverseConfig {
    domain: String,
    base_url: Url,
    user_agent: String,
    authorized_fetch: bool,
    outbound_workers: usize,
    outbound_max_attempts: u32,
    outbound_request_timeout: Duration,
    fetch_actor_cache_ttl: Duration,
    auth_challenge_ttl: Duration,
    debug_local_federation: bool,
}

/// Errors that can occur while loading or validating [`FediverseConfig`].
#[derive(Debug, thiserror::Error)]
pub enum FediverseConfigError {
    /// `FEDIVERSE_DOMAIN` environment variable is missing or empty.
    #[error("FEDIVERSE_DOMAIN environment variable is missing or empty")]
    MissingDomain,

    /// `FEDIVERSE_DOMAIN` value is a known placeholder (e.g. `example.com`,
    /// `localhost.localdomain`) and refused outside debug mode.
    #[error("FEDIVERSE_DOMAIN value `{0}` is a placeholder; set a real domain")]
    PlaceholderDomain(String),

    /// Computed base URL is not HTTPS while `debug_local_federation` is off.
    #[error("base URL `{0}` must use HTTPS in production")]
    InsecureBaseUrl(String),

    /// `FEDIVERSE_DOMAIN` cannot be parsed as a valid URL host.
    #[error("FEDIVERSE_DOMAIN value `{0}` is not a valid host: {1}")]
    InvalidDomain(String, String),

    /// A numeric environment variable could not be parsed.
    #[error("environment variable `{0}` has an invalid value: {1}")]
    InvalidNumeric(&'static str, String),
}

impl FediverseConfig {
    /// Load and validate a `FediverseConfig` from environment variables.
    ///
    /// Required variables:
    /// - `FEDIVERSE_DOMAIN` — the federated domain (no scheme, no path),
    ///   e.g. `example.com`.
    ///
    /// Optional variables (with defaults):
    /// - `FEDIVERSE_AUTHORIZED_FETCH` (`false`)
    /// - `FEDIVERSE_OUTBOUND_WORKERS` (`4`)
    /// - `FEDIVERSE_OUTBOUND_MAX_ATTEMPTS` (`8`)
    /// - `FEDIVERSE_OUTBOUND_REQUEST_TIMEOUT_SECS` (`30`)
    /// - `FEDIVERSE_FETCH_ACTOR_CACHE_TTL_SECS` (`86400`)
    /// - `FEDIVERSE_AUTH_CHALLENGE_TTL_SECS` (`300`)
    /// - `FEDIVERSE_DEBUG_LOCAL_FEDERATION` (`false`) — when `true`, allows
    ///   HTTP base URLs for local 2-instance integration tests.
    ///
    /// # Errors
    ///
    /// Returns [`FediverseConfigError`] when validation fails (missing domain,
    /// placeholder domain, insecure URL outside debug mode, …).
    pub fn from_env() -> Result<Self, FediverseConfigError> { /* … */ }

    /// Federated domain (no scheme, no trailing slash). Example: `example.com`.
    pub fn domain(&self) -> &str { &self.domain }

    /// Canonical base URL, always with a trailing slash.
    /// Example: `https://example.com/`.
    pub fn base_url(&self) -> &Url { &self.base_url }

    /// HTTP `User-Agent` to send on outbound federation requests.
    pub fn user_agent(&self) -> &str { &self.user_agent }

    /// Whether incoming `GET` requests on actor/object endpoints must be
    /// signed (Mastodon "secure mode").
    pub fn authorized_fetch(&self) -> bool { self.authorized_fetch }

    /// Number of concurrent worker tasks delivering outbound activities.
    pub fn outbound_workers(&self) -> usize { self.outbound_workers }

    /// Maximum delivery attempts before a job is marked `dead`.
    pub fn outbound_max_attempts(&self) -> u32 { self.outbound_max_attempts }

    /// Per-request HTTP timeout for outbound deliveries.
    pub fn outbound_request_timeout(&self) -> Duration { self.outbound_request_timeout }

    /// TTL for cached remote actor documents.
    pub fn fetch_actor_cache_ttl(&self) -> Duration { self.fetch_actor_cache_ttl }

    /// TTL for federated authentication challenges (cf. Phase 2).
    pub fn auth_challenge_ttl(&self) -> Duration { self.auth_challenge_ttl }

    /// When `true`, HTTP base URLs are allowed (for local 2-instance tests).
    pub fn debug_local_federation(&self) -> bool { self.debug_local_federation }
}

RustwardenBuilder::with_fediverse()

crates/rustwarden-core/src/app.rs (modification, gated par cfg(feature = "experimental-fediverse")) :

impl RustwardenBuilder {
    /// Enable ActivityPub identity federation on the application.
    ///
    /// Loads [`FediverseConfig`] from environment variables and registers
    /// the federation router and outbound worker on the underlying Axum app.
    ///
    /// This method is **only available when the `experimental-fediverse`
    /// feature is enabled** at compile time. Without the feature, the symbol
    /// is not present and consumer code that calls it will fail to compile,
    /// which is the desired behaviour (compile-time opt-in).
    ///
    /// At runtime, the federation router additionally honours the
    /// `app_settings.fediverse_enabled` flag (covered by RFC 0004): when
    /// `false`, the routes return `404 Not Found` even though they are
    /// mounted.
    ///
    /// # Errors
    ///
    /// Returns [`anyhow::Error`] if [`FediverseConfig::from_env`] fails.
    ///
    /// # Example
    ///
    /// ```rust,ignore
    /// use rustwarden_core::app::RustwardenApp;
    ///
    /// #[tokio::main]
    /// async fn main() -> anyhow::Result<()> {
    ///     RustwardenApp::builder()
    ///         .from_env()
    ///         .with_fediverse()?    // compile-time opt-in via feature
    ///         .build()
    ///         .await?
    ///         .run()
    ///         .await
    /// }
    /// ```
    #[cfg(feature = "experimental-fediverse")]
    pub fn with_fediverse(self) -> anyhow::Result<Self> {
        let config = crate::config::fediverse::FediverseConfig::from_env()?;
        // The actual wiring (router mount, FederationMiddleware, outbound
        // worker spawn) is added by RFCs 0002, 0004, 0005, 0011.
        // For RFC 0001 we only store the config on the builder.
        Ok(Self {
            fediverse_config: Some(config),
            ..self
        })
    }
}

Réexport

crates/rustwarden-core/src/config/mod.rs :

#[cfg(feature = "experimental-fediverse")]
pub mod fediverse;

#[cfg(feature = "experimental-fediverse")]
pub use fediverse::{FediverseConfig, FediverseConfigError};

crates/rustwarden-core/src/lib.rs (prelude étendu) :

pub mod prelude {
    // ... existing re-exports

    #[cfg(feature = "experimental-fediverse")]
    pub use crate::config::FediverseConfig;
}

Analyse de compatibilité

  • API existante non modifiée → non-breaking (patch/minor possible).
  • API existante étendue avec défaut → non-breaking (minor) : ajout de méthode with_fediverse() derrière feature flag.
  • API existante modifiée ou supprimée → non.
  • Nouveau comportement par défaut → la feature experimental-fediverse est off par défaut. Aucune app existante n'est affectée.
  • Changement de schéma DB → non (RFC 0003-0005 introduiront les tables).
  • Changement de format sérialisé → non.

Conclusion : non-breaking, additif pur. Peut sortir en minor release (0.x+1.0).

Alternatives considérées

Alternative A (recommandée) — FediverseConfig + with_fediverse() + feature experimental-fediverse

Pros :

  • Conforme au pattern existant (oauth, redis-session).
  • Compile-time opt-in via feature flag → coût nul pour les apps qui n'en veulent pas.
  • Validation centralisée (HTTPS, placeholders, parsing).
  • API simple, alignée avec with_default_ui() et autres méthodes du builder.
  • Permet un naming progressif experimental-* → * après stabilisation (cf. Evolution Process §5.1).

Cons :

  • Ajoute 3 nouvelles dépendances optionnelles (activitypub_federation, ammonia, url).
  • Une seconde version transitive de reqwest (0.13 via crate AP) coexistera avec la 0.12 actuelle quand oauth ET experimental-fediverse sont activés. ~2 Mo de poids binaire. Mitigation : à ré-évaluer quand oauth2 6 (compatible reqwest 0.13) sortira.

Alternative B — Boot fully driven by env-vars (no explicit builder method)

L'intégration se ferait automatiquement dès que la feature est compilée et que FEDIVERSE_DOMAIN est défini, sans appel explicite à with_fediverse().

Pros :

  • Une étape de moins pour le consommateur.

Cons :

  • Implicite : un consommateur qui active la feature par mégarde voit la fédération démarrer.
  • Pas de flexibilité pour avoir la feature compilée mais la désactiver à un seul endroit.
  • Diverge du pattern de builder utilisé partout ailleurs (with_default_ui, with_admin, …).
  • Les erreurs de config seraient remontées implicitement, plus difficile à diagnostiquer.

Alternative C — Configuration via TOML/YAML file

Au lieu de from_env(), charger depuis un fichier fediverse.toml ou fediverse.yaml.

Pros :

  • Configuration plus riche et versionnable.
  • Plus lisible pour des configs complexes.

Cons :

  • Ajoute une dépendance toml/serde_yaml.
  • Diverge des autres configs Rustwarden (AuthConfig, EmailConfig, …) qui utilisent toutes from_env().
  • Inutile à ce stade : la dizaine de variables qui composent la config tient parfaitement en env.

Alternative D (ne rien faire / workaround)

Demander aux apps consommatrices de gérer elles-mêmes la config et l'intégration.

Pros : zéro coût, zéro risque pour le framework.

Cons :

  • Chaque app consommatrice devra réimplémenter la même logique (validation, parsing).
  • Aucune garantie de cohérence (une app pourrait oublier de valider HTTPS, de rejeter les placeholders).
  • Bloque le plan : aucune des autres RFCs ne peut s'appuyer sur une config commune.
  • Anti-pattern explicite (cf. Evolution Process §11.4 : "Dupliquer un service du framework dans l'app : dette certaine").

Testing plan

Tests unitaires (crates/rustwarden-core/src/config/fediverse.rs#tests)

  1. Happy path — from_env_returns_valid_config_with_required_var_only : seul FEDIVERSE_DOMAIN est défini, tous les autres prennent leurs défauts. La config est construite avec succès, domain(), base_url(), outbound_workers() etc. retournent les valeurs attendues.

  2. Erreur — domain manquant : from_env_returns_missing_domain_when_var_unset. Test vérifie Err(FediverseConfigError::MissingDomain).

  3. Erreur — domain placeholder : from_env_returns_placeholder_error_for_example_com, ..._for_localhost_localdomain. Test vérifie Err(FediverseConfigError::PlaceholderDomain(_)).

  4. Erreur — base URL non HTTPS hors debug : from_env_rejects_http_base_url_in_production. Avec FEDIVERSE_DEBUG_LOCAL_FEDERATION=false et un domain qui produit http://, attendu Err(FediverseConfigError::InsecureBaseUrl(_)).

  5. Cas debug — from_env_allows_http_when_debug_local_federation : avec FEDIVERSE_DEBUG_LOCAL_FEDERATION=true, http://localhost:3000 est accepté.

  6. Erreur — numeric invalid : from_env_returns_invalid_numeric_for_bad_workers_value (env var FEDIVERSE_OUTBOUND_WORKERS=abc).

  7. Default — defaults_match_documented_values : sans aucune variable opt-in, vérifier outbound_workers == 4, max_attempts == 8, request_timeout == 30s, etc.

TDD : ces tests sont écrits avant l'implémentation. Ils doivent échouer initialement (red), puis passer une fois from_env implémenté (green). Les tests utilisent le pattern unsafe { std::env::set_var(…) } + // SAFETY: (constitution ANSSI Rust + amendement edition 2024 réglé en Phase 0).

Doctests

  • Au moins 1 exemple sur FediverseConfig (cf. # Example dans la rustdoc ci-dessus).
  • Au moins 1 exemple sur with_fediverse() (idem).

Tests d'intégration

  • N/A à ce stade (RFC 0001 introduit la config, pas le routing). Les tests d'intégration arriveront à la RFC 0002 (WebFinger / Actor / NodeInfo).

Tests de non-régression

  • cargo build --no-default-features doit toujours passer (workspace fonctionne sans la feature experimental-fediverse).
  • cargo build --features experimental-fediverse (sur rustwarden-core) doit compiler.
  • Tous les tests existants doivent rester verts (cargo test --workspace).

Quality gates

  • cargo build --workspace ✅ 0 erreur.
  • cargo test --workspace -- --test-threads=1 ✅ tous verts.
  • cargo clippy --workspace --all-targets -- -D warnings ✅ 0 warning.
  • cargo fmt --check ✅.
  • cargo doc --no-deps -p rustwarden-core ✅ 0 warning rustdoc.
  • Couverture sur config/fediverse.rs : viser 95 %+ (composant simple, validation pure).

Migration guide

### Added

- New optional Cargo feature `experimental-fediverse` in both `rustwarden-core`
  and `rustwarden-ui`. Disabled by default. Enables ActivityPub identity
  federation primitives.
- `rustwarden_core::config::FediverseConfig` and `FediverseConfigError`
  (re-exported from the prelude when the feature is on).
- `RustwardenBuilder::with_fediverse()` method to wire the federation into
  the Axum app at startup.

### Migration steps

For consumers who do **not** want federation: nothing to do. The feature is
off by default and adds zero overhead.

For consumers who want federation:

1. In `Cargo.toml`, add the feature flag:
   \`\`\`toml
   rustwarden-core = { version = "X.Y.Z", features = ["experimental-fediverse"] }
   rustwarden-ui   = { version = "X.Y.Z", features = ["experimental-fediverse"] }
   \`\`\`
2. In `.env`, define at minimum:
   \`\`\`
   FEDIVERSE_DOMAIN=your.tld
   \`\`\`
3. In `main.rs`, call `.with_fediverse()` on the builder:
   \`\`\`rust
   RustwardenApp::builder()
       .from_env()
       .with_fediverse()?
       .build().await?
       .run().await
   \`\`\`

This RFC alone does not yet expose any HTTP routes — those arrive with
RFC 0002 (WebFinger/Actor/NodeInfo).

Open questions

Résolues lors de l'auto-revue 2026-05-09 :

  • Naming feature flag — Décision : experimental-fediverse. Cohérent avec Evolution Process §5.1 (cycle birth → adoption → promotion → cleanup) et avec la méthodologie §10/E du master plan. Promotion en fediverse standard à prévoir après ≥ 2 apps adoptantes ou 30 j sans bug critique.
  • Visibilité des champs — Décision : accesseurs pub fn uniquement, champs privés. Permet d'évoluer vers du calcul lazy ou de la validation différée sans breaking change.
  • from_env() vs from_env_with(prefix) — Décision : from_env() sans préfixe. Cohérent avec les configs existantes du framework (AuthConfig::from_env(), EmailConfig::from_env(), ServerConfig::from_env()).
  • Stockage dans RustwardenBuilder — Décision : ajouter le champ pub(crate) fediverse_config: Option<FediverseConfig> au builder, gated cfg(feature = "experimental-fediverse"). Pas exposé publiquement.

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èreNoteCommentaire
Généralité4Sert toute app héritière qui veut la fédération. Pas universel (apps SPA-only n'ont pas besoin du builder, mais peuvent toujours appeler FediverseConfig::from_env() directement).
Stabilité API5Additif pur derrière feature flag off par défaut. Non-breaking. Sortable en minor.
Performance5Zéro impact runtime si feature off (cargo n'inclut pas le code). À l'on : seulement chargement env + parsing au démarrage, négligeable.
Sécurité4Centralise validation HTTPS, rejet placeholders, parsing URL strict. Pas de hardening net (pas de signature/auth ici), mais base saine pour les RFCs suivantes.
Complexité d'implémentation5< 200 lignes (struct + parser env + builder method). Patterns existants (AuthConfig::from_env()). Aucun unsafe, aucun refacto.
Charge de maintenance23 nouvelles deps directes optionnelles. activitypub_federation 0.7.0-beta est en API instable (pin strict requis, surveiller releases). ammonia et url sont stables. Risque modéré.
Documentation3Rustdoc EN sur tous les pub prévue, 2 doctests (struct + builder method). CHANGELOG préparé dans la migration guide. Pas encore d'exemple examples/ — à ajouter en phase ultérieure.
Total28/35≥ 25 → Accepted