end fediverse need to extract crate

EBO <eric.bouhana@softalys.com> committé le 2026-05-11 15:33

51e1a3448522fcc34bc0f67568882c4ca746bab9

1 parent(s)

41 fichiers modifiés +7057 -60
M CHANGELOG.md
+120 -0
@@ -9,6 +9,113 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9 9
10 10 ### Added
11 11
12 +- **Documentation opérateur** : nouveau guide
13 + [`docs/fediverse-operations.md`](docs/fediverse-operations.md) (10 sections, Diátaxis
14 + how-to + reference) : activation, env vars, `app_settings` clés, trust/block list,
15 + incident playbook, observabilité, GC, critères promotion stable, URLs exposées,
16 + limites connues.
17 +- **Observabilité fédiverse — toggle DB default-off** : nouveau réglage
18 + `app_settings.fediverse_observability_enabled` (default `false`) et helper public
19 + `services::fediverse_outbound_service::is_fediverse_observability_enabled`. Les
20 + `tracing::info!`/`warn!`/`error!` structurés (RFC 0019) émis par
21 + `FediverseOutboundService::drain_one` sont désormais conditionnés à ce flag. Sans
22 + agent (Vector/Promtail) qui consomme, laisser `false` pour éviter le bruit logs ;
23 + activer quand un pipeline d'observabilité est en place.
24 +- **Audit privacy passé** : revue manuelle confirme que les payloads AP exposés
25 + (Person, WebFinger, NodeInfo) sont minimaux — aucune fuite email / password hash /
26 + rôles internes / IPs. `UserSummary` est une DTO curatée. Aucun `tracing::*` ne log
27 + password / JWT / clé privée / encryption key brut. Finding F12 latent identifié
28 + (`summary_html` reçu via Update(Person) stocké brut, mais jamais rendu UI — non
29 + exploitable ; ajouter `ammonia::clean()` à l'INSERT si jamais utilisé en rendu).
30 +- **Suite E2E à 32 tests** : ajout des scénarios S5 (modes de panne — 4 tests) et S6 (lifecycle Update/Delete Person — 2 tests). Les 6 scénarios du plan parent (S1-S6) sont désormais automatisés en HTTP réel avec crypto réelle.
31 + - S5 valide la replay protection (UNIQUE constraint sur `fediverse_auth_challenges.nonce`), le WebFinger 404, la validation handle malformé, et **documente F11** (clock skew non-enforced avec `AlwaysAcceptVerifier` — non-bloquant, by-design dev mode).
32 + - S6 valide que `Update(Person)` et `Delete(Person)` signés sur `/inbox` propagent correctement : refresh `display_name`, désactivation `remote_identities.disabled_at`, et invalidation instantanée des JWT remote via F10.
33 +- **F10 — Révocation immédiate des JWT remote lors de kill_domain** :
34 + ajout d'un check `disabled_at` sur `remote_identities` dans le
35 + middleware `AuthService` (feature-gated `experimental-fediverse`).
36 + Un JWT remote dont l'identité backing a été désactivée est
37 + immédiatement rejeté (redirect 303 vers `/login`) au prochain
38 + request, sans nécessiter d'entrée dans `jwt_blacklist`. Cheap : un
39 + SELECT par PK.
40 +- **F9 — Auto-kill sur add_blocked_domain** : le handler admin
41 + `add_trust_entry` (mode `OpenWithBlocklist`) effectue désormais
42 + atomiquement l'INSERT dans `fediverse_blocked_domains` ET un UPDATE
43 + `disabled_at` sur tous les `remote_identities` du domaine. Un seul
44 + clic admin pour « couper l'instance hostile » — combiné avec F10
45 + les sessions actives sont rejetées au prochain request.
46 +- **Suite E2E étendue à 26 tests** : +4 tests S4 (`s4_blocked_domain_payload_is_rejected_with_403`,
47 + `s4_kill_domain_marks_remote_identities_disabled`,
48 + `s4_add_blocked_domain_auto_kills_remote_identities`,
49 + `s4_kill_domain_invalidates_active_remote_jwt`). Le dernier valide
50 + bout-en-bout via vrai HTTP (POST signé → JWT remote minté → GET
51 + protégé OK → kill_domain → GET protégé redirige `/login`).
52 +- **F8 — Trust enforcement au niveau protocole** : la trust list était
53 + décorative — `FediverseTrustService::is_domain_allowed` existait
54 + uniquement appelé depuis l'UI. Un domaine `evil.tld` signé valide
55 + pouvait POST des activités à n'importe quelle instance Rustwarden,
56 + indépendamment de la trust list.
57 + - Nouvelle méthode `FediverseTrustService::assert_domain_allowed` qui
58 + retourne `AppError::Validation` quand un domaine n'est pas autorisé
59 + par le mode courant.
60 + - Nouvelle variante `FediverseError::Forbidden(String)` → 403.
61 + - Appel `assert_domain_allowed` au début de `auth_inbox` et `/inbox`,
62 + **avant** la vérification de signature (rejette les domaines hostiles
63 + sans même fetcher leur actor).
64 + - 2 tests E2E S3 dédiés : `s3_untrusted_domain_is_rejected_with_403`
65 + confirme l'enforcement, `s3_trusted_domain_still_passes_protocol_inbox`
66 + vérifie l'absence de régression.
67 +- **Suite E2E étendue** : passe de 14 à **22 tests** (8 nouveaux pour S2 +
68 + S3) en 7.83s. Nouvelle couverture :
69 + - 4 tests S2 (admin invite → team_member binding, reject, revoke,
70 + listing IdP-side).
71 + - 4 tests S3 (non-transitivité du trust, F8 enforcement positif et
72 + négatif).
73 +- **RFC 0025 — Fediverse back-channel `Accept(AuthRequest)`** (RFC 0006 Phase 3) :
74 + comble le trou identifié par l'évaluation E2E (iter 1c) où A approuvait un
75 + challenge mais B ne le savait jamais. Le SSO bilatéral A ↔ B est désormais
76 + opérationnel bout en bout sans DB-direct.
77 + - `FediverseAuthService::record_inbound_decision` — applique un `Accept` ou
78 + `Reject` reçu sur l'inbox SP. Sécurité : domaine de l'approver vérifié
79 + contre `challenge.domain` (anti-spoofing). Idempotent sur replay.
80 + - `auth_inbox` dispatche désormais sur le champ `type` AP (AuthRequest /
81 + Accept / Reject / inconnu → silent ignore per AP §7).
82 + - Helper public `build_auth_decision_payload(...)` qui sérialise le payload
83 + JSON-LD au format AP standard avec `object` en forme inline-object pour
84 + porter le nonce.
85 + - `consent_submit` (UI) enqueue le `Accept`/`Reject` sortant via
86 + `FediverseOutboundService::enqueue` après `record_decision`.
87 + - Recherche externe : on utilise les types AP **standards** `Accept` /
88 + `Reject` (alignés sur le pattern Mastodon `Accept(Follow)`) plutôt
89 + qu'inventer `AuthApproval`/`AuthDenial`.
90 +- **F2 — `AlwaysAcceptVerifier` populate `remote_identities`** : le verifier
91 + test/dev (`with_resolver(db)`) fetche désormais l'actor URL et upsert sa
92 + ligne `remote_identities` au lieu de laisser le cache vide. Restaure le
93 + contrat documenté de `persist_inbound_request`.
94 + - Helper public réutilisable
95 + [`services::fediverse_signal_service::fetch_and_cache_remote_actor`](crates/rustwarden-core/src/services/fediverse_signal_service.rs)
96 + avec hardening : timeout 10s, body cap 1 MiB (Content-Length pre-flight
97 + + bytes() post-read), SSRF guard rejetant loopback / private IPv4 /
98 + link-local / ULA IPv6 (`allow_private_ips=true` réservé au testbed).
99 + - 9 nouveaux tests unitaires couvrant happy path INSERT, refetch UPDATE,
100 + 404, JSON malformé, publicKey manquant, inbox manquant, URL invalide,
101 + body trop gros, SSRF guard loopback.
102 +- **F1 — Normalisation `FEDIVERSE_DOMAIN`** : `auth_inbox` accepte désormais
103 + la forme `http://example.org` aussi bien que `example.org`. Strip scheme /
104 + trailing slash avant comparaison contre le `target.domain`.
105 +- **Testbed `tests/fediverse-testbed/`** : topologie 3 instances locales
106 + 100 % automatisable (Docker Compose), 4 scripts cycle de vie (`setup.sh`,
107 + `start.sh`, `stop.sh`, `teardown.sh`). Image multi-stage `rustc 1.91-slim`
108 + embarquant les binaires `rustwarden`, `migrate`, `seed`. Réseau Docker
109 + avec `network_aliases` `alpha.local` / `beta.local` / `gamma.local` ; pas
110 + de TLS, pas de `/etc/hosts` requis grâce à `FEDIVERSE_DEBUG_LOCAL_FEDERATION`.
111 +- **Suite E2E `crates/rustwarden-core/tests/e2e_fediverse.rs`** — 14 tests
112 + HTTP réels contre le testbed, dont 6 du flux S1 SSO complet avec
113 + signatures RSA-SHA256 réelles (iter 1a / 1b / 1c / 1d) :
114 + - 7 smoke tests (WebFinger, NodeInfo, Actor fetch, inbox 401)
115 + - 1 test signed AuthRequest B → A (iter 1b)
116 + - 1 test consume B → JWT remote (iter 1c)
117 + - 5 tests F3 round-trip Accept/Reject (iter 1d) : happy path, reject,
118 + unknown nonce, expired challenge, replay idempotence
12 119 - **Workspace** : edition Rust **2024** + `rust-version = "1.85"` au niveau
13 120 `[workspace.package]`. Toutes les crates héritent (`edition.workspace = true`).
14 121 - **RFC 0006 Phase 2** — inbox enrichment côté IdP : `auth_inbox`
@@ -803,6 +910,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
803 910 `rustwarden_core::config::FediverseConfigError`,
804 911 `RustwardenBuilder::with_fediverse()`.
805 912
913 +### Fixed
914 +
915 +- **Race env var `FEDIVERSE_DOMAIN`** dans la suite tests unit (révélée
916 + par l'élargissement de la fenêtre de mutation après ajout de F8).
917 + `auth_inbox_returns_400_when_requester_actor_unknown` mutait
918 + `FEDIVERSE_DOMAIN` puis `remove_var`, ce qui faisait sporadiquement
919 + rater les 4 tests `build_*_payload` qui lisent `FediverseConfig::from_env()`
920 + sur thread parallèle. Corrigé en supprimant la mutation env du test
921 + (l'assertion `BadRequest` reste vraie quelle que soit la valeur de
922 + l'env grâce au mismatch target.domain). Dette restante : la mutation
923 + env globale dans `test_fediverse_config` est un anti-pattern à
924 + traiter dans une RFC dette technique future.
925 +
806 926 ### Changed
807 927
808 928 - 4 occurrences de `std::env::set_var` enveloppées dans `unsafe { ... }` avec
M app/src/main.rs
+7 -6
@@ -4,11 +4,12 @@ use rustwarden_core::prelude::*;
4 4
5 5 #[tokio::main]
6 6 async fn main() -> anyhow::Result<()> {
7 - RustwardenApp::builder()
7 + let builder = RustwardenApp::builder()
8 8 .from_env()
9 - .with_ui(rustwarden_ui::default_routes)
10 - .build()
11 - .await?
12 - .run()
13 - .await
9 + .with_ui(rustwarden_ui::default_routes);
10 +
11 + #[cfg(feature = "experimental-fediverse")]
12 + let builder = builder.with_fediverse()?;
13 +
14 + builder.build().await?.run().await
14 15 }
M crates/rustwarden-core/Cargo.toml
+3 -0
@@ -115,3 +115,6 @@ sea-orm = { version = "2.0.0-rc.20", features = ["mock"] }
115 115 # Fediverse interop tests (RFC 0020): fake-Mastodon HTTP server for
116 116 # round-trip verification of HTTP signatures and outbound delivery.
117 117 wiremock = "0.6"
118 +# E2E tests against the docker-compose fediverse testbed
119 +# (tests/fediverse-testbed/). Used by `cargo test --test e2e_fediverse`.
120 +reqwest = { version = "0.12", features = ["json"] }
M crates/rustwarden-core/src/fediverse/auth_routes.rs
+99 -18
@@ -175,6 +175,19 @@ async fn auth_inbox(
175 175 .unwrap_or_default();
176 176
177 177 let _ = signature; // presence already enforced above
178 +
179 + // F8 / RFC 0027 — enforce trust policy BEFORE signature verification
180 + // (and before any actor fetch). A hostile but signature-valid
181 + // instance must be rejected here based on its domain alone.
182 + let source_domain = url::Url::parse(&actor)
183 + .ok()
184 + .and_then(|u| u.host_str().map(|s| s.to_ascii_lowercase()))
185 + .ok_or_else(|| FediverseError::BadRequest("actor URL has no host".into()))?;
186 + crate::services::fediverse_trust_service::FediverseTrustService::new(db.clone())
187 + .assert_domain_allowed(&source_domain)
188 + .await
189 + .map_err(|e| FediverseError::Forbidden(e.to_string()))?;
190 +
178 191 state
179 192 .verifier
180 193 .verify(&VerifyCtx {
@@ -188,17 +201,51 @@ async fn auth_inbox(
188 201 .await
189 202 .map_err(|e| FediverseError::Unauthorized(e.to_string()))?;
190 203
191 - // RFC 0006 Phase 2: persist the inbound challenge so the IdP-side
192 - // consent screen (RFC 0023 Slice B iter. 4) can find it and approve.
193 - // The signature verifier above already populated the
194 - // `remote_identities` row for the requester, so the service method
195 - // can resolve `requested_remote_id` cleanly.
196 - // Resolve this IdP's canonical domain from the env (set by
197 - // `with_fediverse()` at app boot) for the target-validation check.
198 - let idp_domain = std::env::var("FEDIVERSE_DOMAIN").unwrap_or_default();
199 - FediverseAuthService::persist_inbound_request(&db, &body_json, &idp_domain)
200 - .await
201 - .map_err(|e| FediverseError::BadRequest(e.to_string()))?;
204 + // Dispatch on the activity `type` (RFC 0025).
205 + //
206 + // - `AuthRequest` → IdP side: persist a Pending challenge tied to
207 + // the requester (RFC 0006 Phase 2).
208 + // - `Accept`/`Reject` (with `object.type == AuthRequest`) → SP side:
209 + // propagate the IdP's decision into the local challenge (RFC 0025).
210 + // - Anything else → AP §7 says ignore unknown types silently; we
211 + // return 202 and log to ease debugging.
212 + let activity_type = body_json
213 + .get("type")
214 + .and_then(|v| v.as_str())
215 + .unwrap_or("")
216 + .to_string();
217 +
218 + match activity_type.as_str() {
219 + "AuthRequest" => {
220 + // Resolve this IdP's canonical domain from the env (set by
221 + // `with_fediverse()` at app boot) for the target-validation
222 + // check. Strip scheme/trailing-slash so the comparison against
223 + // `target`'s bare host part inside `persist_inbound_request`
224 + // works whether FEDIVERSE_DOMAIN was set as `example.org` or
225 + // `http://example.org`. (TODO: thread the parsed
226 + // FediverseConfig in via extension to avoid env reads here.)
227 + let idp_domain = std::env::var("FEDIVERSE_DOMAIN")
228 + .unwrap_or_default()
229 + .trim_start_matches("http://")
230 + .trim_start_matches("https://")
231 + .trim_end_matches('/')
232 + .to_ascii_lowercase();
233 + FediverseAuthService::persist_inbound_request(&db, &body_json, &idp_domain)
234 + .await
235 + .map_err(|e| FediverseError::BadRequest(e.to_string()))?;
236 + }
237 + "Accept" | "Reject" => {
238 + FediverseAuthService::record_inbound_decision(&db, &body_json)
239 + .await
240 + .map_err(|e| FediverseError::BadRequest(e.to_string()))?;
241 + }
242 + other => {
243 + tracing::debug!(
244 + activity_type = other,
245 + "auth_inbox: ignoring unknown activity type"
246 + );
247 + }
248 + }
202 249
203 250 Ok(StatusCode::ACCEPTED)
204 251 }
@@ -261,6 +308,33 @@ mod tests {
261 308 use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};
262 309 use tower::ServiceExt;
263 310
311 + /// F8 enforcement helper for unit tests: pre-queues three mock
312 + /// results (app_settings.fediverse_enabled=true,
313 + /// fediverse_trust_mode=open_with_blocklist, empty blocklist) so
314 + /// `FediverseTrustService::assert_domain_allowed` answers Ok for
315 + /// any well-formed actor URL.
316 + fn permissive_trust_mock(mock: MockDatabase) -> MockDatabase {
317 + use crate::models::{app_settings, fediverse_blocked_domain};
318 + let now = Utc::now();
319 + mock.append_query_results([vec![app_settings::Model {
320 + id: Uuid::new_v4(),
321 + key: "fediverse_enabled".to_string(),
322 + value: "true".to_string(),
323 + description: None,
324 + updated_by: None,
325 + updated_at: now,
326 + }]])
327 + .append_query_results([vec![app_settings::Model {
328 + id: Uuid::new_v4(),
329 + key: "fediverse_trust_mode".to_string(),
330 + value: "open_with_blocklist".to_string(),
331 + description: None,
332 + updated_by: None,
333 + updated_at: now,
334 + }]])
335 + .append_query_results([Vec::<fediverse_blocked_domain::Model>::new()])
336 + }
337 +
264 338 fn test_auth_config() -> AuthConfig {
265 339 AuthConfig {
266 340 jwt_secret: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
@@ -474,14 +548,21 @@ mod tests {
474 548 .body(Body::from(body))
475 549 .unwrap();
476 550 // Empty remote_identities cache → persist_inbound_request rejects.
477 - let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
478 - .append_query_results([Vec::<crate::models::remote_identity::Model>::new()])
479 - .into_connection();
480 - // SAFETY: tests run sequentially within this `#[tokio::test]` ;
481 - // env-var mutation here is bounded to the request lifetime.
482 - unsafe { std::env::set_var("FEDIVERSE_DOMAIN", "a.tld") };
551 + // F8 needs permissive trust mock prepended so enforcement passes
552 + // and the BadRequest assertion catches the persist_inbound_request
553 + // path, not the trust gate.
554 + //
555 + // We deliberately do NOT mutate FEDIVERSE_DOMAIN here: race
556 + // condition risk with concurrent build_*_payload tests that read
557 + // env via FediverseConfig::from_env(). Whatever value is in env
558 + // (or none), the assertion still holds: either the target domain
559 + // mismatches and persist_inbound_request returns BadRequest, or
560 + // the remote_identities lookup misses and also returns BadRequest.
561 + let db: DatabaseConnection =
562 + permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres))
563 + .append_query_results([Vec::<crate::models::remote_identity::Model>::new()])
564 + .into_connection();
483 565 let resp = build_router(db).oneshot(req).await.unwrap();
484 - unsafe { std::env::remove_var("FEDIVERSE_DOMAIN") };
485 566 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
486 567 }
487 568
M crates/rustwarden-core/src/fediverse/error.rs
+8 -0
@@ -46,6 +46,13 @@ pub enum FediverseError {
46 46 #[error("activity type not implemented: {0}")]
47 47 Unsupported(String),
48 48
49 + /// F8 (RFC 0027 / `experimental-fediverse`): the source domain is
50 + /// not allowed by this server's trust mode (allowlist miss or
51 + /// blocklist hit). 403 is the correct status code: the signature
52 + /// itself is valid (would be 401 otherwise), policy refuses.
53 + #[error("forbidden: {0}")]
54 + Forbidden(String),
55 +
49 56 /// Wraps a generic application error from [`AppError`].
50 57 #[error(transparent)]
51 58 App(#[from] AppError),
@@ -61,6 +68,7 @@ impl IntoResponse for FediverseError {
61 68 Self::NotAcceptable => StatusCode::NOT_ACCEPTABLE.into_response(),
62 69 Self::Unauthorized(_) => StatusCode::UNAUTHORIZED.into_response(),
63 70 Self::Unsupported(_) => StatusCode::NOT_IMPLEMENTED.into_response(),
71 + Self::Forbidden(_) => StatusCode::FORBIDDEN.into_response(),
64 72 Self::App(err) => err.into_response(),
65 73 }
66 74 }
M crates/rustwarden-core/src/fediverse/inbox.rs
+52 -8
@@ -69,6 +69,18 @@ pub async fn handle(
69 69 // (its absence already errors above) — the value is also reachable
70 70 // via `headers["signature"]` for impls.
71 71 let _ = signature_header;
72 +
73 + // F8 / RFC 0027 — trust policy gate. Reject before any verifier work
74 + // or downstream effect when the source domain isn't allowed.
75 + let source_domain = url::Url::parse(actor_ap_id)
76 + .ok()
77 + .and_then(|u| u.host_str().map(|s| s.to_ascii_lowercase()))
78 + .ok_or_else(|| FediverseError::BadRequest("actor URL has no host".into()))?;
79 + crate::services::fediverse_trust_service::FediverseTrustService::new(db.clone())
80 + .assert_domain_allowed(&source_domain)
81 + .await
82 + .map_err(|e| FediverseError::Forbidden(e.to_string()))?;
83 +
72 84 state
73 85 .verifier
74 86 .verify(&VerifyCtx {
@@ -234,6 +246,32 @@ mod tests {
234 246 }
235 247 }
236 248
249 + /// F8 enforcement helper: seed three mock results so that
250 + /// `FediverseTrustService::assert_domain_allowed` answers Ok with
251 + /// no real DB. Mode is `open_with_blocklist` + empty blocklist, the
252 + /// cheapest permissive shape for unit tests.
253 + fn permissive_trust_mock(mock: MockDatabase) -> MockDatabase {
254 + use crate::models::{app_settings, fediverse_blocked_domain};
255 + let now = chrono::Utc::now();
256 + mock.append_query_results([vec![app_settings::Model {
257 + id: Uuid::new_v4(),
258 + key: "fediverse_enabled".to_string(),
259 + value: "true".to_string(),
260 + description: None,
261 + updated_by: None,
262 + updated_at: now,
263 + }]])
264 + .append_query_results([vec![app_settings::Model {
265 + id: Uuid::new_v4(),
266 + key: "fediverse_trust_mode".to_string(),
267 + value: "open_with_blocklist".to_string(),
268 + description: None,
269 + updated_by: None,
270 + updated_at: now,
271 + }]])
272 + .append_query_results([Vec::<fediverse_blocked_domain::Model>::new()])
273 + }
274 +
237 275 fn db_for_update() -> DatabaseConnection {
238 276 let now = chrono::Utc::now();
239 277 let row = crate::models::remote_identity::Model {
@@ -256,7 +294,7 @@ mod tests {
256 294 };
257 295 let mut after = row.clone();
258 296 after.display_name = Some("Alice".into());
259 - MockDatabase::new(DatabaseBackend::Postgres)
297 + permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres))
260 298 .append_query_results([vec![row]])
261 299 .append_query_results([vec![after]])
262 300 .append_exec_results([MockExecResult {
@@ -268,13 +306,13 @@ mod tests {
268 306
269 307 fn db_for_delete_noop() -> DatabaseConnection {
270 308 // SELECT returns empty → DeleteOutcome::default(), no UPDATE.
271 - MockDatabase::new(DatabaseBackend::Postgres)
309 + permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres))
272 310 .append_query_results([Vec::<crate::models::remote_identity::Model>::new()])
273 311 .into_connection()
274 312 }
275 313
276 314 fn db_for_revoke_noop() -> DatabaseConnection {
277 - MockDatabase::new(DatabaseBackend::Postgres)
315 + permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres))
278 316 .append_exec_results([MockExecResult {
279 317 last_insert_id: 0,
280 318 rows_affected: 0,
@@ -349,10 +387,13 @@ mod tests {
349 387
350 388 #[tokio::test]
351 389 async fn inbox_returns_401_when_verifier_rejects() {
352 - let body = serde_json::json!({"type": "Update", "actor": "x"}).to_string();
390 + let body = serde_json::json!({
391 + "type": "Update", "actor": "https://a.tld/users/alice"
392 + })
393 + .to_string();
353 394 let router = make_router(
354 395 Arc::new(AlwaysReject),
355 - MockDatabase::new(DatabaseBackend::Postgres).into_connection(),
396 + permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)).into_connection(),
356 397 );
357 398 let resp = router.oneshot(signed_request(&body)).await.unwrap();
358 399 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
@@ -370,10 +411,13 @@ mod tests {
370 411
371 412 #[tokio::test]
372 413 async fn inbox_returns_501_for_unknown_activity_type() {
373 - let body = serde_json::json!({"type": "Like", "actor": "x"}).to_string();
414 + let body = serde_json::json!({
415 + "type": "Like", "actor": "https://a.tld/users/alice"
416 + })
417 + .to_string();
374 418 let router = make_router(
375 419 Arc::new(AlwaysAccept),
376 - MockDatabase::new(DatabaseBackend::Postgres).into_connection(),
420 + permissive_trust_mock(MockDatabase::new(DatabaseBackend::Postgres)).into_connection(),
377 421 );
378 422 let resp = router.oneshot(signed_request(&body)).await.unwrap();
379 423 assert_eq!(resp.status(), StatusCode::NOT_IMPLEMENTED);
@@ -388,7 +432,7 @@ mod tests {
388 432 "object": "https://a.tld/users/ghost"
389 433 })
390 434 .to_string();
391 - let router = make_router(Arc::new(AlwaysAcceptVerifier), db_for_delete_noop());
435 + let router = make_router(Arc::new(AlwaysAcceptVerifier::new()), db_for_delete_noop());
392 436 let resp = router.oneshot(signed_request(&body)).await.unwrap();
393 437 assert_eq!(resp.status(), StatusCode::ACCEPTED);
394 438 }
M crates/rustwarden-core/src/fediverse/invite_routes.rs
+10 -1
@@ -108,12 +108,21 @@ pub struct IdpListQuery {
108 108 async fn invite(
109 109 Extension(state): Extension<InviteRoutesState>,
110 110 Extension(db): Extension<DatabaseConnection>,
111 + claims: Option<Extension<crate::auth::jwt::Claims>>,
111 112 Path(team_id): Path<Uuid>,
112 113 Json(body): Json<InviteBody>,
113 114 ) -> Result<(StatusCode, Json<InviteResponse>), FediverseError> {
114 115 let role = TeamRole::parse(&body.role)
115 116 .ok_or_else(|| FediverseError::BadRequest(format!("invalid role: {}", body.role)))?;
116 - let inviter = (state.system_actor_user_id)();
117 + // Prefer the authenticated admin's id (injected by AuthService for
118 + // `/api/v1/teams/*` since iter 4 hygiene). Fall back to the
119 + // configured placeholder only when no JWT was present — that path
120 + // remains for unit tests with `MockDatabase` and seed-style flows
121 + // that don't go through the auth middleware.
122 + let inviter = match claims {
123 + Some(Extension(c)) => c.sub,
124 + None => (state.system_actor_user_id)(),
125 + };
117 126 let model = FediverseInviteService::invite_remote(
118 127 &db,
119 128 team_id,
M crates/rustwarden-core/src/fediverse/routes.rs
+6 -3
@@ -28,8 +28,11 @@ use super::webfinger;
28 28 /// Build the federation router with the default SeaORM-backed resolver
29 29 /// and the test-only [`AlwaysAcceptVerifier`].
30 30 pub fn router(config: FediverseConfig, db: DatabaseConnection) -> Router<DatabaseConnection> {
31 - let resolver: Arc<dyn LocalUserResolver> = Arc::new(SeaOrmLocalUserResolver::new(db));
32 - let verifier: Arc<dyn SignatureVerifier> = Arc::new(AlwaysAcceptVerifier);
31 + let resolver: Arc<dyn LocalUserResolver> = Arc::new(SeaOrmLocalUserResolver::new(db.clone()));
32 + // Debug/dev verifier with cache-population side-effect so the
33 + // testbed (FEDIVERSE_DEBUG_LOCAL_FEDERATION=true) doesn't need to
34 + // manually pre-seed `remote_identities`.
35 + let verifier: Arc<dyn SignatureVerifier> = Arc::new(AlwaysAcceptVerifier::with_resolver(db));
33 36 router_with(config, resolver, verifier)
34 37 }
35 38
@@ -129,6 +132,6 @@ pub fn router_with_resolver(
129 132 config: FediverseConfig,
130 133 resolver: Arc<dyn LocalUserResolver>,
131 134 ) -> Router<DatabaseConnection> {
132 - let verifier: Arc<dyn SignatureVerifier> = Arc::new(AlwaysAcceptVerifier);
135 + let verifier: Arc<dyn SignatureVerifier> = Arc::new(AlwaysAcceptVerifier::new());
133 136 router_with(config, resolver, verifier)
134 137 }
M crates/rustwarden-core/src/middleware/auth_service.rs
+48 -0
@@ -73,6 +73,17 @@ where
73 73 "/fediverse/consent",
74 74 "/fediverse/invite",
75 75 ];
76 + // NOTE on `/api/v1/teams/*` : *not* protected here on purpose.
77 + // The canonical admin path is the UI handler
78 + // `crates/rustwarden-ui/src/handlers/admin_fediverse.rs::create_team_invite`
79 + // which is protected via `/admin/*` and passes `claims.sub` as
80 + // inviter directly. The `/api/v1/teams/*` route remains a
81 + // back-door consumed by E2E tests + future API integrators ;
82 + // its `invite()` handler accepts an `Option<Extension<Claims>>`
83 + // and falls back to `system_actor_user_id()` when no JWT is
84 + // present. A future RFC will gate this route once external
85 + // consumers are wired through a per-route auth extractor
86 + // independent of the UI middleware.
76 87 let is_protected = protected_routes.iter().any(|route| path.starts_with(route));
77 88
78 89 if is_protected {
@@ -147,6 +158,43 @@ where
147 158 }
148 159 };
149 160
161 + // F10 (iter 4 / S4) — remote JWTs (`kind=remote`) must be
162 + // rejected when their backing `remote_identities` row was
163 + // disabled (kill_domain, F9 auto-kill, or manual revoke).
164 + // Feature-gated because `remote_identity` model only
165 + // exists when `experimental-fediverse` is on; without the
166 + // feature, remote JWTs simply can't be minted, so the
167 + // check is unreachable.
168 + #[cfg(feature = "experimental-fediverse")]
169 + if claims.is_remote() {
170 + if let Some(rid) = claims.remote_identity_id() {
171 + use crate::models::remote_identity;
172 + use sea_orm::EntityTrait;
173 + match remote_identity::Entity::find_by_id(rid).one(&db).await {
174 + Ok(Some(ri)) => {
175 + if ri.disabled_at.is_some() {
176 + tracing::warn!(
177 + "AuthService: remote_identity {} is disabled, rejecting JWT",
178 + rid
179 + );
180 + let redirect_url = sanitize_redirect(uri.path());
181 + return Ok(
182 + axum::response::Redirect::to(&redirect_url).into_response()
183 + );
184 + }
185 + }
186 + Ok(None) => tracing::warn!(
187 + "AuthService: F10 — remote_identity {} not found",
188 + rid
189 + ),
190 + Err(e) => tracing::warn!(
191 + "AuthService: F10 — remote_identity lookup error: {}",
192 + e
193 + ),
194 + }
195 + }
196 + }
197 +
150 198 tracing::debug!("AuthService: User {} accessing: {}", claims.sub, uri);
151 199
152 200 {
M crates/rustwarden-core/src/services/fediverse_auth_service.rs
+171 -0
@@ -284,6 +284,105 @@ impl FediverseAuthService {
284 284 Ok(active.insert(db).await?)
285 285 }
286 286
287 + /// Step 1ter (RFC 0025 — back-channel) : called by the SP-side
288 + /// `/fediverse/auth/inbox` when the IdP delivers an
289 + /// `Accept(AuthRequest)` or `Reject(AuthRequest)`. The activity's
290 + /// `object` IRI references B's original AuthRequest; the nonce is
291 + /// the last URL segment.
292 + ///
293 + /// Security: the approver's domain (`actor`'s host) must match the
294 + /// `domain` recorded on the local challenge when it was initiated.
295 + /// Without that check, `evil.tld` could fabricate an `Accept` for a
296 + /// nonce it observed and trick B into approving it.
297 + ///
298 + /// Idempotent: replaying the same decision on a challenge that's
299 + /// already left `Pending` returns `Ok(())` without DB write.
300 + pub async fn record_inbound_decision(
301 + db: &DatabaseConnection,
302 + body: &serde_json::Value,
303 + ) -> Result<(), AppError> {
304 + let activity_type = body
305 + .get("type")
306 + .and_then(|v| v.as_str())
307 + .ok_or_else(|| AppError::Validation("activity missing `type`".into()))?
308 + .to_string();
309 +
310 + let approver_actor_url = body
311 + .get("actor")
312 + .and_then(|v| v.as_str())
313 + .ok_or_else(|| AppError::Validation("activity missing `actor`".into()))?
314 + .to_string();
315 +
316 + let nonce = extract_nonce_from_decision_object(body)?;
317 +
318 + use crate::models::remote_identity;
319 + use sea_orm::{ColumnTrait, QueryFilter};
320 +
321 + let challenge = fediverse_auth_challenge::Entity::find()
322 + .filter(fediverse_auth_challenge::Column::Nonce.eq(&nonce))
323 + .one(db)
324 + .await?
325 + .ok_or_else(|| AppError::NotFound(format!("unknown nonce {nonce}")))?;
326 +
327 + // Idempotence on terminal states.
328 + if challenge.status != ChallengeStatus::Pending.as_str() {
329 + return Ok(());
330 + }
331 + if challenge.expires_at < Utc::now() {
332 + return Err(AppError::Validation(format!(
333 + "challenge {} expired before decision arrived",
334 + challenge.id
335 + )));
336 + }
337 +
338 + // Security: the approver must live on the IdP that this
339 + // challenge originally targeted.
340 + let approver_domain = url::Url::parse(&approver_actor_url)
341 + .ok()
342 + .and_then(|u| u.host_str().map(|s| s.to_ascii_lowercase()))
343 + .ok_or_else(|| {
344 + AppError::Validation(format!(
345 + "approver actor URL has no host: {approver_actor_url}"
346 + ))
347 + })?;
348 + if approver_domain != challenge.domain.to_ascii_lowercase() {
349 + return Err(AppError::Validation(format!(
350 + "approver domain `{approver_domain}` does not match challenge IdP `{}`",
351 + challenge.domain
352 + )));
353 + }
354 +
355 + // Resolve the approver in `remote_identities`. F2's verifier
356 + // populated this row on signature verification; if it's still
357 + // absent, surface the integrity issue rather than approve blindly.
358 + let approver = remote_identity::Entity::find()
359 + .filter(remote_identity::Column::ApId.eq(&approver_actor_url))
360 + .one(db)
361 + .await?
362 + .ok_or_else(|| {
363 + AppError::Validation(format!(
364 + "approver {approver_actor_url} not in remote_identities cache; \
365 + signature verifier should have populated this row",
366 + ))
367 + })?;
368 +
369 + let new_status = match activity_type.as_str() {
370 + "Accept" => ChallengeStatus::Approved,
371 + "Reject" => ChallengeStatus::Rejected,
372 + other => {
373 + return Err(AppError::Validation(format!(
374 + "record_inbound_decision: unsupported type `{other}` (expected Accept|Reject)"
375 + )))
376 + }
377 + };
378 +
379 + let mut active: fediverse_auth_challenge::ActiveModel = challenge.into();
380 + active.status = Set(new_status.as_str().to_string());
381 + active.requested_remote_id = Set(Some(approver.id));
382 + active.update(db).await?;
383 + Ok(())
384 + }
385 +
287 386 /// Step 3 — caller polls. Promotes pending-but-expired rows to
288 387 /// `Expired` lazily so the caller observes a stable terminal state.
289 388 pub async fn status(
@@ -382,6 +481,78 @@ pub fn build_auth_request_payload(
382 481 })
383 482 }
384 483
484 +/// Build the JSON-LD body of an `Accept(AuthRequest)` or
485 +/// `Reject(AuthRequest)` activity (RFC 0025).
486 +///
487 +/// The IRI-only form of `object` would require the IdP to know B's
488 +/// AuthRequest URL — which we don't store. We use the inline-object
489 +/// form `{ type: "AuthRequest", nonce }` instead; the receiving side
490 +/// extracts the nonce via [`extract_nonce_from_decision_object`].
491 +///
492 +/// `idp_base_url` is the canonical base URL of A (the IdP, e.g.
493 +/// `http://alpha.local/`). `approver_actor_url` is the AP id of the
494 +/// local user that clicked Approve / Reject. `requester_inbox_url` is
495 +/// the inbox of B's user that initiated the AuthRequest (delivery
496 +/// target). `approve=true` emits Accept, `false` emits Reject.
497 +pub fn build_auth_decision_payload(
498 + idp_base_url: &url::Url,
499 + approver_actor_url: &str,
500 + requester_inbox_url: &str,
501 + nonce: &str,
502 + approve: bool,
503 +) -> serde_json::Value {
504 + let activity_type = if approve { "Accept" } else { "Reject" };
505 + let activity_id = format!("{}auth-decisions/{}", idp_base_url, Uuid::new_v4());
506 + serde_json::json!({
507 + "@context": [
508 + "https://www.w3.org/ns/activitystreams",
509 + {
510 + "AuthRequest": {
511 + "@id": "https://schema.rustwarden.org/auth-request/v1#AuthRequest"
512 + }
513 + }
514 + ],
515 + "type": activity_type,
516 + "id": activity_id,
517 + "actor": approver_actor_url,
518 + "object": {
519 + "type": "AuthRequest",
520 + "nonce": nonce,
521 + },
522 + "to": [ requester_inbox_url ],
523 + })
524 +}
525 +
526 +/// Extract the AuthRequest nonce from the `object` field of an inbound
527 +/// `Accept` or `Reject` activity. Supports two payload shapes:
528 +/// - **IRI form** (canonical) : `"object": "http://b.tld/auth-requests/<nonce>"`
529 +/// — the nonce is the last URL path segment.
530 +/// - **Inline-object form** (defensive fallback) : `"object": { "nonce": "<v>" }`.
531 +fn extract_nonce_from_decision_object(body: &serde_json::Value) -> Result<String, AppError> {
532 + let object = body
533 + .get("object")
534 + .ok_or_else(|| AppError::Validation("Accept/Reject missing `object`".into()))?;
535 +
536 + if let Some(iri) = object.as_str() {
537 + let nonce = iri.rsplit('/').next().unwrap_or("").trim();
538 + if nonce.is_empty() {
539 + return Err(AppError::Validation(format!(
540 + "cannot extract nonce from object IRI `{iri}`"
541 + )));
542 + }
543 + return Ok(nonce.to_string());
544 + }
545 + if let Some(inline) = object.as_object()
546 + && let Some(nonce) = inline.get("nonce").and_then(|v| v.as_str())
547 + && !nonce.is_empty()
548 + {
549 + return Ok(nonce.to_string());
550 + }
551 + Err(AppError::Validation(
552 + "cannot extract nonce from Accept/Reject `object`".into(),
553 + ))
554 +}
555 +
385 556 fn handle_domain(handle: &str) -> Result<String, AppError> {
386 557 let trimmed = handle.trim().trim_start_matches('@');
387 558 let (_, domain) = trimmed.split_once('@').ok_or_else(|| {
M crates/rustwarden-core/src/services/fediverse_outbound_service.rs
+24 -2
@@ -219,8 +219,13 @@ impl FediverseOutboundService {
219 219
220 220 // RFC 0019: structured tracing events — drive Prometheus / Loki /
221 221 // OTEL exporters without coupling rustwarden-core to a metrics
222 - // crate.
223 - match (&delivery, updated.status.as_str()) {
222 + // crate. Gated by `app_settings.fediverse_observability_enabled`
223 + // (default `false`) — opt-in to avoid log noise on instances
224 + // that pipe nothing to an observability backend.
225 + let observability =
226 + is_fediverse_observability_enabled(db).await.unwrap_or(false);
227 + if observability {
228 + match (&delivery, updated.status.as_str()) {
224 229 (Ok(()), _) => {
225 230 tracing::info!(
226 231 target: "rustwarden::fediverse::outbound",
@@ -253,12 +258,29 @@ impl FediverseOutboundService {
253 258 event = "deferred",
254 259 );
255 260 }
261 + }
256 262 }
257 263
258 264 Ok(Some(updated))
259 265 }
260 266 }
261 267
268 +/// RFC 0019 + S4 observability toggle — `app_settings.fediverse_observability_enabled`,
269 +/// default **false**. When `false`, fediverse routes/services skip the
270 +/// structured `tracing::info!` / `warn!` / `error!` emit-points used for
271 +/// metric aggregation. Cheap (1 SELECT by key) ; cached by the upstream
272 +/// `AppSettingsService` if the consumer wires its standard cache.
273 +pub async fn is_fediverse_observability_enabled(
274 + db: &DatabaseConnection,
275 +) -> Result<bool, AppError> {
276 + crate::services::app_settings_service::AppSettingsService::get_bool(
277 + db,
278 + "fediverse_observability_enabled",
279 + false,
280 + )
281 + .await
282 +}
283 +
262 284 /// Background loop that periodically drains the queue.
263 285 ///
264 286 /// Spawned by `RustwardenBuilder::with_fediverse()` (RFC 0011 hooks
M crates/rustwarden-core/src/services/fediverse_signal_service.rs
+507 -3
@@ -236,7 +236,51 @@ pub trait SignatureVerifier: Send + Sync {
236 236 ///
237 237 /// **Never use in production.** A logged warning emphasises the
238 238 /// hazard at instantiation in production builds.
239 -pub struct AlwaysAcceptVerifier;
239 +///
240 +/// When constructed with [`AlwaysAcceptVerifier::with_resolver`], the
241 +/// verifier also fetches the actor over HTTP and upserts the
242 +/// `remote_identities` row before returning `Ok(())` — this preserves
243 +/// the contract documented by [`crate::services::fediverse_auth_service::FediverseAuthService::persist_inbound_request`]
244 +/// (« the signature verifier ran upstream and already required this
245 +/// row ») so downstream services don't need to special-case the debug
246 +/// verifier.
247 +pub struct AlwaysAcceptVerifier {
248 + resolver: Option<(DatabaseConnection, reqwest::Client)>,
249 +}
250 +
251 +impl AlwaysAcceptVerifier {
252 + /// Verifier with no side-effects. Suitable for unit tests where the
253 + /// caller pre-seeds `remote_identities` themselves.
254 + pub fn new() -> Self {
255 + Self { resolver: None }
256 + }
257 +
258 + /// Verifier that, on each accepted request, fetches the actor doc
259 + /// from `ctx.actor_ap_id` over HTTP and upserts the corresponding
260 + /// `remote_identities` cache row. Best-effort: a fetch/parse failure
261 + /// is logged and the request is still accepted (since this verifier
262 + /// is « always accept » by contract).
263 + ///
264 + /// The internal HTTP client has [`ACTOR_FETCH_TIMEOUT`] applied. The
265 + /// SSRF guard inside [`fetch_and_cache_remote_actor`] is **bypassed**
266 + /// here (`allow_private_ips=true`) because this verifier is test/dev
267 + /// only and the docker-compose testbed lives on private IPs.
268 + pub fn with_resolver(db: DatabaseConnection) -> Self {
269 + let http = reqwest::Client::builder()
270 + .timeout(ACTOR_FETCH_TIMEOUT)
271 + .build()
272 + .expect("reqwest client builder must not fail");
273 + Self {
274 + resolver: Some((db, http)),
275 + }
276 + }
277 +}
278 +
279 +impl Default for AlwaysAcceptVerifier {
280 + fn default() -> Self {
281 + Self::new()
282 + }
283 +}
240 284
241 285 #[async_trait]
242 286 impl SignatureVerifier for AlwaysAcceptVerifier {
@@ -245,10 +289,227 @@ impl SignatureVerifier for AlwaysAcceptVerifier {
245 289 actor = %ctx.actor_ap_id,
246 290 "AlwaysAcceptVerifier in use — DO NOT enable in production",
247 291 );
292 + if let Some((db, http)) = &self.resolver
293 + && let Err(e) =
294 + fetch_and_cache_remote_actor(db, http, ctx.actor_ap_id, /* allow_private_ips */ true).await
295 + {
296 + // « Always accept » applies to signature verification, not
297 + // to the cache-side-effect: a failed fetch must not 401 the
298 + // caller. Log and let downstream services decide what to do
299 + // with an absent row.
300 + tracing::warn!(
301 + actor = %ctx.actor_ap_id,
302 + error = %e,
303 + "AlwaysAcceptVerifier: actor fetch+cache failed (continuing)",
304 + );
305 + }
248 306 Ok(())
249 307 }
250 308 }
251 309
310 +/// Maximum body size accepted from a remote actor fetch. Mastodon's
311 +/// canonical Person doc is ~2 KB; 1 MiB leaves room for icons in
312 +/// `attachment` and large `summary` fields while bounding DoS exposure.
313 +pub const MAX_ACTOR_BODY_BYTES: u64 = 1024 * 1024;
314 +
315 +/// Per-request timeout applied to every outgoing actor fetch. Caps
316 +/// hangs from unreachable / slow remote instances without breaking the
317 +/// inbox handler that called the verifier.
318 +pub const ACTOR_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
319 +
320 +/// Refuse to fetch URLs whose resolved IP is loopback, private,
321 +/// link-local, ULA, or unspecified. Prevents SSRF pivots through
322 +/// `actor_ap_id` values like `http://169.254.169.254/...` (AWS
323 +/// metadata) or `http://10.x.x.x/admin`.
324 +///
325 +/// Caller passes `allow_private_ips=true` only in test/dev contexts
326 +/// where the topology is local (e.g. the docker-compose testbed using
327 +/// `*.local` aliases resolving to 172.x.x.x).
328 +async fn ssrf_guard(url: &url::Url, allow_private_ips: bool) -> Result<(), AppError> {
329 + if allow_private_ips {
330 + return Ok(());
331 + }
332 + let host = url
333 + .host_str()
334 + .ok_or_else(|| AppError::Validation("URL has no host".into()))?;
335 + let port = url.port_or_known_default().unwrap_or(80);
336 + let addrs = tokio::net::lookup_host((host, port))
337 + .await
338 + .map_err(|e| AppError::Validation(format!("DNS resolution failed for {host}: {e}")))?;
339 + for addr in addrs {
340 + use std::net::IpAddr;
341 + let ip = addr.ip();
342 + if ip.is_loopback() || ip.is_unspecified() {
343 + return Err(AppError::Validation(format!(
344 + "refusing to fetch {host}: resolves to loopback/unspecified ({ip})"
345 + )));
346 + }
347 + match ip {
348 + IpAddr::V4(v4) => {
349 + if v4.is_private() || v4.is_link_local() {
350 + return Err(AppError::Validation(format!(
351 + "refusing to fetch {host}: resolves to private/link-local ({v4})"
352 + )));
353 + }
354 + }
355 + IpAddr::V6(v6) => {
356 + let seg0 = v6.segments()[0];
357 + // ULA fc00::/7, link-local fe80::/10
358 + if (seg0 & 0xfe00) == 0xfc00 || (seg0 & 0xffc0) == 0xfe80 {
359 + return Err(AppError::Validation(format!(
360 + "refusing to fetch {host}: resolves to ULA/link-local ({v6})"
361 + )));
362 + }
363 + }
364 + }
365 + }
366 + Ok(())
367 +}
368 +
369 +/// Fetch a remote AP `Person` actor over HTTP and upsert the matching
370 +/// `remote_identities` row. Idempotent: a re-fetch refreshes
371 +/// `public_key_pem`, `inbox_url`, `shared_inbox_url`, `display_name`,
372 +/// and `last_fetched_at`.
373 +///
374 +/// Hardening:
375 +/// - 10 s per-request timeout (via the caller's `http` client).
376 +/// - 1 MiB body cap via Content-Length sniff + post-read assertion.
377 +/// - SSRF guard rejecting private/loopback/link-local IPs unless
378 +/// `allow_private_ips=true` (only set by test/dev callers).
379 +///
380 +/// Network/parse errors return [`AppError::Validation`]; the caller
381 +/// chooses whether to surface them (production verifier) or swallow
382 +/// them (test/dev verifier).
383 +pub async fn fetch_and_cache_remote_actor(
384 + db: &DatabaseConnection,
385 + http: &reqwest::Client,
386 + actor_ap_id: &str,
387 + allow_private_ips: bool,
388 +) -> Result<remote_identity::Model, AppError> {
389 + let url = url::Url::parse(actor_ap_id)
390 + .map_err(|e| AppError::Validation(format!("actor {actor_ap_id} not a valid URL: {e}")))?;
391 +
392 + ssrf_guard(&url, allow_private_ips).await?;
393 +
394 + let response = http
395 + .get(actor_ap_id)
396 + .header("Accept", "application/activity+json")
397 + .send()
398 + .await
399 + .map_err(|e| AppError::Validation(format!("cannot fetch actor {actor_ap_id}: {e}")))?;
400 +
401 + if !response.status().is_success() {
402 + return Err(AppError::Validation(format!(
403 + "actor fetch returned {} for {actor_ap_id}",
404 + response.status()
405 + )));
406 + }
407 +
408 + // Belt: refuse early if the server advertises a body larger than
409 + // our cap. Suspenders: re-check after the body is fully buffered,
410 + // because Content-Length is optional and can lie.
411 + if let Some(len) = response.content_length()
412 + && len > MAX_ACTOR_BODY_BYTES
413 + {
414 + return Err(AppError::Validation(format!(
415 + "actor {actor_ap_id} body too large: {len} > {MAX_ACTOR_BODY_BYTES}"
416 + )));
417 + }
418 + let bytes = response
419 + .bytes()
420 + .await
421 + .map_err(|e| AppError::Validation(format!("actor {actor_ap_id} read error: {e}")))?;
422 + if (bytes.len() as u64) > MAX_ACTOR_BODY_BYTES {
423 + return Err(AppError::Validation(format!(
424 + "actor {actor_ap_id} body too large: {} > {MAX_ACTOR_BODY_BYTES}",
425 + bytes.len()
426 + )));
427 + }
428 +
429 + let body: serde_json::Value = serde_json::from_slice(&bytes)
430 + .map_err(|e| AppError::Validation(format!("actor {actor_ap_id} not valid JSON: {e}")))?;
431 +
432 + let preferred_username = body
433 + .get("preferredUsername")
434 + .and_then(|v| v.as_str())
435 + .ok_or_else(|| {
436 + AppError::Validation(format!("actor {actor_ap_id} missing preferredUsername"))
437 + })?
438 + .to_string();
439 +
440 + let domain = url
441 + .host_str()
442 + .ok_or_else(|| AppError::Validation(format!("actor {actor_ap_id} has no host")))?
443 + .to_string();
444 +
445 + let public_key_pem = body
446 + .get("publicKey")
447 + .and_then(|v| v.get("publicKeyPem"))
448 + .and_then(|v| v.as_str())
449 + .ok_or_else(|| {
450 + AppError::Validation(format!(
451 + "actor {actor_ap_id} missing publicKey.publicKeyPem"
452 + ))
453 + })?
454 + .to_string();
455 +
456 + let inbox_url = body
457 + .get("inbox")
458 + .and_then(|v| v.as_str())
459 + .ok_or_else(|| AppError::Validation(format!("actor {actor_ap_id} missing inbox")))?
460 + .to_string();
461 +
462 + let shared_inbox_url = body
463 + .get("endpoints")
464 + .and_then(|v| v.get("sharedInbox"))
465 + .and_then(|v| v.as_str())
466 + .map(|s| s.to_string());
467 +
468 + let display_name = body
469 + .get("name")
470 + .and_then(|v| v.as_str())
471 + .map(|s| s.to_string());
472 +
473 + let now = Utc::now();
474 + let existing = remote_identity::Entity::find()
475 + .filter(remote_identity::Column::ApId.eq(actor_ap_id))
476 + .one(db)
477 + .await?;
478 +
479 + let model = if let Some(row) = existing {
480 + let mut active: remote_identity::ActiveModel = row.into();
481 + active.public_key_pem = Set(public_key_pem);
482 + active.inbox_url = Set(inbox_url);
483 + active.shared_inbox_url = Set(shared_inbox_url);
484 + active.display_name = Set(display_name);
485 + active.raw_json = Set(Some(body));
486 + active.last_fetched_at = Set(now);
487 + active.updated_at = Set(now);
488 + active.update(db).await?
489 + } else {
490 + let active = remote_identity::ActiveModel {
491 + id: Set(Uuid::new_v4()),
492 + ap_id: Set(actor_ap_id.to_string()),
493 + username: Set(preferred_username),
494 + domain: Set(domain),
495 + display_name: Set(display_name),
496 + summary_html: Set(None),
497 + inbox_url: Set(inbox_url),
498 + shared_inbox_url: Set(shared_inbox_url),
499 + public_key_pem: Set(public_key_pem),
500 + icon_url: Set(None),
501 + actor_type: Set("Person".to_string()),
502 + raw_json: Set(Some(body)),
503 + last_fetched_at: Set(now),
504 + disabled_at: Set(None),
505 + created_at: Set(now),
506 + updated_at: Set(now),
507 + };
508 + active.insert(db).await?
509 + };
510 + Ok(model)
511 +}
512 +
252 513 fn parse_handle_from_ap_id(ap_id: &str) -> Option<(String, String)> {
253 514 let url = url::Url::parse(ap_id).ok()?;
254 515 let host = url.host_str()?.to_string();
@@ -460,7 +721,7 @@ mod tests {
460 721 #[tokio::test]
461 722 async fn always_accept_verifier_accepts_any_input() {
462 723 let headers = axum::http::HeaderMap::new();
463 - AlwaysAcceptVerifier
724 + AlwaysAcceptVerifier::new()
464 725 .verify(&dummy_ctx(&headers))
465 726 .await
466 727 .expect("AlwaysAcceptVerifier never rejects");
@@ -468,7 +729,7 @@ mod tests {
468 729
469 730 #[tokio::test]
470 731 async fn signature_verifier_trait_object_works() {
471 - let verifier: Box<dyn SignatureVerifier> = Box::new(AlwaysAcceptVerifier);
732 + let verifier: Box<dyn SignatureVerifier> = Box::new(AlwaysAcceptVerifier::new());
472 733 let headers = axum::http::HeaderMap::new();
473 734 verifier
474 735 .verify(&dummy_ctx(&headers))
@@ -520,4 +781,247 @@ mod tests {
520 781 assert!(v.is_empty());
521 782 }
522 783
784 + // ─── fetch_and_cache_remote_actor (F2 hardening) ────────────────
785 +
786 + /// Build a syntactically valid AP Person doc with the given overrides.
787 + /// `extra` is merged at the top level — pass `null` for « no extra ».
788 + fn ap_person_json(
789 + actor_url: &str,
790 + username: &str,
791 + with_public_key: bool,
792 + with_inbox: bool,
793 + ) -> serde_json::Value {
794 + let mut doc = serde_json::json!({
795 + "@context": ["https://www.w3.org/ns/activitystreams"],
796 + "type": "Person",
797 + "id": actor_url,
798 + "preferredUsername": username,
799 + "name": username,
800 + });
801 + if with_public_key {
802 + doc["publicKey"] = serde_json::json!({
803 + "id": format!("{actor_url}#main-key"),
804 + "owner": actor_url,
805 + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nFAKE\n-----END PUBLIC KEY-----\n",
806 + });
807 + }
808 + if with_inbox {
809 + doc["inbox"] = serde_json::json!(format!("{actor_url}/inbox"));
810 + }
811 + doc
812 + }
813 +
814 + fn testbed_http_client() -> reqwest::Client {
815 + reqwest::Client::builder()
816 + .timeout(std::time::Duration::from_secs(2))
817 + .build()
818 + .unwrap()
819 + }
820 +
821 + #[tokio::test]
822 + async fn fetch_inserts_new_remote_identity_on_first_sight() {
823 + use wiremock::matchers::{method, path};
824 + use wiremock::{Mock, MockServer, ResponseTemplate};
825 +
826 + let server = MockServer::start().await;
827 + let actor_url = format!("{}/users/alice", server.uri());
828 + Mock::given(method("GET"))
829 + .and(path("/users/alice"))
830 + .respond_with(
831 + ResponseTemplate::new(200)
832 + .set_body_json(ap_person_json(&actor_url, "alice", true, true)),
833 + )
834 + .mount(&server)
835 + .await;
836 +
837 + let inserted = fresh_remote(Uuid::new_v4(), &actor_url);
838 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
839 + .append_query_results([Vec::<remote_identity::Model>::new()]) // SELECT empty
840 + .append_query_results([vec![inserted.clone()]]) // INSERT RETURNING
841 + .append_exec_results([MockExecResult {
842 + last_insert_id: 0,
843 + rows_affected: 1,
844 + }])
845 + .into_connection();
846 +
847 + let model = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true)
848 + .await
849 + .expect("happy path INSERT must succeed");
850 + assert_eq!(model.ap_id, inserted.ap_id);
851 + }
852 +
853 + #[tokio::test]
854 + async fn fetch_updates_existing_remote_identity_on_refetch() {
855 + use wiremock::matchers::{method, path};
856 + use wiremock::{Mock, MockServer, ResponseTemplate};
857 +
858 + let server = MockServer::start().await;
859 + let actor_url = format!("{}/users/bob", server.uri());
860 + Mock::given(method("GET"))
861 + .and(path("/users/bob"))
862 + .respond_with(
863 + ResponseTemplate::new(200)
864 + .set_body_json(ap_person_json(&actor_url, "bob", true, true)),
865 + )
866 + .mount(&server)
867 + .await;
868 +
869 + let existing = fresh_remote(Uuid::new_v4(), &actor_url);
870 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
871 + .append_query_results([vec![existing.clone()]]) // SELECT hit
872 + .append_query_results([vec![existing.clone()]]) // UPDATE RETURNING
873 + .append_exec_results([MockExecResult {
874 + last_insert_id: 0,
875 + rows_affected: 1,
876 + }])
877 + .into_connection();
878 +
879 + fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true)
880 + .await
881 + .expect("happy path UPDATE must succeed");
882 + }
883 +
884 + #[tokio::test]
885 + async fn fetch_returns_error_on_http_404() {
886 + use wiremock::matchers::{method, path};
887 + use wiremock::{Mock, MockServer, ResponseTemplate};
888 +
889 + let server = MockServer::start().await;
890 + let actor_url = format!("{}/users/ghost", server.uri());
891 + Mock::given(method("GET"))
892 + .and(path("/users/ghost"))
893 + .respond_with(ResponseTemplate::new(404))
894 + .mount(&server)
895 + .await;
896 +
897 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
898 + let err = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true)
899 + .await
900 + .expect_err("404 must be surfaced as Validation");
901 + assert!(format!("{err}").contains("404"), "got: {err}");
902 + }
903 +
904 + #[tokio::test]
905 + async fn fetch_returns_error_on_malformed_json() {
906 + use wiremock::matchers::{method, path};
907 + use wiremock::{Mock, MockServer, ResponseTemplate};
908 +
909 + let server = MockServer::start().await;
910 + let actor_url = format!("{}/users/junk", server.uri());
911 + Mock::given(method("GET"))
912 + .and(path("/users/junk"))
913 + .respond_with(ResponseTemplate::new(200).set_body_string("not-json-at-all"))
914 + .mount(&server)
915 + .await;
916 +
917 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
918 + let err = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true)
919 + .await
920 + .expect_err("malformed JSON must be rejected");
921 + assert!(format!("{err}").contains("not valid JSON"), "got: {err}");
922 + }
923 +
924 + #[tokio::test]
925 + async fn fetch_returns_error_when_public_key_missing() {
926 + use wiremock::matchers::{method, path};
927 + use wiremock::{Mock, MockServer, ResponseTemplate};
928 +
929 + let server = MockServer::start().await;
930 + let actor_url = format!("{}/users/nokey", server.uri());
931 + Mock::given(method("GET"))
932 + .and(path("/users/nokey"))
933 + .respond_with(
934 + ResponseTemplate::new(200)
935 + .set_body_json(ap_person_json(&actor_url, "nokey", false, true)),
936 + )
937 + .mount(&server)
938 + .await;
939 +
940 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
941 + let err = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true)
942 + .await
943 + .expect_err("missing publicKey must be rejected");
944 + assert!(format!("{err}").contains("publicKey"), "got: {err}");
945 + }
946 +
947 + #[tokio::test]
948 + async fn fetch_returns_error_when_inbox_missing() {
949 + use wiremock::matchers::{method, path};
950 + use wiremock::{Mock, MockServer, ResponseTemplate};
951 +
952 + let server = MockServer::start().await;
953 + let actor_url = format!("{}/users/noinbox", server.uri());
954 + Mock::given(method("GET"))
955 + .and(path("/users/noinbox"))
956 + .respond_with(
957 + ResponseTemplate::new(200)
958 + .set_body_json(ap_person_json(&actor_url, "noinbox", true, false)),
959 + )
960 + .mount(&server)
961 + .await;
962 +
963 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
964 + let err = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true)
965 + .await
966 + .expect_err("missing inbox must be rejected");
967 + assert!(format!("{err}").contains("inbox"), "got: {err}");
968 + }
969 +
970 + #[tokio::test]
971 + async fn fetch_returns_error_on_invalid_url() {
972 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
973 + let err =
974 + fetch_and_cache_remote_actor(&db, &testbed_http_client(), "not a valid url", true)
975 + .await
976 + .expect_err("malformed URL must be rejected before HTTP");
977 + assert!(format!("{err}").contains("not a valid URL"), "got: {err}");
978 + }
979 +
980 + #[tokio::test]
981 + async fn fetch_returns_error_when_body_exceeds_cap() {
982 + use wiremock::matchers::{method, path};
983 + use wiremock::{Mock, MockServer, ResponseTemplate};
984 +
985 + let server = MockServer::start().await;
986 + let actor_url = format!("{}/users/huge", server.uri());
987 + // Send a payload just over our cap. wiremock sets a truthful
988 + // Content-Length, so the pre-flight check trips before we
989 + // allocate the full body in our process.
990 + let payload = vec![b'x'; (MAX_ACTOR_BODY_BYTES as usize) + 1];
991 + Mock::given(method("GET"))
992 + .and(path("/users/huge"))
993 + .respond_with(ResponseTemplate::new(200).set_body_bytes(payload))
994 + .mount(&server)
995 + .await;
996 +
997 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
998 + let err = fetch_and_cache_remote_actor(&db, &testbed_http_client(), &actor_url, true)
999 + .await
1000 + .expect_err("oversized body must be rejected");
1001 + assert!(format!("{err}").contains("too large"), "got: {err}");
1002 + }
1003 +
1004 + #[tokio::test]
1005 + async fn ssrf_guard_rejects_loopback_when_strict() {
1006 + // Use a known-loopback URL (wiremock binds 127.0.0.1, so the
1007 + // server starts but the guard must reject before HTTP fires).
1008 + use wiremock::MockServer;
1009 + let server = MockServer::start().await;
1010 + let actor_url = format!("{}/users/anything", server.uri());
1011 +
1012 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
1013 + let err = fetch_and_cache_remote_actor(
1014 + &db,
1015 + &testbed_http_client(),
1016 + &actor_url,
1017 + /* allow_private_ips */ false,
1018 + )
1019 + .await
1020 + .expect_err("loopback must be refused when allow_private_ips=false");
1021 + let msg = format!("{err}");
1022 + assert!(
1023 + msg.contains("loopback") || msg.contains("private") || msg.contains("link-local"),
1024 + "expected SSRF guard rejection, got: {err}"
1025 + );
1026 + }
523 1027 }
M crates/rustwarden-core/src/services/fediverse_trust_service.rs
+19 -0
@@ -151,6 +151,25 @@ impl FediverseTrustService {
151 151 }
152 152 }
153 153
154 + /// F8 / RFC 0027 enforcement helper. Returns `Ok(())` when the
155 + /// origin domain of an inbound activity is allowed; otherwise
156 + /// returns [`AppError::Validation`] with a message safe to surface
157 + /// (no leakage of internal state beyond the domain name).
158 + ///
159 + /// Intended call sites: top of every protocolar inbox / route that
160 + /// accepts data from outside. Cheap (2 SELECTs at most) — caches
161 + /// can be added later if needed.
162 + pub async fn assert_domain_allowed(&self, domain: &str) -> Result<(), AppError> {
163 + if self.is_domain_allowed(domain).await? {
164 + Ok(())
165 + } else {
166 + Err(AppError::Validation(format!(
167 + "domain `{}` is not allowed by this server's trust policy",
168 + normalize_domain(domain)
169 + )))
170 + }
171 + }
172 +
154 173 async fn is_in_trusted(&self, domain: &str) -> Result<bool, AppError> {
155 174 Ok(fediverse_trusted_domain::Entity::find()
156 175 .filter(fediverse_trusted_domain::Column::Domain.eq(domain))
A crates/rustwarden-core/tests/e2e_fediverse.rs
+1982 -0
@@ -0,0 +1,1982 @@
1 +//! End-to-end smoke tests against the docker-compose fediverse testbed.
2 +//!
3 +//! Run with:
4 +//! ```text
5 +//! cd tests/fediverse-testbed && ./scripts/setup.sh
6 +//! cargo test -p rustwarden-core --test e2e_fediverse \
7 +//! --features experimental-fediverse
8 +//! ```
9 +//!
10 +//! Tests skip gracefully (without failing) if the testbed is not running, so
11 +//! they can stay in the default test suite without breaking developers who
12 +//! haven't booted the testbed.
13 +
14 +#![cfg(feature = "experimental-fediverse")]
15 +
16 +use base64::Engine;
17 +use reqwest::{Client, StatusCode};
18 +use rustwarden_core::fediverse::http_signatures::{build_signature_input, rsa_sha256_sign};
19 +use rustwarden_core::services::crypto_service::CryptoService;
20 +use serde_json::{json, Value};
21 +use std::process::Command;
22 +use uuid::Uuid;
23 +
24 +const ALPHA: &str = "http://localhost:3001";
25 +const BETA: &str = "http://localhost:3002";
26 +const GAMMA: &str = "http://localhost:3003";
27 +
28 +const INSTANCES: &[(&str, &str)] = &[
29 + (ALPHA, "alpha.local"),
30 + (BETA, "beta.local"),
31 + (GAMMA, "gamma.local"),
32 +];
33 +
34 +/// Probe `/health` on the three instances. Returns a configured client if
35 +/// every probe succeeds, `None` otherwise (printing a hint for the developer).
36 +async fn testbed_or_skip() -> Option<Client> {
37 + let client = Client::builder()
38 + .timeout(std::time::Duration::from_secs(5))
39 + .build()
40 + .expect("reqwest client builder must not fail");
41 +
42 + for (base, _) in INSTANCES {
43 + match client.get(format!("{base}/health")).send().await {
44 + Ok(r) if r.status() == StatusCode::OK => continue,
45 + other => {
46 + eprintln!(
47 + "[e2e_fediverse] testbed unreachable at {base}: {other:?}\n\
48 + [e2e_fediverse] start with: tests/fediverse-testbed/scripts/setup.sh\n\
49 + [e2e_fediverse] skipping E2E test."
50 + );
51 + return None;
52 + }
53 + }
54 + }
55 + Some(client)
56 +}
57 +
58 +#[tokio::test]
59 +async fn webfinger_resolves_local_user_on_each_instance() {
60 + let Some(client) = testbed_or_skip().await else {
61 + return;
62 + };
63 + for (base, domain) in INSTANCES {
64 + let response = client
65 + .get(format!("{base}/.well-known/webfinger"))
66 + .query(&[("resource", format!("acct:joanie@{domain}"))])
67 + .send()
68 + .await
69 + .expect("request must succeed");
70 + assert_eq!(response.status(), StatusCode::OK, "{base}");
71 + let jrd: Value = response.json().await.expect("body must be JSON");
72 + assert_eq!(jrd["subject"], format!("acct:joanie@{domain}"));
73 + let links = jrd["links"].as_array().expect("links must be array");
74 + let has_self_ap = links.iter().any(|l| {
75 + l["rel"] == "self" && l["type"] == "application/activity+json"
76 + });
77 + assert!(has_self_ap, "JRD on {base} must expose a self link with AP type");
78 + }
79 +}
80 +
81 +#[tokio::test]
82 +async fn webfinger_returns_404_for_foreign_domain() {
83 + let Some(client) = testbed_or_skip().await else {
84 + return;
85 + };
86 + // Alpha must not vouch for an account on beta's domain.
87 + let response = client
88 + .get(format!("{ALPHA}/.well-known/webfinger"))
89 + .query(&[("resource", "acct:joanie@beta.local")])
90 + .send()
91 + .await
92 + .expect("request must succeed");
93 + assert_eq!(response.status(), StatusCode::NOT_FOUND);
94 +}
95 +
96 +#[tokio::test]
97 +async fn webfinger_returns_4xx_for_malformed_resource() {
98 + let Some(client) = testbed_or_skip().await else {
99 + return;
100 + };
101 + let response = client
102 + .get(format!("{ALPHA}/.well-known/webfinger"))
103 + .query(&[("resource", "not-an-acct-uri")])
104 + .send()
105 + .await
106 + .expect("request must succeed");
107 + assert!(
108 + response.status().is_client_error(),
109 + "expected 4xx for malformed resource, got {}",
110 + response.status()
111 + );
112 +}
113 +
114 +#[tokio::test]
115 +async fn actor_fetch_returns_valid_person_jsonld() {
116 + let Some(client) = testbed_or_skip().await else {
117 + return;
118 + };
119 + for (base, domain) in INSTANCES {
120 + let response = client
121 + .get(format!("{base}/users/joanie"))
122 + .header("Accept", "application/activity+json")
123 + .send()
124 + .await
125 + .expect("request must succeed");
126 + assert_eq!(response.status(), StatusCode::OK, "{base}");
127 + let actor: Value = response.json().await.expect("body must be JSON");
128 + assert_eq!(actor["type"], "Person");
129 + assert_eq!(actor["preferredUsername"], "joanie");
130 + assert_eq!(actor["id"], format!("http://{domain}/users/joanie"));
131 + assert_eq!(
132 + actor["inbox"],
133 + format!("http://{domain}/users/joanie/inbox")
134 + );
135 + let key = &actor["publicKey"];
136 + assert!(
137 + key["publicKeyPem"]
138 + .as_str()
139 + .unwrap_or_default()
140 + .contains("BEGIN PUBLIC KEY"),
141 + "actor on {base} must expose a PEM publicKey"
142 + );
143 + }
144 +}
145 +
146 +#[tokio::test]
147 +async fn nodeinfo_discovery_returns_links() {
148 + let Some(client) = testbed_or_skip().await else {
149 + return;
150 + };
151 + let response = client
152 + .get(format!("{ALPHA}/.well-known/nodeinfo"))
153 + .send()
154 + .await
155 + .expect("request must succeed");
156 + assert_eq!(response.status(), StatusCode::OK);
157 + let body: Value = response.json().await.expect("body must be JSON");
158 + let links = body["links"].as_array().expect("links must be array");
159 + assert!(
160 + links.iter().any(|l| l["rel"]
161 + .as_str()
162 + .unwrap_or_default()
163 + .contains("nodeinfo")),
164 + "nodeinfo discovery must expose at least one nodeinfo link"
165 + );
166 +}
167 +
168 +#[tokio::test]
169 +async fn auth_inbox_rejects_unsigned_post() {
170 + let Some(client) = testbed_or_skip().await else {
171 + return;
172 + };
173 + // No Signature header, no Date, no Digest — must be refused.
174 + let response = client
175 + .post(format!("{ALPHA}/fediverse/auth/inbox"))
176 + .header("Content-Type", "application/activity+json")
177 + .body(r#"{"type":"AuthRequest","actor":"http://beta.local/users/joanie"}"#)
178 + .send()
179 + .await
180 + .expect("request must succeed");
181 + assert!(
182 + matches!(
183 + response.status(),
184 + StatusCode::UNAUTHORIZED | StatusCode::BAD_REQUEST
185 + ),
186 + "unsigned inbox POST must be 4xx, got {}",
187 + response.status()
188 + );
189 +}
190 +
191 +// ───────────────────────────────────────────────────────────────────────
192 +// Iteration 1b: S1 — SSO bilateral signed request, real HTTP, real crypto
193 +// ───────────────────────────────────────────────────────────────────────
194 +
195 +/// Beta's ENCRYPTION_KEY, copied from `tests/fediverse-testbed/env/beta.env`.
196 +/// Testbed-only: never reuse anywhere else.
197 +const BETA_ENCRYPTION_KEY: &str =
198 + "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2";
199 +
200 +/// Alpha's ENCRYPTION_KEY, copied from `tests/fediverse-testbed/env/alpha.env`.
201 +const ALPHA_ENCRYPTION_KEY: &str =
202 + "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1";
203 +
204 +/// Absolute path to the testbed directory (compose file lives here).
205 +const TESTBED_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../tests/fediverse-testbed");
206 +
207 +/// Run a SQL statement on a testbed instance via `docker compose exec psql`.
208 +/// Returns trimmed stdout. Panics on non-zero exit.
209 +fn psql(instance: &str, sql: &str) -> String {
210 + let output = Command::new("docker")
211 + .args([
212 + "compose",
213 + "exec",
214 + "-T",
215 + &format!("postgres-{instance}"),
216 + "psql",
217 + "-U",
218 + "rustwarden",
219 + "-d",
220 + &format!("rustwarden_{instance}"),
221 + "-tA",
222 + "-c",
223 + sql,
224 + ])
225 + .current_dir(TESTBED_DIR)
226 + .output()
227 + .expect("docker compose exec must be runnable");
228 + assert!(
229 + output.status.success(),
230 + "psql {instance} failed: {}",
231 + String::from_utf8_lossy(&output.stderr)
232 + );
233 + String::from_utf8_lossy(&output.stdout).trim().to_string()
234 +}
235 +
236 +/// Reqwest client with custom DNS so that `http://alpha.local/...` connects
237 +/// to `127.0.0.1:3001` while sending `Host: alpha.local`. Necessary because
238 +/// the signed signing string must match the Host the server actually sees.
239 +fn signing_client() -> Client {
240 + Client::builder()
241 + .resolve("alpha.local", "127.0.0.1:3001".parse().unwrap())
242 + .resolve("beta.local", "127.0.0.1:3002".parse().unwrap())
243 + .resolve("gamma.local", "127.0.0.1:3003".parse().unwrap())
244 + .timeout(std::time::Duration::from_secs(5))
245 + .build()
246 + .expect("reqwest client builder must not fail")
247 +}
248 +
249 +/// Build the three headers (Date, Host, Signature) that a signed POST to
250 +/// `target_url` (e.g. `http://alpha.local/fediverse/auth/inbox`) must carry,
251 +/// per draft-cavage with `(request-target) host date`.
252 +///
253 +/// `actor_url` is the *non-fragmented* AP actor URL of the requester; the
254 +/// `#main-key` fragment is appended automatically to match what
255 +/// [`HttpSignedDelivery::deliver`] emits.
256 +fn signed_headers(
257 + private_pem: &str,
258 + actor_url: &str,
259 + target_url: &str,
260 +) -> (String, String, String) {
261 + let target = url::Url::parse(target_url).expect("target_url must parse");
262 + let host = target.host_str().expect("target_url must have host").to_string();
263 + let path = target.path().to_string();
264 +
265 + let date = chrono::Utc::now()
266 + .format("%a, %d %b %Y %H:%M:%S GMT")
267 + .to_string();
268 + let signed = vec![
269 + "(request-target)".to_string(),
270 + "host".to_string(),
271 + "date".to_string(),
272 + ];
273 + let request_target = format!("post {path}");
274 +
275 + let host_for_input = host.clone();
276 + let date_for_input = date.clone();
277 + let input = build_signature_input(&signed, &request_target, |h| match h {
278 + "host" => Some(host_for_input.clone()),
279 + "date" => Some(date_for_input.clone()),
280 + _ => None,
281 + })
282 + .expect("build_signature_input must succeed");
283 +
284 + let sig_bytes = rsa_sha256_sign(private_pem, input.as_bytes())
285 + .expect("rsa_sha256_sign must succeed");
286 + let sig_b64 = base64::engine::general_purpose::STANDARD.encode(&sig_bytes);
287 +
288 + let sig_header = format!(
289 + r#"keyId="{actor_url}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="{sig_b64}""#
290 + );
291 + (date, host, sig_header)
292 +}
293 +
294 +/// Material the test needs to act on behalf of joanie@beta against alpha.
295 +struct JoanieBeta {
296 + private_pem: String,
297 + actor_url: String,
298 +}
299 +
300 +/// Fetch joanie@beta's actor (which lazy-generates RSA keys server-side) and
301 +/// extract her decrypted private PEM from beta's DB.
302 +async fn joanie_beta_keys(client: &Client) -> JoanieBeta {
303 + let actor: Value = client
304 + .get(format!("{BETA}/users/joanie"))
305 + .header("Accept", "application/activity+json")
306 + .send()
307 + .await
308 + .expect("actor fetch must succeed")
309 + .json()
310 + .await
311 + .expect("actor JSON must parse");
312 + let actor_url = actor["id"].as_str().unwrap().to_string();
313 + assert_eq!(actor_url, "http://beta.local/users/joanie");
314 +
315 + let encrypted = psql(
316 + "beta",
317 + "SELECT ap_private_key_pem FROM users WHERE username='joanie'",
318 + );
319 + assert!(encrypted.starts_with("enc:"));
320 +
321 + let prev = std::env::var("ENCRYPTION_KEY").ok();
322 + unsafe { std::env::set_var("ENCRYPTION_KEY", BETA_ENCRYPTION_KEY) };
323 + let private_pem = CryptoService::decrypt(&encrypted).expect("decrypt must succeed");
324 + unsafe {
325 + match prev {
326 + Some(v) => std::env::set_var("ENCRYPTION_KEY", v),
327 + None => std::env::remove_var("ENCRYPTION_KEY"),
328 + }
329 + }
330 + assert!(private_pem.contains("BEGIN PRIVATE KEY"));
331 +
332 + JoanieBeta {
333 + private_pem,
334 + actor_url,
335 + }
336 +}
337 +
338 +/// Simulate the side-effect of the real signature verifier: insert a
339 +/// `remote_identities` row on `target_instance` for the given remote actor.
340 +/// The debug `AlwaysAcceptVerifier` used in the testbed lets requests through
341 +/// without populating this cache, so the test does it explicitly.
342 +fn upsert_remote_identity(
343 + target_instance: &str,
344 + actor_url: &str,
345 + username: &str,
346 + domain: &str,
347 + inbox_url: &str,
348 + public_pem: &str,
349 +) {
350 + // Escape single quotes so the PEM body is safe inside a SQL literal.
351 + let pem_escaped = public_pem.replace('\'', "''");
352 + let sql = format!(
353 + "INSERT INTO remote_identities
354 + (id, ap_id, username, domain, inbox_url, public_key_pem,
355 + actor_type, last_fetched_at, created_at, updated_at)
356 + VALUES (gen_random_uuid(), '{actor_url}', '{username}', '{domain}',
357 + '{inbox_url}', '{pem_escaped}', 'Person', NOW(), NOW(), NOW())
358 + ON CONFLICT (ap_id) DO UPDATE SET
359 + public_key_pem = EXCLUDED.public_key_pem,
360 + last_fetched_at = NOW(),
361 + updated_at = NOW();"
362 + );
363 + psql(target_instance, &sql);
364 +}
365 +
366 +#[tokio::test]
367 +async fn s1_signed_authrequest_yields_pending_challenge_on_alpha() {
368 + let Some(_) = testbed_or_skip().await else {
369 + return;
370 + };
371 + let client = signing_client();
372 +
373 + // 1. Get joanie@beta's keys (server-side generation + DB extraction).
374 + let joanie = joanie_beta_keys(&client).await;
375 +
376 + // 2. (Finding F2 fix) — `AlwaysAcceptVerifier::with_resolver` now
377 + // populates alpha's `remote_identities` cache on first sight of the
378 + // actor as part of the inbox handling. Nothing to pre-seed here;
379 + // the test exercises that exact contract.
380 +
381 + // 3. Snapshot alpha's pending challenge count.
382 + let before: i64 = psql(
383 + "alpha",
384 + "SELECT COUNT(*) FROM fediverse_auth_challenges WHERE status='pending'",
385 + )
386 + .parse()
387 + .expect("count must parse");
388 +
389 + // 3. Build a fresh, valid AuthRequest body.
390 + let nonce = uuid::Uuid::new_v4().to_string();
391 + let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339();
392 + let payload = json!({
393 + "type": "AuthRequest",
394 + "actor": joanie.actor_url,
395 + "target": "joanie@alpha.local",
396 + "nonce": nonce,
397 + "expiresAt": expires_at,
398 + });
399 + let body_bytes = serde_json::to_vec(&payload).expect("payload must serialize");
400 +
401 + // 4. Sign + POST.
402 + let target_url = "http://alpha.local/fediverse/auth/inbox";
403 + let (date, host, sig) = signed_headers(&joanie.private_pem, &joanie.actor_url, target_url);
404 +
405 + let response = client
406 + .post(target_url)
407 + .header("Host", host)
408 + .header("Date", date)
409 + .header("Signature", sig)
410 + .header("Content-Type", "application/activity+json")
411 + .body(body_bytes)
412 + .send()
413 + .await
414 + .expect("signed POST must complete");
415 +
416 + let status = response.status();
417 + let body = response.text().await.unwrap_or_default();
418 + assert_eq!(
419 + status,
420 + StatusCode::ACCEPTED,
421 + "alpha must accept the signed AuthRequest; got {status} body={body}"
422 + );
423 +
424 + // 5. Alpha must now know about joanie@beta as a remote identity.
425 + let ri_count = psql(
426 + "alpha",
427 + "SELECT COUNT(*) FROM remote_identities WHERE ap_id='http://beta.local/users/joanie'",
428 + );
429 + assert_eq!(ri_count, "1", "alpha must have 1 remote_identity for joanie@beta");
430 +
431 + // 6. Alpha must have created a pending challenge for that nonce.
432 + let after: i64 = psql(
433 + "alpha",
434 + "SELECT COUNT(*) FROM fediverse_auth_challenges WHERE status='pending'",
435 + )
436 + .parse()
437 + .expect("count must parse");
438 + assert_eq!(
439 + after,
440 + before + 1,
441 + "alpha must have exactly one more pending challenge after the POST"
442 + );
443 +
444 + let nonce_present = psql(
445 + "alpha",
446 + &format!("SELECT COUNT(*) FROM fediverse_auth_challenges WHERE nonce='{nonce}'"),
447 + );
448 + assert_eq!(nonce_present, "1", "the inserted challenge must carry our nonce");
449 +}
450 +
451 +// ───────────────────────────────────────────────────────────────────────
452 +// Iteration 1d: F3 — full S1 round-trip via Accept(AuthRequest) (RFC 0025)
453 +// ───────────────────────────────────────────────────────────────────────
454 +
455 +/// Fetch joanie@alpha's keys (mirror of `joanie_beta_keys`). Used to
456 +/// sign `Accept(AuthRequest)` activities pretending to come from
457 +/// alpha's UI consent click.
458 +async fn joanie_alpha_keys(client: &Client) -> JoanieBeta {
459 + let actor: Value = client
460 + .get(format!("{ALPHA}/users/joanie"))
461 + .header("Accept", "application/activity+json")
462 + .send()
463 + .await
464 + .expect("alpha actor fetch must succeed")
465 + .json()
466 + .await
467 + .expect("alpha actor JSON must parse");
468 + let actor_url = actor["id"].as_str().unwrap().to_string();
469 + assert_eq!(actor_url, "http://alpha.local/users/joanie");
470 +
471 + let encrypted = psql(
472 + "alpha",
473 + "SELECT ap_private_key_pem FROM users WHERE username='joanie'",
474 + );
475 + assert!(encrypted.starts_with("enc:"));
476 +
477 + let prev = std::env::var("ENCRYPTION_KEY").ok();
478 + unsafe { std::env::set_var("ENCRYPTION_KEY", ALPHA_ENCRYPTION_KEY) };
479 + let private_pem = CryptoService::decrypt(&encrypted).expect("decrypt alpha key must succeed");
480 + unsafe {
481 + match prev {
482 + Some(v) => std::env::set_var("ENCRYPTION_KEY", v),
483 + None => std::env::remove_var("ENCRYPTION_KEY"),
484 + }
485 + }
486 + assert!(private_pem.contains("BEGIN PRIVATE KEY"));
487 + JoanieBeta {
488 + private_pem,
489 + actor_url,
490 + }
491 +}
492 +
493 +/// Initiate an AuthRequest on B for the given handle (e.g.
494 +/// `"joanie@alpha.local"`). Returns the B-local `challenge_id` and the
495 +/// `nonce` that the cross-instance protocol uses as correlation token.
496 +async fn initiate_on_beta(client: &Client, handle: &str) -> (Uuid, String) {
497 + let body: Value = client
498 + .post(format!("{BETA}/api/v1/auth/login/fediverse/initiate"))
499 + .json(&serde_json::json!({ "handle": handle }))
500 + .send()
501 + .await
502 + .expect("initiate request must complete")
503 + .json()
504 + .await
505 + .expect("initiate response must be JSON");
506 + let id = Uuid::parse_str(body["challenge_id"].as_str().unwrap()).unwrap();
507 + let nonce = body["nonce"].as_str().unwrap().to_string();
508 + (id, nonce)
509 +}
510 +
511 +/// Build, sign, and POST an `Accept(AuthRequest)` or
512 +/// `Reject(AuthRequest)` from joanie@alpha to beta's inbox. Returns the
513 +/// raw HTTP response so callers can assert on status + body.
514 +async fn send_decision_to_beta(
515 + client: &Client,
516 + alpha: &JoanieBeta,
517 + nonce: &str,
518 + approve: bool,
519 +) -> reqwest::Response {
520 + let activity_type = if approve { "Accept" } else { "Reject" };
521 + let payload = serde_json::json!({
522 + "@context": [
523 + "https://www.w3.org/ns/activitystreams",
524 + { "AuthRequest": { "@id": "https://schema.rustwarden.org/auth-request/v1#AuthRequest" } }
525 + ],
526 + "type": activity_type,
527 + "id": format!("http://alpha.local/auth-decisions/{}", uuid::Uuid::new_v4()),
528 + "actor": alpha.actor_url,
529 + "object": { "type": "AuthRequest", "nonce": nonce },
530 + "to": ["http://beta.local/users/joanie/inbox"],
531 + });
532 + let body_bytes = serde_json::to_vec(&payload).unwrap();
533 +
534 + let target_url = "http://beta.local/fediverse/auth/inbox";
535 + let (date, host, sig) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url);
536 +
537 + client
538 + .post(target_url)
539 + .header("Host", host)
540 + .header("Date", date)
541 + .header("Signature", sig)
542 + .header("Content-Type", "application/activity+json")
543 + .body(body_bytes)
544 + .send()
545 + .await
546 + .expect("decision POST must complete")
547 +}
548 +
549 +#[tokio::test]
550 +async fn s1_full_round_trip_accept_marks_b_challenge_approved() {
551 + let Some(_) = testbed_or_skip().await else {
552 + return;
553 + };
554 + let client = signing_client();
555 +
556 + // 1. B initiates: creates a Pending local challenge.
557 + let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await;
558 +
559 + // 2. Alpha keys (the approver clicking Approve on the IdP-side UI).
560 + let alpha = joanie_alpha_keys(&client).await;
561 +
562 + // 3. Send signed Accept to B's inbox.
563 + let resp = send_decision_to_beta(&client, &alpha, &nonce, /* approve */ true).await;
564 + let status = resp.status();
565 + let body = resp.text().await.unwrap_or_default();
566 + assert_eq!(
567 + status,
568 + StatusCode::ACCEPTED,
569 + "beta must 202 a valid Accept; got {status} body={body}"
570 + );
571 +
572 + // 4. B's local challenge must now be Approved.
573 + let row_status = psql(
574 + "beta",
575 + &format!("SELECT status FROM fediverse_auth_challenges WHERE id='{challenge_id}'"),
576 + );
577 + assert_eq!(row_status, "approved", "challenge status mismatch");
578 +
579 + // 5. B's consume must mint a remote JWT.
580 + let consume = client
581 + .post(format!(
582 + "{BETA}/api/v1/auth/login/fediverse/consume/{challenge_id}"
583 + ))
584 + .send()
585 + .await
586 + .expect("consume must complete");
587 + assert_eq!(consume.status(), StatusCode::OK);
588 + let consume_body: Value = consume.json().await.unwrap();
589 + assert!(consume_body["jwt"].as_str().is_some());
590 +}
591 +
592 +#[tokio::test]
593 +async fn s1_reject_propagates_and_blocks_consume() {
594 + let Some(_) = testbed_or_skip().await else {
595 + return;
596 + };
597 + let client = signing_client();
598 +
599 + let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await;
600 + let alpha = joanie_alpha_keys(&client).await;
601 +
602 + let resp = send_decision_to_beta(&client, &alpha, &nonce, /* approve */ false).await;
603 + assert_eq!(resp.status(), StatusCode::ACCEPTED);
604 +
605 + let row_status = psql(
606 + "beta",
607 + &format!("SELECT status FROM fediverse_auth_challenges WHERE id='{challenge_id}'"),
608 + );
609 + assert_eq!(row_status, "rejected");
610 +
611 + let consume = client
612 + .post(format!(
613 + "{BETA}/api/v1/auth/login/fediverse/consume/{challenge_id}"
614 + ))
615 + .send()
616 + .await
617 + .expect("consume must complete");
618 + assert!(
619 + consume.status().is_client_error(),
620 + "rejected challenge must yield 4xx on consume, got {}",
621 + consume.status()
622 + );
623 +}
624 +
625 +#[tokio::test]
626 +async fn s1_unknown_nonce_is_rejected() {
627 + let Some(_) = testbed_or_skip().await else {
628 + return;
629 + };
630 + let client = signing_client();
631 + let alpha = joanie_alpha_keys(&client).await;
632 +
633 + // Forge a fresh nonce that no B-side initiate has used.
634 + let fake_nonce = format!("never-seen-{}", uuid::Uuid::new_v4());
635 + let resp = send_decision_to_beta(&client, &alpha, &fake_nonce, true).await;
636 + let status = resp.status();
637 + assert!(
638 + status.is_client_error(),
639 + "unknown nonce must be refused, got {status}"
640 + );
641 +}
642 +
643 +#[tokio::test]
644 +async fn s1_expired_challenge_rejects_late_accept() {
645 + let Some(_) = testbed_or_skip().await else {
646 + return;
647 + };
648 + let client = signing_client();
649 +
650 + let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await;
651 + let alpha = joanie_alpha_keys(&client).await;
652 +
653 + // Manually backdate `expires_at` so the challenge looks expired.
654 + psql(
655 + "beta",
656 + &format!(
657 + "UPDATE fediverse_auth_challenges SET expires_at = NOW() - INTERVAL '1 hour' \
658 + WHERE id='{challenge_id}'"
659 + ),
660 + );
661 +
662 + let resp = send_decision_to_beta(&client, &alpha, &nonce, true).await;
663 + assert!(
664 + resp.status().is_client_error(),
665 + "expired challenge must reject Accept; got {}",
666 + resp.status()
667 + );
668 +}
669 +
670 +#[tokio::test]
671 +async fn s1_replay_accept_is_idempotent() {
672 + let Some(_) = testbed_or_skip().await else {
673 + return;
674 + };
675 + let client = signing_client();
676 +
677 + let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await;
678 + let alpha = joanie_alpha_keys(&client).await;
679 +
680 + let r1 = send_decision_to_beta(&client, &alpha, &nonce, true).await;
681 + assert_eq!(r1.status(), StatusCode::ACCEPTED);
682 +
683 + // PostgreSQL system column `xmin` carries the transaction id that
684 + // last wrote the row. If `record_inbound_decision` correctly bails
685 + // out on a non-Pending challenge, xmin must be stable.
686 + let xmin_1 = psql(
687 + "beta",
688 + &format!("SELECT xmin::text FROM fediverse_auth_challenges WHERE id='{challenge_id}'"),
689 + );
690 +
691 + let r2 = send_decision_to_beta(&client, &alpha, &nonce, true).await;
692 + assert_eq!(r2.status(), StatusCode::ACCEPTED, "replay must still 202");
693 +
694 + let xmin_2 = psql(
695 + "beta",
696 + &format!("SELECT xmin::text FROM fediverse_auth_challenges WHERE id='{challenge_id}'"),
697 + );
698 + assert_eq!(xmin_1, xmin_2, "idempotent replay must not rewrite the row");
699 +}
700 +
701 +// ───────────────────────────────────────────────────────────────────────
702 +// Iteration 2a (S2) — Cross-instance team invitation, B-side flow.
703 +//
704 +// Validates the part of S2 that is operational without the missing
705 +// AP-Invite back-channel (findings F4–F7 of the iter1 plan, hors-scope
706 +// this iteration). Specifically: bob (admin on B) creates a federated
707 +// invite for @joanie@alpha.local; joanie already has a remote_identity
708 +// row on B (populated by S1's verifier); accepting the invite via the
709 +// API attaches a team_member row bound to that remote identity.
710 +// ───────────────────────────────────────────────────────────────────────
711 +
712 +/// Run a one-shot S1 happy-path so joanie@alpha is cached in beta's
713 +/// `remote_identities` before the invite tests need her.
714 +async fn ensure_joanie_alpha_in_beta_cache(client: &Client) {
715 + let (_challenge_id, nonce) = initiate_on_beta(client, "joanie@alpha.local").await;
716 + let alpha = joanie_alpha_keys(client).await;
717 + let _ = send_decision_to_beta(client, &alpha, &nonce, true).await;
718 +}
719 +
720 +#[tokio::test]
721 +async fn s2_admin_invite_to_remote_joanie_creates_team_membership() {
722 + let Some(_) = testbed_or_skip().await else {
723 + return;
724 + };
725 + let client = signing_client();
726 +
727 + // 0. Make sure beta knows joanie@alpha (S1 contract).
728 + ensure_joanie_alpha_in_beta_cache(&client).await;
729 + let joanie_alpha_rid = psql(
730 + "beta",
731 + "SELECT id FROM remote_identities WHERE ap_id='http://alpha.local/users/joanie'",
732 + );
733 + assert!(!joanie_alpha_rid.is_empty(), "joanie@alpha must be cached");
734 +
735 + // 1. Create a team on beta. Slug must be unique across runs.
736 + let team_slug = format!(
737 + "team-s2-{}",
738 + Uuid::new_v4().to_string().chars().take(8).collect::<String>()
739 + );
740 + psql(
741 + "beta",
742 + &format!(
743 + "INSERT INTO teams (id, slug, name, owner_id) \
744 + VALUES (gen_random_uuid(), '{team_slug}', 'S2 Test Team', \
745 + (SELECT id FROM users WHERE username='joanie'))"
746 + ),
747 + );
748 + let team_id = psql(
749 + "beta",
750 + &format!("SELECT id FROM teams WHERE slug='{team_slug}'"),
751 + );
752 +
753 + // 2. Admin creates the invite via the API.
754 + let invite_resp: Value = client
755 + .post(format!(
756 + "{BETA}/api/v1/teams/{team_id}/members/invite-fediverse"
757 + ))
758 + .json(&json!({ "handle": "joanie@alpha.local", "role": "member" }))
759 + .send()
760 + .await
761 + .expect("invite POST must complete")
762 + .json()
763 + .await
764 + .expect("invite response must be JSON");
765 + let invite_id = invite_resp["invite_id"].as_str().unwrap().to_string();
766 +
767 + // 3. Accept the invite, binding to joanie@alpha's remote_identity.
768 + let accept = client
769 + .post(format!(
770 + "{BETA}/api/v1/fediverse/invites/{invite_id}/accept"
771 + ))
772 + .json(&json!({ "remote_identity_id": joanie_alpha_rid }))
773 + .send()
774 + .await
775 + .expect("accept must complete");
776 + assert_eq!(accept.status(), StatusCode::OK, "accept must 200");
777 +
778 + // 4. team_members row must exist with the remote_identity_id.
779 + let member_count = psql(
780 + "beta",
781 + &format!(
782 + "SELECT COUNT(*) FROM team_members \
783 + WHERE team_id='{team_id}' AND remote_identity_id='{joanie_alpha_rid}'"
784 + ),
785 + );
786 + assert_eq!(member_count, "1", "exactly 1 remote team_member expected");
787 +
788 + // 5. Invite status must be `accepted`.
789 + let invite_status = psql(
790 + "beta",
791 + &format!("SELECT status FROM fediverse_pending_invites WHERE id='{invite_id}'"),
792 + );
793 + assert_eq!(invite_status, "accepted");
794 +}
795 +
796 +#[tokio::test]
797 +async fn s2_invite_reject_flips_status_to_rejected_no_member() {
798 + let Some(_) = testbed_or_skip().await else {
799 + return;
800 + };
801 + let client = signing_client();
802 + ensure_joanie_alpha_in_beta_cache(&client).await;
803 +
804 + let team_slug = format!(
805 + "team-s2-rej-{}",
806 + Uuid::new_v4().to_string().chars().take(8).collect::<String>()
807 + );
808 + psql(
809 + "beta",
810 + &format!(
811 + "INSERT INTO teams (id, slug, name, owner_id) \
812 + VALUES (gen_random_uuid(), '{team_slug}', 'Rej Team', \
813 + (SELECT id FROM users WHERE username='joanie'))"
814 + ),
815 + );
816 + let team_id = psql(
817 + "beta",
818 + &format!("SELECT id FROM teams WHERE slug='{team_slug}'"),
819 + );
820 +
821 + let invite_resp: Value = client
822 + .post(format!(
823 + "{BETA}/api/v1/teams/{team_id}/members/invite-fediverse"
824 + ))
825 + .json(&json!({ "handle": "joanie@alpha.local", "role": "member" }))
826 + .send()
827 + .await
828 + .unwrap()
829 + .json()
830 + .await
831 + .unwrap();
832 + let invite_id = invite_resp["invite_id"].as_str().unwrap().to_string();
833 +
834 + let reject = client
835 + .post(format!(
836 + "{BETA}/api/v1/fediverse/invites/{invite_id}/reject"
837 + ))
838 + .send()
839 + .await
840 + .unwrap();
841 + assert_eq!(reject.status(), StatusCode::OK);
842 +
843 + let invite_status = psql(
844 + "beta",
845 + &format!("SELECT status FROM fediverse_pending_invites WHERE id='{invite_id}'"),
846 + );
847 + assert_eq!(invite_status, "rejected");
848 +
849 + let member_count = psql(
850 + "beta",
851 + &format!("SELECT COUNT(*) FROM team_members WHERE team_id='{team_id}'"),
852 + );
853 + assert_eq!(member_count, "0", "rejection must NOT add a member");
854 +}
855 +
856 +#[tokio::test]
857 +async fn s2_invite_revoke_blocks_subsequent_accept() {
858 + let Some(_) = testbed_or_skip().await else {
859 + return;
860 + };
861 + let client = signing_client();
862 + ensure_joanie_alpha_in_beta_cache(&client).await;
863 + let joanie_alpha_rid = psql(
864 + "beta",
865 + "SELECT id FROM remote_identities WHERE ap_id='http://alpha.local/users/joanie'",
866 + );
867 +
868 + let team_slug = format!(
869 + "team-s2-rev-{}",
870 + Uuid::new_v4().to_string().chars().take(8).collect::<String>()
871 + );
872 + psql(
873 + "beta",
874 + &format!(
875 + "INSERT INTO teams (id, slug, name, owner_id) \
876 + VALUES (gen_random_uuid(), '{team_slug}', 'Rev Team', \
877 + (SELECT id FROM users WHERE username='joanie'))"
878 + ),
879 + );
880 + let team_id = psql(
881 + "beta",
882 + &format!("SELECT id FROM teams WHERE slug='{team_slug}'"),
883 + );
884 +
885 + let invite_resp: Value = client
886 + .post(format!(
887 + "{BETA}/api/v1/teams/{team_id}/members/invite-fediverse"
888 + ))
889 + .json(&json!({ "handle": "joanie@alpha.local", "role": "member" }))
890 + .send()
891 + .await
892 + .unwrap()
893 + .json()
894 + .await
895 + .unwrap();
896 + let invite_id = invite_resp["invite_id"].as_str().unwrap().to_string();
897 +
898 + // Admin revokes the invite before joanie accepts.
899 + let revoke = client
900 + .post(format!(
901 + "{BETA}/api/v1/teams/{team_id}/invites/{invite_id}/revoke"
902 + ))
903 + .send()
904 + .await
905 + .unwrap();
906 + assert_eq!(revoke.status(), StatusCode::OK);
907 +
908 + // Joanie's late acceptance must now fail (state != Pending).
909 + let accept = client
910 + .post(format!(
911 + "{BETA}/api/v1/fediverse/invites/{invite_id}/accept"
912 + ))
913 + .json(&json!({ "remote_identity_id": joanie_alpha_rid }))
914 + .send()
915 + .await
916 + .unwrap();
917 + assert!(
918 + accept.status().is_client_error(),
919 + "revoked invite must reject Accept, got {}",
920 + accept.status()
921 + );
922 +}
923 +
924 +#[tokio::test]
925 +async fn s2_idp_list_returns_pending_invite_for_handle() {
926 + let Some(_) = testbed_or_skip().await else {
927 + return;
928 + };
929 + let client = signing_client();
930 + ensure_joanie_alpha_in_beta_cache(&client).await;
931 +
932 + let team_slug = format!(
933 + "team-s2-list-{}",
934 + Uuid::new_v4().to_string().chars().take(8).collect::<String>()
935 + );
936 + psql(
937 + "beta",
938 + &format!(
939 + "INSERT INTO teams (id, slug, name, owner_id) \
940 + VALUES (gen_random_uuid(), '{team_slug}', 'List Team', \
941 + (SELECT id FROM users WHERE username='joanie'))"
942 + ),
943 + );
944 + let team_id = psql(
945 + "beta",
946 + &format!("SELECT id FROM teams WHERE slug='{team_slug}'"),
947 + );
948 +
949 + let invite_resp: Value = client
950 + .post(format!(
951 + "{BETA}/api/v1/teams/{team_id}/members/invite-fediverse"
952 + ))
953 + .json(&json!({ "handle": "joanie@alpha.local", "role": "admin" }))
954 + .send()
955 + .await
956 + .unwrap()
957 + .json()
958 + .await
959 + .unwrap();
960 + let invite_id = invite_resp["invite_id"].as_str().unwrap().to_string();
961 +
962 + // IdP-side listing: beta's own /api/v1/fediverse/invites endpoint
963 + // returns invites pending for a given handle.
964 + let listed: Value = client
965 + .get(format!("{BETA}/api/v1/fediverse/invites"))
966 + .query(&[("handle", "joanie@alpha.local")])
967 + .send()
968 + .await
969 + .expect("list must complete")
970 + .json()
971 + .await
972 + .expect("list response must be JSON");
973 + let arr = listed.as_array().expect("list must be array");
974 + let found = arr.iter().any(|row| row["id"].as_str() == Some(&invite_id));
975 + assert!(found, "freshly-created invite must appear in the listing");
976 +}
977 +
978 +// ───────────────────────────────────────────────────────────────────────
979 +// Iteration 3a (S3) — Trust model & non-transitivity
980 +//
981 +// Scenario from plan parent §S3 :
982 +// A trust B uniquement
983 +// B trust A and C
984 +// C trust B uniquement
985 +// A does NOT trust C, and C is not in A's blocklist either.
986 +//
987 +// Property under test : an AP activity signed by some domain D may
988 +// only enrich A's protocol state in ways scoped to D itself — it MUST
989 +// NOT cause A to add D (or anyone else) to `fediverse_trusted_domains`
990 +// automatically. The trust list is **administrator-only** input.
991 +//
992 +// **F8 (bloquant pour promotion stable)** — the protocolar routes
993 +// (auth_routes, inbox, invite_routes) never consult
994 +// `FediverseTrustService`. Trust list enforcement is decorative
995 +// (UI-only). A hostile but signature-valid instance can today push
996 +// AP activities into another instance regardless of its trust state.
997 +// The test `s3_finding_f8_untrusted_domain_payload_accepted`
998 +// reproduces the bug objectively.
999 +// ───────────────────────────────────────────────────────────────────────
1000 +
1001 +#[tokio::test]
1002 +async fn s3_signed_protocol_flow_never_writes_trust_list() {
1003 + let Some(_) = testbed_or_skip().await else {
1004 + return;
1005 + };
1006 + let client = signing_client();
1007 +
1008 + // Snapshot beta's trust list before any protocol exchange.
1009 + let before = psql(
1010 + "beta",
1011 + "SELECT array_agg(domain ORDER BY domain) FROM fediverse_trusted_domains",
1012 + );
1013 +
1014 + // Drive a complete signed S1 flow (which exercises auth_inbox +
1015 + // F2's fetch_and_cache_remote_actor + F3's record_inbound_decision)
1016 + // and a signed S2 invite flow.
1017 + ensure_joanie_alpha_in_beta_cache(&client).await;
1018 + let team_slug = format!(
1019 + "team-s3-trust-{}",
1020 + Uuid::new_v4().to_string().chars().take(8).collect::<String>()
1021 + );
1022 + psql(
1023 + "beta",
1024 + &format!(
1025 + "INSERT INTO teams (id, slug, name, owner_id) \
1026 + VALUES (gen_random_uuid(), '{team_slug}', 'S3 Trust Team', \
1027 + (SELECT id FROM users WHERE username='joanie'))"
1028 + ),
1029 + );
1030 + let team_id = psql(
1031 + "beta",
1032 + &format!("SELECT id FROM teams WHERE slug='{team_slug}'"),
1033 + );
1034 + let _ = client
1035 + .post(format!(
1036 + "{BETA}/api/v1/teams/{team_id}/members/invite-fediverse"
1037 + ))
1038 + .json(&json!({ "handle": "joanie@alpha.local", "role": "member" }))
1039 + .send()
1040 + .await
1041 + .unwrap();
1042 +
1043 + // After all that, the trust list must be byte-identical: protocol
1044 + // exchanges do not promote anyone to trusted.
1045 + let after = psql(
1046 + "beta",
1047 + "SELECT array_agg(domain ORDER BY domain) FROM fediverse_trusted_domains",
1048 + );
1049 + assert_eq!(
1050 + before, after,
1051 + "fediverse_trusted_domains must be untouched by protocol-level activity"
1052 + );
1053 +}
1054 +
1055 +#[tokio::test]
1056 +async fn s3_untrusted_domain_is_rejected_with_403() {
1057 + // F8 fix : beta's auth_inbox now consults
1058 + // FediverseTrustService::assert_domain_allowed and refuses 403
1059 + // (FediverseError::Forbidden) when the source domain is not in
1060 + // beta's allowlist.
1061 +
1062 + let Some(_) = testbed_or_skip().await else {
1063 + return;
1064 + };
1065 + let client = signing_client();
1066 +
1067 + // 1. Remove alpha from beta's trust list.
1068 + psql(
1069 + "beta",
1070 + "DELETE FROM fediverse_trusted_domains WHERE domain='alpha.local'",
1071 + );
1072 + let trust_count = psql(
1073 + "beta",
1074 + "SELECT COUNT(*) FROM fediverse_trusted_domains WHERE domain='alpha.local'",
1075 + );
1076 + assert_eq!(trust_count, "0", "alpha must be removed from beta's trust list");
1077 +
1078 + // 2. Sign and send an AuthRequest from joanie@alpha to beta.
1079 + let alpha = joanie_alpha_keys(&client).await;
1080 + let nonce = Uuid::new_v4().to_string();
1081 + let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339();
1082 + let payload = json!({
1083 + "type": "AuthRequest",
1084 + "actor": alpha.actor_url,
1085 + "target": "joanie@beta.local",
1086 + "nonce": nonce,
1087 + "expiresAt": expires_at,
1088 + });
1089 + let body_bytes = serde_json::to_vec(&payload).unwrap();
1090 + let target_url = "http://beta.local/fediverse/auth/inbox";
1091 + let (date, host, sig) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url);
1092 +
1093 + let resp = client
1094 + .post(target_url)
1095 + .header("Host", host)
1096 + .header("Date", date)
1097 + .header("Signature", sig)
1098 + .header("Content-Type", "application/activity+json")
1099 + .body(body_bytes)
1100 + .send()
1101 + .await
1102 + .expect("POST must complete");
1103 +
1104 + let status = resp.status();
1105 + // Restore trust list FIRST to avoid poisoning other tests on failure path.
1106 + psql(
1107 + "beta",
1108 + "INSERT INTO fediverse_trusted_domains (id, domain, note, created_at) \
1109 + VALUES (gen_random_uuid(), 'alpha.local', 'restore after S3 test', NOW()) \
1110 + ON CONFLICT (domain) DO NOTHING",
1111 + );
1112 +
1113 + assert_eq!(
1114 + status,
1115 + StatusCode::FORBIDDEN,
1116 + "F8 enforcement: untrusted source must receive 403; got {status}"
1117 + );
1118 +}
1119 +
1120 +#[tokio::test]
1121 +async fn s3_trusted_domain_still_passes_protocol_inbox() {
1122 + // Positive regression for F8 : while the trust list contains alpha
1123 + // (default after setup.sh + configure-trust.sh), signed activities
1124 + // from alpha must still 202.
1125 + let Some(_) = testbed_or_skip().await else {
1126 + return;
1127 + };
1128 + let client = signing_client();
1129 +
1130 + // Confirm alpha is in beta's trust list (configure-trust.sh state).
1131 + let present = psql(
1132 + "beta",
1133 + "SELECT COUNT(*) FROM fediverse_trusted_domains WHERE domain='alpha.local'",
1134 + );
1135 + assert_eq!(present, "1", "alpha must be in beta's trust list");
1136 +
1137 + let (_challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await;
1138 + let alpha = joanie_alpha_keys(&client).await;
1139 + let resp = send_decision_to_beta(&client, &alpha, &nonce, true).await;
1140 + assert_eq!(
1141 + resp.status(),
1142 + StatusCode::ACCEPTED,
1143 + "trusted domain must continue to be accepted"
1144 + );
1145 +}
1146 +
1147 +#[tokio::test]
1148 +async fn s3_trust_list_entries_persist_independent_of_protocol_activity() {
1149 + let Some(_) = testbed_or_skip().await else {
1150 + return;
1151 + };
1152 + let client = signing_client();
1153 +
1154 + // Both pre-existing trust entries are present (configure-trust.sh
1155 + // bootstrap). Hammer the protocol with a signed flow and assert the
1156 + // exact same rows remain — not merely a count check.
1157 + ensure_joanie_alpha_in_beta_cache(&client).await;
1158 +
1159 + let beta_trust = psql(
1160 + "beta",
1161 + "SELECT string_agg(domain, ',' ORDER BY domain) FROM fediverse_trusted_domains",
1162 + );
1163 + let gamma_trust = psql(
1164 + "gamma",
1165 + "SELECT string_agg(domain, ',' ORDER BY domain) FROM fediverse_trusted_domains",
1166 + );
1167 + assert_eq!(
1168 + beta_trust, "alpha.local,gamma.local",
1169 + "beta's trust list must remain alpha+gamma — no auto-mutation"
1170 + );
1171 + assert_eq!(
1172 + gamma_trust, "alpha.local,beta.local",
1173 + "gamma's trust list must remain alpha+beta — non-transitivity preserved"
1174 + );
1175 +}
1176 +
1177 +// ───────────────────────────────────────────────────────────────────────
1178 +// Iteration 4 (S4) — Block list + révocation en masse
1179 +//
1180 +// Per plan §S4, admin must be able to "cut" a hostile instance with
1181 +// immediate effect : future signed POSTs rejected AND existing remote
1182 +// JWT sessions invalidated.
1183 +//
1184 +// Validation covers what works as-is via direct DB / API drive ; surfaces
1185 +// **F9** (add_blocked_domain doesn't auto-kill remote_identities) and
1186 +// **F10** (kill_domain doesn't blacklist JWT) as findings.
1187 +// ───────────────────────────────────────────────────────────────────────
1188 +
1189 +/// Switch beta's trust mode to `open_with_blocklist` for the duration of
1190 +/// a S4 test, returning the previous value so it can be restored.
1191 +fn set_beta_open_with_blocklist() -> Option<String> {
1192 + let previous = psql(
1193 + "beta",
1194 + "SELECT value FROM app_settings WHERE key='fediverse_trust_mode'",
1195 + );
1196 + psql(
1197 + "beta",
1198 + "INSERT INTO app_settings (id, key, value, description, updated_at) \
1199 + VALUES (gen_random_uuid(), 'fediverse_trust_mode', 'open_with_blocklist', NULL, NOW()) \
1200 + ON CONFLICT (key) DO UPDATE SET value='open_with_blocklist', updated_at=NOW()",
1201 + );
1202 + if previous.is_empty() { None } else { Some(previous) }
1203 +}
1204 +
1205 +fn restore_beta_trust_mode(previous: Option<String>) {
1206 + let value = previous.unwrap_or_else(|| "allowlist".to_string());
1207 + psql(
1208 + "beta",
1209 + &format!(
1210 + "UPDATE app_settings SET value='{value}', updated_at=NOW() \
1211 + WHERE key='fediverse_trust_mode'"
1212 + ),
1213 + );
1214 +}
1215 +
1216 +#[tokio::test]
1217 +async fn s4_blocked_domain_payload_is_rejected_with_403() {
1218 + let Some(_) = testbed_or_skip().await else {
1219 + return;
1220 + };
1221 + let client = signing_client();
1222 +
1223 + let prev = set_beta_open_with_blocklist();
1224 + // Add alpha.local to beta's blocklist.
1225 + psql(
1226 + "beta",
1227 + "INSERT INTO fediverse_blocked_domains \
1228 + (id, domain, severity, reason, added_by_user_id, created_at) \
1229 + VALUES (gen_random_uuid(), 'alpha.local', 'suspend', 's4-test', NULL, NOW()) \
1230 + ON CONFLICT (domain) DO NOTHING",
1231 + );
1232 +
1233 + let alpha = joanie_alpha_keys(&client).await;
1234 + let nonce = Uuid::new_v4().to_string();
1235 + let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339();
1236 + let payload = json!({
1237 + "type": "AuthRequest",
1238 + "actor": alpha.actor_url,
1239 + "target": "joanie@beta.local",
1240 + "nonce": nonce,
1241 + "expiresAt": expires_at,
1242 + });
1243 + let body_bytes = serde_json::to_vec(&payload).unwrap();
1244 + let target_url = "http://beta.local/fediverse/auth/inbox";
1245 + let (date, host, sig) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url);
1246 +
1247 + let resp = client
1248 + .post(target_url)
1249 + .header("Host", host)
1250 + .header("Date", date)
1251 + .header("Signature", sig)
1252 + .header("Content-Type", "application/activity+json")
1253 + .body(body_bytes)
1254 + .send()
1255 + .await
1256 + .expect("POST must complete");
1257 +
1258 + let status = resp.status();
1259 +
1260 + // Clean up state regardless of assertion outcome.
1261 + psql(
1262 + "beta",
1263 + "DELETE FROM fediverse_blocked_domains WHERE domain='alpha.local'",
1264 + );
1265 + restore_beta_trust_mode(prev);
1266 +
1267 + assert_eq!(
1268 + status,
1269 + StatusCode::FORBIDDEN,
1270 + "blocklist must produce 403 in OpenWithBlocklist mode; got {status}"
1271 + );
1272 +}
1273 +
1274 +#[tokio::test]
1275 +async fn s4_kill_domain_marks_remote_identities_disabled() {
1276 + let Some(_) = testbed_or_skip().await else {
1277 + return;
1278 + };
1279 + let client = signing_client();
1280 +
1281 + // Ensure joanie@alpha exists in beta's cache (S1 flow seeds it).
1282 + ensure_joanie_alpha_in_beta_cache(&client).await;
1283 + let active_before = psql(
1284 + "beta",
1285 + "SELECT COUNT(*) FROM remote_identities \
1286 + WHERE domain='alpha.local' AND disabled_at IS NULL",
1287 + );
1288 + assert!(
1289 + active_before.parse::<i64>().unwrap() >= 1,
1290 + "expected at least one active row to kill"
1291 + );
1292 +
1293 + // Bulk disable.
1294 + psql(
1295 + "beta",
1296 + "UPDATE remote_identities SET disabled_at = NOW(), updated_at = NOW() \
1297 + WHERE domain='alpha.local' AND disabled_at IS NULL",
1298 + );
1299 +
1300 + let active_after = psql(
1301 + "beta",
1302 + "SELECT COUNT(*) FROM remote_identities \
1303 + WHERE domain='alpha.local' AND disabled_at IS NULL",
1304 + );
1305 + assert_eq!(active_after, "0", "no active row must remain for alpha.local");
1306 +
1307 + // Restore for follow-on tests.
1308 + psql(
1309 + "beta",
1310 + "UPDATE remote_identities SET disabled_at = NULL, updated_at = NOW() \
1311 + WHERE domain='alpha.local'",
1312 + );
1313 +}
1314 +
1315 +#[tokio::test]
1316 +async fn s4_kill_domain_invalidates_active_remote_jwt() {
1317 + // F10 fix : remote JWTs are rejected at validation time when their
1318 + // backing `remote_identities` row was disabled (kill_domain, F9
1319 + // auto-kill, or revoke). No `jwt_blacklist` insertion needed — the
1320 + // disabled_at check is a cheap PK lookup in `AuthService`.
1321 +
1322 + let Some(_) = testbed_or_skip().await else {
1323 + return;
1324 + };
1325 + let client = signing_client();
1326 +
1327 + // 1. Full S1 round-trip → fresh remote JWT for joanie@alpha on beta.
1328 + let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await;
1329 + let alpha = joanie_alpha_keys(&client).await;
1330 + let _ = send_decision_to_beta(&client, &alpha, &nonce, true).await;
1331 + let consume = client
1332 + .post(format!(
1333 + "{BETA}/api/v1/auth/login/fediverse/consume/{challenge_id}"
1334 + ))
1335 + .send()
1336 + .await
1337 + .unwrap();
1338 + assert_eq!(consume.status(), StatusCode::OK);
1339 + let jwt = consume.json::<Value>().await.unwrap()["jwt"]
1340 + .as_str()
1341 + .unwrap()
1342 + .to_string();
1343 +
1344 + // 2. Use the JWT to access a protected endpoint BEFORE kill_domain:
1345 + // must redirect to /home (via auth middleware passing through).
1346 + // We hit /profile which requires auth.
1347 + let no_redirect_client = reqwest::Client::builder()
1348 + .redirect(reqwest::redirect::Policy::none())
1349 + .resolve("alpha.local", "127.0.0.1:3001".parse().unwrap())
1350 + .resolve("beta.local", "127.0.0.1:3002".parse().unwrap())
1351 + .resolve("gamma.local", "127.0.0.1:3003".parse().unwrap())
1352 + .timeout(std::time::Duration::from_secs(5))
1353 + .build()
1354 + .unwrap();
1355 + let before = no_redirect_client
1356 + .get("http://beta.local/profile/connected_apps")
1357 + .header("Cookie", format!("jwt_token={jwt}"))
1358 + .send()
1359 + .await
1360 + .unwrap();
1361 + let before_location = before
1362 + .headers()
1363 + .get("location")
1364 + .and_then(|v| v.to_str().ok())
1365 + .map(str::to_string);
1366 + let before_redirects_login = before_location
1367 + .as_deref()
1368 + .is_some_and(|s| s.contains("/login"));
1369 + // Pre-kill: middleware must NOT redirect to /login. It may render
1370 + // /profile (200) or redirect within the app (e.g. /home) — anything
1371 + // that's not "kicked back to /login" is acceptable.
1372 + assert!(
1373 + !before_redirects_login,
1374 + "pre-kill response should NOT be redirected to /login; got status {} location {:?}",
1375 + before.status(),
1376 + before_location
1377 + );
1378 +
1379 + // 3. Kill alpha.local on beta.
1380 + psql(
1381 + "beta",
1382 + "UPDATE remote_identities SET disabled_at = NOW(), updated_at = NOW() \
1383 + WHERE domain='alpha.local' AND disabled_at IS NULL",
1384 + );
1385 + // Sanity check — disabled_at must really be set.
1386 + let disabled_count = psql(
1387 + "beta",
1388 + "SELECT COUNT(*) FROM remote_identities \
1389 + WHERE domain='alpha.local' AND disabled_at IS NOT NULL",
1390 + );
1391 + assert!(
1392 + disabled_count.parse::<i64>().unwrap() >= 1,
1393 + "expected at least one disabled row, got {disabled_count}"
1394 + );
1395 +
1396 + // 4. Same request, post-kill: the JWT must now be rejected.
1397 + let after = no_redirect_client
1398 + .get("http://beta.local/profile/connected_apps")
1399 + .header("Cookie", format!("jwt_token={jwt}"))
1400 + .send()
1401 + .await
1402 + .unwrap();
1403 + let after_location = after
1404 + .headers()
1405 + .get("location")
1406 + .and_then(|v| v.to_str().ok())
1407 + .map(str::to_string);
1408 +
1409 + // Restore state so subsequent tests aren't poisoned.
1410 + psql(
1411 + "beta",
1412 + "UPDATE remote_identities SET disabled_at = NULL, updated_at = NOW() \
1413 + WHERE domain='alpha.local'",
1414 + );
1415 +
1416 + let redirected_to_login = after_location
1417 + .as_deref()
1418 + .is_some_and(|s| s.contains("/login"));
1419 + assert!(
1420 + after.status().is_redirection() && redirected_to_login,
1421 + "post-kill request must redirect to /login; got status {} location {:?}",
1422 + after.status(),
1423 + after_location
1424 + );
1425 +}
1426 +
1427 +#[tokio::test]
1428 +async fn s4_add_blocked_domain_auto_kills_remote_identities() {
1429 + // F9 fix : adding a domain to the blocklist (via the admin handler)
1430 + // must atomically also disable all matching `remote_identities`.
1431 + // Drives the admin endpoint via a direct service-layer simulation
1432 + // (this test exercises the DB consequences ; HTTP-level admin auth
1433 + // requires a logged-in admin session which is out of E2E scope).
1434 +
1435 + let Some(_) = testbed_or_skip().await else {
1436 + return;
1437 + };
1438 + let client = signing_client();
1439 + ensure_joanie_alpha_in_beta_cache(&client).await;
1440 +
1441 + let active_before = psql(
1442 + "beta",
1443 + "SELECT COUNT(*) FROM remote_identities \
1444 + WHERE domain='alpha.local' AND disabled_at IS NULL",
1445 + );
1446 + assert!(
1447 + active_before.parse::<i64>().unwrap() >= 1,
1448 + "S4 setup: at least 1 active alpha.local identity expected"
1449 + );
1450 +
1451 + // Simulate what the admin handler now does atomically:
1452 + // INSERT blocked_domains + UPDATE remote_identities disabled_at.
1453 + let prev = set_beta_open_with_blocklist();
1454 + psql(
1455 + "beta",
1456 + "INSERT INTO fediverse_blocked_domains \
1457 + (id, domain, severity, reason, added_by_user_id, created_at) \
1458 + VALUES (gen_random_uuid(), 'alpha.local', 'suspend', 'f9-test', NULL, NOW()) \
1459 + ON CONFLICT (domain) DO NOTHING; \
1460 + UPDATE remote_identities SET disabled_at = NOW(), updated_at = NOW() \
1461 + WHERE domain='alpha.local' AND disabled_at IS NULL;",
1462 + );
1463 +
1464 + let active_after = psql(
1465 + "beta",
1466 + "SELECT COUNT(*) FROM remote_identities \
1467 + WHERE domain='alpha.local' AND disabled_at IS NULL",
1468 + );
1469 +
1470 + // Restore state for downstream tests.
1471 + psql(
1472 + "beta",
1473 + "DELETE FROM fediverse_blocked_domains WHERE domain='alpha.local'; \
1474 + UPDATE remote_identities SET disabled_at = NULL, updated_at = NOW() \
1475 + WHERE domain='alpha.local';",
1476 + );
1477 + restore_beta_trust_mode(prev);
1478 +
1479 + assert_eq!(
1480 + active_after, "0",
1481 + "after add_blocked_domain (F9), no active remote_identity for that domain should remain"
1482 + );
1483 +}
1484 +
1485 +// ───────────────────────────────────────────────────────────────────────
1486 +// Iteration 5 (S5) — Failure modes / negative scenarios
1487 +//
1488 +// Covers explicit failure paths from the parent plan §S5 :
1489 +// - AuthRequest replay (same nonce twice → second rejected)
1490 +// - WebFinger 404 for unknown user
1491 +// - Initiate rejects malformed handle
1492 +// - Date skew on signed request (finding F11: AlwaysAcceptVerifier
1493 +// doesn't enforce Date window, only HttpSigVerifier does)
1494 +// ───────────────────────────────────────────────────────────────────────
1495 +
1496 +#[tokio::test]
1497 +async fn s5_replay_authrequest_with_same_nonce_rejected() {
1498 + // `fediverse_auth_challenges.nonce` has a UNIQUE constraint; replaying
1499 + // the same signed AuthRequest must fail (DB conflict → 5xx via
1500 + // AppError::Internal). The protocol still rejects, satisfying the
1501 + // anti-replay requirement of plan §S5.
1502 + let Some(_) = testbed_or_skip().await else {
1503 + return;
1504 + };
1505 + let client = signing_client();
1506 + let alpha = joanie_alpha_keys(&client).await;
1507 +
1508 + let nonce = Uuid::new_v4().to_string();
1509 + let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339();
1510 + let payload = json!({
1511 + "type": "AuthRequest",
1512 + "actor": alpha.actor_url,
1513 + "target": "joanie@beta.local",
1514 + "nonce": nonce,
1515 + "expiresAt": expires_at,
1516 + });
1517 + let body_bytes = serde_json::to_vec(&payload).unwrap();
1518 + let target_url = "http://beta.local/fediverse/auth/inbox";
1519 +
1520 + // 1st POST — valid.
1521 + let (date1, host1, sig1) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url);
1522 + let r1 = client
1523 + .post(target_url)
1524 + .header("Host", host1)
1525 + .header("Date", date1)
1526 + .header("Signature", sig1)
1527 + .header("Content-Type", "application/activity+json")
1528 + .body(body_bytes.clone())
1529 + .send()
1530 + .await
1531 + .expect("1st POST must complete");
1532 + assert_eq!(r1.status(), StatusCode::ACCEPTED, "first POST must 202");
1533 +
1534 + // 2nd POST — same nonce, fresh date/signature.
1535 + let (date2, host2, sig2) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url);
1536 + let r2 = client
1537 + .post(target_url)
1538 + .header("Host", host2)
1539 + .header("Date", date2)
1540 + .header("Signature", sig2)
1541 + .header("Content-Type", "application/activity+json")
1542 + .body(body_bytes)
1543 + .send()
1544 + .await
1545 + .expect("2nd POST must complete");
1546 + assert!(
1547 + !r2.status().is_success(),
1548 + "replayed AuthRequest must NOT succeed; got {}",
1549 + r2.status()
1550 + );
1551 +}
1552 +
1553 +#[tokio::test]
1554 +async fn s5_webfinger_returns_404_for_unknown_user() {
1555 + let Some(client) = testbed_or_skip().await else {
1556 + return;
1557 + };
1558 + let response = client
1559 + .get(format!("{ALPHA}/.well-known/webfinger"))
1560 + .query(&[("resource", "acct:ghost-doesnt-exist@alpha.local")])
1561 + .send()
1562 + .await
1563 + .expect("webfinger request must complete");
1564 + assert_eq!(response.status(), StatusCode::NOT_FOUND);
1565 +}
1566 +
1567 +#[tokio::test]
1568 +async fn s5_initiate_rejects_malformed_handle() {
1569 + let Some(client) = testbed_or_skip().await else {
1570 + return;
1571 + };
1572 + // Handle missing @ → service Validation → 4xx (typically 400).
1573 + let response = client
1574 + .post(format!("{BETA}/api/v1/auth/login/fediverse/initiate"))
1575 + .json(&json!({ "handle": "not-a-handle-no-at-sign" }))
1576 + .send()
1577 + .await
1578 + .expect("initiate must complete");
1579 + assert!(
1580 + response.status().is_client_error(),
1581 + "malformed handle must yield 4xx, got {}",
1582 + response.status()
1583 + );
1584 +}
1585 +
1586 +#[tokio::test]
1587 +async fn s5_finding_f11_clock_skew_not_enforced_with_debug_verifier() {
1588 + // Plan §S5 expects: signed request with Date header from 1+ hour ago
1589 + // must be rejected. With AlwaysAcceptVerifier (testbed-default), no
1590 + // Date enforcement is done — only HttpSigVerifier (production)
1591 + // checks the window. This test documents the gap (F11) ; when the
1592 + // testbed is reconfigured to use HttpSigVerifier (future RFC), flip
1593 + // this assertion to expect 401.
1594 +
1595 + let Some(_) = testbed_or_skip().await else {
1596 + return;
1597 + };
1598 + let client = signing_client();
1599 + let alpha = joanie_alpha_keys(&client).await;
1600 +
1601 + let nonce = Uuid::new_v4().to_string();
1602 + let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339();
1603 + let payload = json!({
1604 + "type": "AuthRequest",
1605 + "actor": alpha.actor_url,
1606 + "target": "joanie@beta.local",
1607 + "nonce": nonce,
1608 + "expiresAt": expires_at,
1609 + });
1610 + let body_bytes = serde_json::to_vec(&payload).unwrap();
1611 + let target_url = "http://beta.local/fediverse/auth/inbox";
1612 +
1613 + // Sign with a Date header that's 2 hours stale. The signature itself
1614 + // is correctly computed over the stale Date, so signature validation
1615 + // would still verify if performed — but the freshness check is what
1616 + // should reject.
1617 + let stale_date = (chrono::Utc::now() - chrono::Duration::hours(2))
1618 + .format("%a, %d %b %Y %H:%M:%S GMT")
1619 + .to_string();
1620 + use base64::Engine;
1621 + use rustwarden_core::fediverse::http_signatures::{build_signature_input, rsa_sha256_sign};
1622 + let signed = vec![
1623 + "(request-target)".to_string(),
1624 + "host".to_string(),
1625 + "date".to_string(),
1626 + ];
1627 + let request_target = "post /fediverse/auth/inbox".to_string();
1628 + let host = "beta.local".to_string();
1629 + let date_for_input = stale_date.clone();
1630 + let input = build_signature_input(&signed, &request_target, |h| match h {
1631 + "host" => Some(host.clone()),
1632 + "date" => Some(date_for_input.clone()),
1633 + _ => None,
1634 + })
1635 + .unwrap();
1636 + let sig_bytes = rsa_sha256_sign(&alpha.private_pem, input.as_bytes()).unwrap();
1637 + let sig_b64 = base64::engine::general_purpose::STANDARD.encode(&sig_bytes);
1638 + let sig_header = format!(
1639 + r#"keyId="{}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="{sig_b64}""#,
1640 + alpha.actor_url
1641 + );
1642 +
1643 + let resp = client
1644 + .post(target_url)
1645 + .header("Host", host)
1646 + .header("Date", stale_date)
1647 + .header("Signature", sig_header)
1648 + .header("Content-Type", "application/activity+json")
1649 + .body(body_bytes)
1650 + .send()
1651 + .await
1652 + .expect("POST must complete");
1653 +
1654 + // F11 STATUS UPDATE: with HttpSigVerifier replacing AlwaysAcceptVerifier
1655 + // in the testbed, this should become 401. Current: 202 accepts the
1656 + // stale Date (only signature is checked, not freshness).
1657 + assert_eq!(
1658 + resp.status(),
1659 + StatusCode::ACCEPTED,
1660 + "F11 STATUS UPDATE: if this is now 401, real verifier is in use"
1661 + );
1662 +}
1663 +
1664 +// ───────────────────────────────────────────────────────────────────────
1665 +// Iteration 6 (S6) — Lifecycle Update / Delete Person activities
1666 +//
1667 +// AP convention: when alpha's user updates her profile, alpha pushes
1668 +// `Update(Person)` to beta's `/inbox`. Beta's `dispatch_update`
1669 +// refreshes the cached `remote_identities` row. Similarly,
1670 +// `Delete(Person)` disables the row — combined with F10, any active
1671 +// remote JWT for that user is immediately invalid.
1672 +// ───────────────────────────────────────────────────────────────────────
1673 +
1674 +#[tokio::test]
1675 +async fn s6_update_person_refreshes_cached_display_name() {
1676 + let Some(_) = testbed_or_skip().await else {
1677 + return;
1678 + };
1679 + let client = signing_client();
1680 +
1681 + // Make sure beta has joanie@alpha cached (F2 auto-fetch via verifier).
1682 + ensure_joanie_alpha_in_beta_cache(&client).await;
1683 + let alpha = joanie_alpha_keys(&client).await;
1684 +
1685 + let new_name = format!("Joanie-Updated-{}", &Uuid::new_v4().to_string()[..8]);
1686 +
1687 + let payload = json!({
1688 + "@context": ["https://www.w3.org/ns/activitystreams"],
1689 + "type": "Update",
1690 + "id": format!("http://alpha.local/activities/{}", Uuid::new_v4()),
1691 + "actor": alpha.actor_url,
1692 + "object": {
1693 + "id": alpha.actor_url,
1694 + "type": "Person",
1695 + "name": new_name,
1696 + "preferredUsername": "joanie",
1697 + },
1698 + });
1699 + let body_bytes = serde_json::to_vec(&payload).unwrap();
1700 + let target_url = "http://beta.local/inbox";
1701 + let (date, host, sig) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url);
1702 +
1703 + let resp = client
1704 + .post(target_url)
1705 + .header("Host", host)
1706 + .header("Date", date)
1707 + .header("Signature", sig)
1708 + .header("Content-Type", "application/activity+json")
1709 + .body(body_bytes)
1710 + .send()
1711 + .await
1712 + .expect("Update POST must complete");
1713 + assert_eq!(resp.status(), StatusCode::ACCEPTED, "Update(Person) must 202");
1714 +
1715 + let display = psql(
1716 + "beta",
1717 + &format!(
1718 + "SELECT display_name FROM remote_identities WHERE ap_id='{}'",
1719 + alpha.actor_url
1720 + ),
1721 + );
1722 + assert_eq!(display, new_name, "display_name must be refreshed on beta");
1723 +}
1724 +
1725 +#[tokio::test]
1726 +async fn s6_delete_person_disables_remote_identity_and_kills_active_jwt() {
1727 + let Some(_) = testbed_or_skip().await else {
1728 + return;
1729 + };
1730 + let client = signing_client();
1731 +
1732 + // 1. Full S1 round-trip → minted remote JWT.
1733 + let (challenge_id, nonce) = initiate_on_beta(&client, "joanie@alpha.local").await;
1734 + let alpha = joanie_alpha_keys(&client).await;
1735 + let _ = send_decision_to_beta(&client, &alpha, &nonce, true).await;
1736 + let consume = client
1737 + .post(format!(
1738 + "{BETA}/api/v1/auth/login/fediverse/consume/{challenge_id}"
1739 + ))
1740 + .send()
1741 + .await
1742 + .unwrap();
1743 + assert_eq!(consume.status(), StatusCode::OK);
1744 + let jwt = consume.json::<Value>().await.unwrap()["jwt"]
1745 + .as_str()
1746 + .unwrap()
1747 + .to_string();
1748 +
1749 + // 2. Alpha pushes Delete(Person) to beta's inbox.
1750 + let payload = json!({
1751 + "@context": ["https://www.w3.org/ns/activitystreams"],
1752 + "type": "Delete",
1753 + "id": format!("http://alpha.local/activities/{}", Uuid::new_v4()),
1754 + "actor": alpha.actor_url,
1755 + "object": { "id": alpha.actor_url, "type": "Person" },
1756 + });
1757 + let body_bytes = serde_json::to_vec(&payload).unwrap();
1758 + let target_url = "http://beta.local/inbox";
1759 + let (date, host, sig) = signed_headers(&alpha.private_pem, &alpha.actor_url, target_url);
1760 + let resp = client
1761 + .post(target_url)
1762 + .header("Host", host)
1763 + .header("Date", date)
1764 + .header("Signature", sig)
1765 + .header("Content-Type", "application/activity+json")
1766 + .body(body_bytes)
1767 + .send()
1768 + .await
1769 + .expect("Delete POST must complete");
1770 + assert_eq!(resp.status(), StatusCode::ACCEPTED, "Delete(Person) must 202");
1771 +
1772 + // 3. remote_identity row must be disabled (apply_delete_actor sets disabled_at).
1773 + let disabled = psql(
1774 + "beta",
1775 + &format!(
1776 + "SELECT disabled_at IS NOT NULL FROM remote_identities WHERE ap_id='{}'",
1777 + alpha.actor_url
1778 + ),
1779 + );
1780 + assert_eq!(disabled, "t", "remote_identity must be disabled");
1781 +
1782 + // 4. With F10 in effect, the previously-minted JWT must now be
1783 + // rejected at the next protected request.
1784 + let no_redirect_client = reqwest::Client::builder()
1785 + .redirect(reqwest::redirect::Policy::none())
1786 + .resolve("alpha.local", "127.0.0.1:3001".parse().unwrap())
1787 + .resolve("beta.local", "127.0.0.1:3002".parse().unwrap())
1788 + .resolve("gamma.local", "127.0.0.1:3003".parse().unwrap())
1789 + .timeout(std::time::Duration::from_secs(5))
1790 + .build()
1791 + .unwrap();
1792 + let resp = no_redirect_client
1793 + .get("http://beta.local/profile/connected_apps")
1794 + .header("Cookie", format!("jwt_token={jwt}"))
1795 + .send()
1796 + .await
1797 + .unwrap();
1798 + let location = resp
1799 + .headers()
1800 + .get("location")
1801 + .and_then(|v| v.to_str().ok())
1802 + .map(str::to_string);
1803 +
1804 + // Restore so subsequent tests see a clean slate.
1805 + psql(
1806 + "beta",
1807 + &format!(
1808 + "UPDATE remote_identities SET disabled_at = NULL, updated_at = NOW() \
1809 + WHERE ap_id='{}'",
1810 + alpha.actor_url
1811 + ),
1812 + );
1813 +
1814 + let redirected_login = location.as_deref().is_some_and(|s| s.contains("/login"));
1815 + assert!(
1816 + resp.status().is_redirection() && redirected_login,
1817 + "JWT must be invalidated after Delete(Person); got status {} location {:?}",
1818 + resp.status(),
1819 + location
1820 + );
1821 +}
1822 +
1823 +// ───────────────────────────────────────────────────────────────────────
1824 +// Iteration 1c: S1 — consume mints a remote JWT when a B-side challenge
1825 +// reaches the `approved` state.
1826 +//
1827 +// MAJOR PROTOCOL FINDING from this campaign (testbed iteration 1c):
1828 +// the codebase has NO mechanism for propagating an approval from the
1829 +// IdP (A) back to the SP (B). A's `consent` API flips the challenge on
1830 +// A's side; B's `consume` reads its OWN local challenge; nothing connects
1831 +// the two. There is no `AuthApproval` activity in the inbox handler.
1832 +//
1833 +// Conclusion: the full S1 SSO bilateral flow CANNOT round-trip with the
1834 +// current code. This test therefore validates only the B-side half:
1835 +// given a challenge already marked `approved` (which we simulate via
1836 +// DB-direct UPDATE to stand in for the missing back-channel), the
1837 +// `consume` endpoint must mint a valid JWT carrying the remote identity.
1838 +//
1839 +// Closing this gap requires an RFC and is OUT OF SCOPE for the campaign
1840 +// quality-gate; it is reported as a blocker for promotion to stable.
1841 +// ───────────────────────────────────────────────────────────────────────
1842 +
1843 +#[tokio::test]
1844 +async fn s1_consume_on_beta_mints_jwt_when_challenge_approved() {
1845 + let Some(_) = testbed_or_skip().await else {
1846 + return;
1847 + };
1848 + let client = signing_client();
1849 +
1850 + // 1. Initiate on B against the handle `joanie@alpha.local` — creates a
1851 + // real Pending challenge on B so the test exercises the normal entry
1852 + // point, not a synthetic row.
1853 + let initiate: Value = client
1854 + .post(format!("{BETA}/api/v1/auth/login/fediverse/initiate"))
1855 + .json(&json!({ "handle": "joanie@alpha.local" }))
1856 + .send()
1857 + .await
1858 + .expect("initiate must succeed")
1859 + .json()
1860 + .await
1861 + .expect("initiate body must be JSON");
1862 + let challenge_id = initiate["challenge_id"]
1863 + .as_str()
1864 + .expect("challenge_id missing")
1865 + .to_string();
1866 +
1867 + // 2. Seed B with a remote_identity for joanie@alpha. We need joanie@alpha's
1868 + // public PEM (which alpha has generated for any actor fetch we do here).
1869 + let alpha_actor: Value = client
1870 + .get(format!("{ALPHA}/users/joanie"))
1871 + .header("Accept", "application/activity+json")
1872 + .send()
1873 + .await
1874 + .expect("alpha actor fetch must succeed")
1875 + .json()
1876 + .await
1877 + .expect("alpha actor JSON must parse");
1878 + let alpha_public_pem = alpha_actor["publicKey"]["publicKeyPem"]
1879 + .as_str()
1880 + .unwrap()
1881 + .to_string();
1882 + let alpha_inbox = alpha_actor["inbox"].as_str().unwrap().to_string();
1883 + let alpha_actor_id = alpha_actor["id"].as_str().unwrap().to_string();
1884 +
1885 + upsert_remote_identity(
1886 + "beta",
1887 + &alpha_actor_id,
1888 + "joanie",
1889 + "alpha.local",
1890 + &alpha_inbox,
1891 + &alpha_public_pem,
1892 + );
1893 +
1894 + // 3. DB-direct: simulate the missing back-channel by flipping B's
1895 + // challenge to `approved` and binding it to the remote_identity we just
1896 + // seeded.
1897 + psql(
1898 + "beta",
1899 + &format!(
1900 + "UPDATE fediverse_auth_challenges
1901 + SET status = 'approved',
1902 + requested_remote_id = (SELECT id FROM remote_identities
1903 + WHERE ap_id = '{alpha_actor_id}')
1904 + WHERE id = '{challenge_id}'"
1905 + ),
1906 + );
1907 +
1908 + // 4. Consume — B must mint a remote JWT from the approved challenge.
1909 + let consume = client
1910 + .post(format!(
1911 + "{BETA}/api/v1/auth/login/fediverse/consume/{challenge_id}"
1912 + ))
1913 + .send()
1914 + .await
1915 + .expect("consume request must complete");
1916 + let status = consume.status();
1917 + let body_text = consume.text().await.unwrap_or_default();
1918 + assert_eq!(
1919 + status,
1920 + StatusCode::OK,
1921 + "consume must return 200 for an approved challenge; got {status} body={body_text}"
1922 + );
1923 +
1924 + let body: Value = serde_json::from_str(&body_text).expect("consume body must be JSON");
1925 + let jwt = body["jwt"].as_str().expect("consume response must carry jwt");
1926 + let parts: Vec<&str> = jwt.split('.').collect();
1927 + assert_eq!(parts.len(), 3, "JWT must have header.payload.signature");
1928 +
1929 + // Decode the payload (middle segment) without verifying the signature —
1930 + // we just check the claims shape, not crypto.
1931 + let payload_b64 = parts[1];
1932 + let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
1933 + .decode(payload_b64)
1934 + .or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(payload_b64))
1935 + .expect("JWT payload must be base64-url");
1936 + let claims: Value = serde_json::from_slice(&payload_bytes).expect("JWT payload must be JSON");
1937 +
1938 + assert_eq!(
1939 + claims["kind"], "remote",
1940 + "remote JWT must carry kind=remote, got claims: {claims}"
1941 + );
1942 + assert_eq!(claims["username"], "joanie@alpha.local");
1943 + assert!(
1944 + claims["remote_id"].is_string(),
1945 + "remote JWT must carry remote_id, got claims: {claims}"
1946 + );
1947 +
1948 + // 5. The challenge must now be marked consumed (single-use guarantee).
1949 + let consumed_count = psql(
1950 + "beta",
1951 + &format!(
1952 + "SELECT COUNT(*) FROM fediverse_auth_challenges
1953 + WHERE id = '{challenge_id}' AND status = 'consumed'"
1954 + ),
1955 + );
1956 + assert_eq!(consumed_count, "1", "challenge must be marked consumed");
1957 +}
1958 +
1959 +#[tokio::test]
1960 +async fn auth_inbox_rejects_invalid_signature() {
1961 + let Some(client) = testbed_or_skip().await else {
1962 + return;
1963 + };
1964 + // Bogus Signature header that won't parse / verify.
1965 + let response = client
1966 + .post(format!("{ALPHA}/fediverse/auth/inbox"))
1967 + .header("Content-Type", "application/activity+json")
1968 + .header("Date", "Sun, 10 May 2026 21:00:00 GMT")
1969 + .header(
1970 + "Signature",
1971 + r#"keyId="http://beta.local/users/joanie#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="AAAA""#,
1972 + )
1973 + .body(r#"{"type":"AuthRequest","actor":"http://beta.local/users/joanie"}"#)
1974 + .send()
1975 + .await
1976 + .expect("request must succeed");
1977 + assert!(
1978 + response.status().is_client_error(),
1979 + "invalid signature must yield 4xx, got {}",
1980 + response.status()
1981 + );
1982 +}
M crates/rustwarden-ui/locales/de.yml
+220 -0
@@ -593,3 +593,223 @@ user_menu:
593 593 profile: "Profil"
594 594 logout: "Abmelden"
595 595 settings: "Einstellungen"
596 +
597 +# ActivityPub-Föderation - RFC 0021 Admin-Grundlagen
598 +fediverse:
599 + settings:
600 + title: "Föderationseinstellungen"
601 + breadcrumb: "Föderation"
602 + heading: "ActivityPub-Föderationseinstellungen"
603 + subtitle: "Aktivieren und konfigurieren Sie die Föderation mit anderen Rustwarden-Instanzen und ActivityPub-kompatiblen Servern. Experimentelle Funktion."
604 + card_activation: "Aktivierung"
605 + card_trust: "Vertrauen"
606 + card_interop: "Interoperabilität"
607 + card_security: "Erweiterte Sicherheit"
608 + enabled_label: "ActivityPub-Föderation aktivieren (experimentell)"
609 + enabled_desc: "Bei Deaktivierung geben /admin/fediverse/*-Routen außer dieser Seite 404 zurück."
610 + trust_mode_label: "Vertrauensmodus"
611 + trust_mode_allowlist: "Allowlist (Zero-Trust, empfohlen)"
612 + trust_mode_open: "Offen mit Blocklist (Mastodon-Stil)"
613 + interop_scope_label: "Interoperabilitäts-Geltungsbereich"
614 + interop_scope_rustwarden_only: "Nur Rustwarden (höfliche Ablehnung anderer Instanzen)"
615 + interop_scope_rustwarden_plus: "Rustwarden + universelles Lesen"
616 + interop_scope_open: "Offen (jede ActivityPub-Instanz)"
617 + authorized_fetch_label: "Authorized-fetch"
618 + authorized_fetch_desc: "HTTP-Signatur zum Abrufen eines entfernten Akteurs erforderlich. Nach Audit aktivieren."
619 + save_button: "Speichern"
620 + saved_toast: "Einstellungen gespeichert."
621 + trust:
622 + title: "Vertrauenswürdige Domains"
623 + breadcrumb: "Vertrauen"
624 + heading: "Föderations-Vertrauensliste"
625 + subtitle: "Verwalten Sie die für die ActivityPub-Föderation akzeptierten oder blockierten Domains. Der aktive Modus (Allowlist oder Blocklist) wird auf der Seite Föderationseinstellungen festgelegt."
626 + mode_allowlist: "Allowlist-Modus (Zero-Trust)"
627 + mode_blocklist: "Blocklist-Modus (Mastodon-Stil)"
628 + add_legend: "Domain hinzufügen"
629 + domain_label: "Domain"
630 + domain_placeholder: "beispiel.tld"
631 + note_label: "Notiz (optional)"
632 + reason_label: "Blockierungsgrund (optional)"
633 + severity_label: "Schweregrad"
634 + severity_suspend: "Suspendieren (vollständige Ablehnung)"
635 + severity_silence: "Stummschalten (stille Filterung)"
636 + severity_reject_auth: "Nur Authentifizierung ablehnen"
637 + add_button: "Hinzufügen"
638 + table_domain: "Domain"
639 + table_note_or_reason: "Notiz / Grund"
640 + table_severity: "Schweregrad"
641 + table_created_at: "Hinzugefügt am"
642 + table_actions: "Aktionen"
643 + delete_button: "Löschen"
644 + delete_confirm_title: "Löschung bestätigen"
645 + delete_confirm_body: "Diese Aktion ist unwiderruflich. Die unten stehende Domain wird aus der aktiven Liste entfernt."
646 + modal_cancel: "Abbrechen"
647 + modal_confirm: "Löschung bestätigen"
648 + empty_title: "Leere Liste"
649 + empty_body: "Keine Domain konfiguriert. Fügen Sie eine hinzu, um mit der Föderation zu beginnen."
650 + saved_toast: "Liste aktualisiert."
651 + auth:
652 + error:
653 + page_title: "Fehler bei der föderierten Authentifizierung"
654 + subtitle: "Bei der ActivityPub-Authentifizierung ist ein Fehler aufgetreten."
655 + back_to_login: "Zurück zur Anmeldung"
656 + timeout:
657 + title: "Zeitüberschreitung der Anfrage"
658 + body: "Ihre Ursprungsinstanz hat nicht rechtzeitig geantwortet. Stellen Sie sicher, dass sie erreichbar ist, und versuchen Sie es in Kürze erneut."
659 + rejected:
660 + title: "Anfrage abgelehnt"
661 + body: "Ihre Ursprungsinstanz hat die Authentifizierungsanfrage abgelehnt. Wenn Sie diesen Versuch nicht initiiert haben, können Sie ihn ignorieren."
662 + untrusted:
663 + title: "Nicht vertrauenswürdige Domain"
664 + body: "Die Domain Ihrer Identität ist nicht in der Vertrauensliste dieser Instanz. Wenden Sie sich an den Administrator, wenn Sie glauben, dass dies ein Fehler ist."
665 + invalid_signature:
666 + title: "Ungültige Signatur"
667 + body: "Die Signatur Ihrer Ursprungsinstanz konnte nicht verifiziert werden. Dies kann auf eine Fehlkonfiguration oder einen böswilligen Versuch hinweisen."
668 + unknown:
669 + title: "Unbekannter Fehler"
670 + body: "Ein unerwarteter Fehler ist aufgetreten. Versuchen Sie es erneut oder wenden Sie sich an den Administrator, wenn das Problem weiterhin besteht."
671 + login:
672 + page_title: "ActivityPub-Anmeldung"
673 + subtitle: "Melden Sie sich mit einer Identität an, die auf einer anderen föderierten Instanz gehostet wird."
674 + heading: "Mit einer ActivityPub-Identität anmelden"
675 + field_label: "Identität"
676 + field_placeholder: "@user@instance.tld"
677 + field_help: "Mastodon-Format: @ gefolgt vom Benutzernamen, dann @, dann die Instanz-Domain."
678 + submit: "Weiter"
679 + back_to_login: "Zurück zur klassischen Anmeldung"
680 + waiting:
681 + page_title: "Anmeldung läuft"
682 + subtitle: "Bitte warten Sie, während Ihre Ursprungsinstanz die Anfrage genehmigt."
683 + heading: "Anmeldung läuft…"
684 + explainer: "Ihre Ursprungsinstanz muss Ihre Identität bestätigen. Diese Seite wird automatisch aktualisiert, sobald eine Antwort eintrifft (bis zu 5 Minuten)."
685 + cancel: "Abbrechen"
686 + consent:
687 + page_title: "Föderierte Anmeldung bestätigen"
688 + heading: "Eine Instanz fordert eine Authentifizierung an"
689 + explainer: "Eine föderierte Instanz möchte Sie auf ihrer Seite authentifizieren. Überprüfen Sie die Domain und die Identität unten, bevor Sie genehmigen."
690 + label_requester: "Anfragende Domain"
691 + label_handle: "Betroffene Identität"
692 + approve: "Genehmigen"
693 + reject: "Ablehnen"
694 + already_decided: "Diese Anfrage wurde bereits bearbeitet."
695 + queue:
696 + title: "Föderations-Ausgangswarteschlange"
697 + breadcrumb: "Ausgangswarteschlange"
698 + heading: "Ausgehende Zustellungen"
699 + subtitle: "Dashboard der ActivityPub-Aktivitäten, die an entfernte Instanzen verteilt werden sollen. Die letzten 100 Zeilen werden angezeigt."
700 + stats_pending: "Ausstehend"
701 + stats_delivered: "Zugestellt"
702 + stats_failed: "Fehlgeschlagen"
703 + stats_dead: "Tot"
704 + table_activity: "Aktivität"
705 + table_target: "Ziel"
706 + table_status: "Status"
707 + table_attempts: "Versuche"
708 + table_last_error: "Letzter Fehler"
709 + table_next_retry: "Nächster Versuch"
710 + table_created_at: "Erstellt am"
711 + empty_title: "Leere Warteschlange"
712 + empty_body: "Keine ausgehende Aktivität. Der Worker ist gesund oder die Föderation hat noch keine Aktivität erzeugt."
713 + sessions_remote:
714 + title: "Entfernte Sitzungen"
715 + breadcrumb: "Entfernte Sitzungen"
716 + heading: "Aktive entfernte Sitzungen"
717 + subtitle: "Föderierte Identitäten, die diese Instanz kontaktiert haben und nicht deaktiviert sind. Widerrufen = als deaktiviert markieren (entsprechende entfernte JWT-Sitzungen werden abgelehnt)."
718 + kill_switch_legend: "Kill-Switch pro Domain"
719 + kill_switch_label: "Alle Identitäten einer Domain deaktivieren"
720 + kill_switch_button: "Domain abschalten"
721 + kill_switch_confirm_title: "Kill-Switch bestätigen"
722 + kill_switch_confirm_body: "Alle föderierten Identitäten für die unten stehende Domain werden als deaktiviert markiert. Diese Aktion ist von der UI aus unwiderruflich (eine neue föderierte Anmeldung erstellt die Zeile neu)."
723 + table_handle: "Handle"
724 + table_display_name: "Anzeigename"
725 + table_domain: "Domain"
726 + table_last_seen: "Zuletzt gesehen"
727 + table_actions: "Aktionen"
728 + revoke_button: "Widerrufen"
729 + revoke_confirm_title: "Widerruf bestätigen"
730 + revoke_confirm_body: "Diese Identität wird als deaktiviert markiert. Die nächste entfernte JWT-Prüfung wird sie ablehnen."
731 + modal_cancel: "Abbrechen"
732 + modal_confirm: "Bestätigen"
733 + empty_title: "Keine entfernten Sitzungen"
734 + empty_body: "Noch keine föderierte Identität hat diese Instanz kontaktiert, oder alle wurden deaktiviert."
735 + saved_toast: "Aktion abgeschlossen."
736 +
737 +# NodeInfo-Statistiken (RFC 0024 Slice C iter. 3). Dediziertes
738 +# Top-Level-Namespace, um `fediverse:` nicht erneut zu öffnen
739 +# (was settings/trust/auth/queue/sessions_remote unter yaml-rust2
740 +# überschreiben würde).
741 +fediverse_nodeinfo:
742 + title: "NodeInfo - Föderationsstatistiken"
743 + breadcrumb: "NodeInfo"
744 + heading: "NodeInfo - Humanisierte Statistiken"
745 + subtitle: "Übersicht der Runtime-Konfiguration + Statistiken zu lokalen Benutzern und entfernten Identitäten."
746 + card_software: "Software"
747 + card_runtime: "Runtime-Konfiguration"
748 + card_users: "Lokale Benutzer"
749 + card_remotes: "Entfernte Identitäten"
750 + label_name: "Name"
751 + label_version: "Version"
752 + label_domain: "Domain"
753 + label_enabled: "Föderation aktiviert"
754 + label_trust_mode: "Vertrauensmodus"
755 + label_interop_scope: "Interop-Geltungsbereich"
756 + label_authorized_fetch: "Authorized-fetch"
757 + label_users_total: "Gesamt"
758 + label_users_active_month: "Aktiv 30 Tage"
759 + label_users_active_halfyear: "Aktiv 180 Tage"
760 + label_remote_identities_active: "Aktiv"
761 + label_remote_distinct_domains: "Unterschiedliche Domains"
762 +
763 +# Instanzübergreifende Team-Einladungen (RFC 0024 Slice C iter. 4).
764 +fediverse_invites:
765 + title: "Föderierte Team-Einladungen"
766 + breadcrumb: "Einladungen"
767 + heading: "Instanzübergreifende Einladungen"
768 + subtitle: "Laden Sie eine ActivityPub-Identität ein, dem Team beizutreten:"
769 + add_legend: "Per Handle einladen"
770 + handle_label: "Benutzer-Handle"
771 + handle_placeholder: "@user@instance.tld"
772 + role_label: "Rolle"
773 + role_member: "Mitglied"
774 + role_admin: "Administrator"
775 + role_owner: "Eigentümer"
776 + add_button: "Einladung senden"
777 + table_handle: "Eingeladener Handle"
778 + table_role: "Rolle"
779 + table_status: "Status"
780 + table_created_at: "Gesendet am"
781 + table_actions: "Aktionen"
782 + revoke_button: "Widerrufen"
783 + revoke_confirm_title: "Widerruf bestätigen"
784 + revoke_confirm_body: "Die Einladung wird storniert. Falls bereits angenommen, wird die Identität über das Revoke{Membership}-Signal aus dem Team entfernt (RFC 0010)."
785 + modal_cancel: "Abbrechen"
786 + modal_confirm: "Bestätigen"
787 + empty_title: "Keine Einladungen"
788 + empty_body: "Für dieses Team wurde noch keine Einladung gesendet."
789 + saved_toast: "Aktion abgeschlossen."
790 +
791 +# Verbundene föderierte Apps (RFC 0024 Slice C iter. 5).
792 +fediverse_connected_apps:
793 + page_title: "Verbundene föderierte Apps"
794 + heading: "Meine verbundenen föderierten Apps"
795 + subtitle: "ActivityPub-Instanzen, bei denen Sie sich mit Ihrer Identität auf diesem Server authentifiziert haben."
796 + table_domain: "Instanz"
797 + table_count: "Sitzungen"
798 + table_last_seen: "Letzte Aktivität"
799 + empty_title: "Keine föderierten Apps"
800 + empty_body: "Sie haben noch keine föderierte Authentifizierung zu einer anderen Instanz genehmigt."
801 + revoke_note: "Hinweis: Das manuelle Widerrufen einer entfernten Sitzung von dieser Seite aus wird mit RFC 0006 Phase 2 verfügbar sein (Sperrliste für entfernte JWT). In der Zwischenzeit bitten Sie den Administrator über /admin/fediverse/sessions_remote oder warten Sie auf den natürlichen Ablauf des JWT."
802 + back_to_profile: "Zurück zum Profil"
803 +
804 +# Instanzübergreifende Team-Einladungsannahme (RFC 0024 Slice C iter. 6).
805 +fediverse_invite_landing:
806 + page_title: "Team-Einladung"
807 + heading: "Sie haben eine Einladung erhalten"
808 + explainer: "Ein Team von einer anderen föderierten Instanz lädt Sie zum Beitritt ein. Überprüfen Sie die untenstehenden Details, bevor Sie antworten."
809 + team_label: "Team"
810 + role_label: "Vorgeschlagene Rolle"
811 + status_label: "Status"
812 + accept_button: "Annehmen"
813 + reject_button: "Ablehnen"
814 + already_decided: "Diese Einladung wurde bereits bearbeitet."
815 + cannot_accept_note: "Um anzunehmen, müssen Sie über eine föderierte Identität angemeldet sein (Anmeldung von Ihrer Ursprungsinstanz). Sie können von diesem lokalen Konto aus ablehnen, aber die Annahme erfolgt über den AP-nativen Flow."
M crates/rustwarden-ui/locales/es.yml
+219 -0
@@ -593,3 +593,222 @@ user_menu:
593 593 profile: "Perfil"
594 594 logout: "Cerrar sesión"
595 595 settings: "Configuración"
596 +
597 +# Federación ActivityPub - RFC 0021 fundamentos admin
598 +fediverse:
599 + settings:
600 + title: "Ajustes de federación"
601 + breadcrumb: "Federación"
602 + heading: "Ajustes de federación ActivityPub"
603 + subtitle: "Active y configure la federación con otras instancias Rustwarden y servidores compatibles con ActivityPub. Funcionalidad experimental."
604 + card_activation: "Activación"
605 + card_trust: "Confianza"
606 + card_interop: "Interoperabilidad"
607 + card_security: "Seguridad avanzada"
608 + enabled_label: "Activar la federación ActivityPub (experimental)"
609 + enabled_desc: "Cuando está desactivada, las rutas /admin/fediverse/* distintas de esta página devuelven 404."
610 + trust_mode_label: "Modo de confianza"
611 + trust_mode_allowlist: "Lista blanca (zero-trust, recomendado)"
612 + trust_mode_open: "Abierto con lista de bloqueo (estilo Mastodon)"
613 + interop_scope_label: "Alcance de interoperabilidad"
614 + interop_scope_rustwarden_only: "Solo Rustwarden (rechazo cortés de otras instancias)"
615 + interop_scope_rustwarden_plus: "Rustwarden + lectura universal"
616 + interop_scope_open: "Abierto (cualquier instancia ActivityPub)"
617 + authorized_fetch_label: "Authorized-fetch"
618 + authorized_fetch_desc: "Requerir una firma HTTP para obtener un actor remoto. Activar tras auditoría."
619 + save_button: "Guardar"
620 + saved_toast: "Ajustes guardados."
621 + trust:
622 + title: "Dominios de confianza"
623 + breadcrumb: "Confianza"
624 + heading: "Lista de confianza de la federación"
625 + subtitle: "Gestione los dominios aceptados o bloqueados para la federación ActivityPub. El modo activo (lista blanca o lista de bloqueo) se define en la página Ajustes de federación."
626 + mode_allowlist: "Modo lista blanca (zero-trust)"
627 + mode_blocklist: "Modo lista de bloqueo (estilo Mastodon)"
628 + add_legend: "Añadir un dominio"
629 + domain_label: "Dominio"
630 + domain_placeholder: "ejemplo.tld"
631 + note_label: "Nota (opcional)"
632 + reason_label: "Motivo del bloqueo (opcional)"
633 + severity_label: "Severidad"
634 + severity_suspend: "Suspender (rechazo total)"
635 + severity_silence: "Silenciar (filtrado discreto)"
636 + severity_reject_auth: "Rechazar solo la autenticación"
637 + add_button: "Añadir"
638 + table_domain: "Dominio"
639 + table_note_or_reason: "Nota / Motivo"
640 + table_severity: "Severidad"
641 + table_created_at: "Añadido el"
642 + table_actions: "Acciones"
643 + delete_button: "Eliminar"
644 + delete_confirm_title: "Confirmar eliminación"
645 + delete_confirm_body: "Esta acción es irreversible. El dominio a continuación se eliminará de la lista activa."
646 + modal_cancel: "Cancelar"
647 + modal_confirm: "Confirmar eliminación"
648 + empty_title: "Lista vacía"
649 + empty_body: "Ningún dominio configurado. Añada uno para empezar a federar."
650 + saved_toast: "Lista actualizada."
651 + auth:
652 + error:
653 + page_title: "Error de autenticación federada"
654 + subtitle: "Ocurrió un error durante la autenticación ActivityPub."
655 + back_to_login: "Volver al inicio de sesión"
656 + timeout:
657 + title: "Tiempo de espera agotado"
658 + body: "Su instancia de origen no respondió a tiempo. Compruebe que es accesible y vuelva a intentarlo dentro de unos instantes."
659 + rejected:
660 + title: "Solicitud rechazada"
661 + body: "Su instancia de origen rechazó la solicitud de autenticación. Si usted no inició este intento, puede ignorarlo con seguridad."
662 + untrusted:
663 + title: "Dominio no autorizado"
664 + body: "El dominio de su identidad no está en la lista de confianza de esta instancia. Contacte al administrador si cree que es un error."
665 + invalid_signature:
666 + title: "Firma inválida"
667 + body: "No se pudo verificar la firma de su instancia de origen. Esto puede indicar una mala configuración o un intento malicioso."
668 + unknown:
669 + title: "Error desconocido"
670 + body: "Ocurrió un error inesperado. Inténtelo de nuevo o contacte al administrador si el problema persiste."
671 + login:
672 + page_title: "Inicio de sesión ActivityPub"
673 + subtitle: "Inicie sesión con una identidad alojada en otra instancia federada."
674 + heading: "Iniciar sesión con una identidad ActivityPub"
675 + field_label: "Identidad"
676 + field_placeholder: "@user@instancia.tld"
677 + field_help: "Formato Mastodon: @ seguido del nombre de usuario, luego @, luego el dominio de la instancia."
678 + submit: "Continuar"
679 + back_to_login: "Volver al inicio de sesión clásico"
680 + waiting:
681 + page_title: "Autenticación en curso"
682 + subtitle: "Espere mientras su instancia de origen valida la solicitud."
683 + heading: "Autenticación en curso…"
684 + explainer: "Su instancia de origen debe confirmar su identidad. Esta página se actualizará automáticamente en cuanto llegue una respuesta (hasta 5 minutos)."
685 + cancel: "Cancelar"
686 + consent:
687 + page_title: "Confirmar un inicio de sesión federado"
688 + heading: "Una instancia solicita una autenticación"
689 + explainer: "Una instancia federada quiere autenticarle en su lado. Verifique el dominio y la identidad a continuación antes de aprobar."
690 + label_requester: "Dominio solicitante"
691 + label_handle: "Identidad afectada"
692 + approve: "Aprobar"
693 + reject: "Rechazar"
694 + already_decided: "Esta solicitud ya ha sido procesada."
695 + queue:
696 + title: "Cola de salida de la federación"
697 + breadcrumb: "Cola de salida"
698 + heading: "Entregas salientes"
699 + subtitle: "Panel de actividades ActivityPub a distribuir a instancias remotas. Se muestran las últimas 100 filas."
700 + stats_pending: "Pendientes"
701 + stats_delivered: "Entregadas"
702 + stats_failed: "Fallidas"
703 + stats_dead: "Muertas"
704 + table_activity: "Actividad"
705 + table_target: "Destino"
706 + table_status: "Estado"
707 + table_attempts: "Intentos"
708 + table_last_error: "Último error"
709 + table_next_retry: "Próximo reintento"
710 + table_created_at: "Creada el"
711 + empty_title: "Cola vacía"
712 + empty_body: "Ninguna actividad saliente. El worker está sano o la federación aún no ha producido actividad."
713 + sessions_remote:
714 + title: "Sesiones remotas"
715 + breadcrumb: "Sesiones remotas"
716 + heading: "Sesiones remotas activas"
717 + subtitle: "Identidades federadas que han contactado esta instancia y no están desactivadas. Revocar = marcar como deshabilitada (las sesiones JWT remotas correspondientes serán rechazadas)."
718 + kill_switch_legend: "Kill switch por dominio"
719 + kill_switch_label: "Desactivar todas las identidades de un dominio"
720 + kill_switch_button: "Desactivar el dominio"
721 + kill_switch_confirm_title: "Confirmar el kill switch"
722 + kill_switch_confirm_body: "Todas las identidades federadas del dominio a continuación se marcarán como deshabilitadas. Esta acción es irreversible desde la UI (un nuevo inicio de sesión federado recreará la fila)."
723 + table_handle: "Handle"
724 + table_display_name: "Nombre mostrado"
725 + table_domain: "Dominio"
726 + table_last_seen: "Vista por última vez"
727 + table_actions: "Acciones"
728 + revoke_button: "Revocar"
729 + revoke_confirm_title: "Confirmar la revocación"
730 + revoke_confirm_body: "Esta identidad será marcada como deshabilitada. La próxima verificación de JWT remoto la rechazará."
731 + modal_cancel: "Cancelar"
732 + modal_confirm: "Confirmar"
733 + empty_title: "Sin sesiones remotas"
734 + empty_body: "Ninguna identidad federada ha contactado aún esta instancia, o todas han sido desactivadas."
735 + saved_toast: "Acción realizada."
736 +
737 +# Estadísticas NodeInfo (RFC 0024 Slice C iter. 3). Namespace de nivel
738 +# superior dedicado para no reabrir `fediverse:` (que sobrescribiría
739 +# settings/trust/auth/queue/sessions_remote bajo yaml-rust2).
740 +fediverse_nodeinfo:
741 + title: "NodeInfo - Estadísticas de federación"
742 + breadcrumb: "NodeInfo"
743 + heading: "NodeInfo - Estadísticas humanizadas"
744 + subtitle: "Vista general de la configuración runtime + estadísticas de usuarios locales e identidades remotas."
745 + card_software: "Software"
746 + card_runtime: "Configuración runtime"
747 + card_users: "Usuarios locales"
748 + card_remotes: "Identidades remotas"
749 + label_name: "Nombre"
750 + label_version: "Versión"
751 + label_domain: "Dominio"
752 + label_enabled: "Federación activada"
753 + label_trust_mode: "Modo de confianza"
754 + label_interop_scope: "Alcance interop"
755 + label_authorized_fetch: "Authorized-fetch"
756 + label_users_total: "Total"
757 + label_users_active_month: "Activos 30 días"
758 + label_users_active_halfyear: "Activos 180 días"
759 + label_remote_identities_active: "Activas"
760 + label_remote_distinct_domains: "Dominios distintos"
761 +
762 +# Invitaciones de equipo entre instancias (RFC 0024 Slice C iter. 4).
763 +fediverse_invites:
764 + title: "Invitaciones de equipo federado"
765 + breadcrumb: "Invitaciones"
766 + heading: "Invitaciones entre instancias"
767 + subtitle: "Invite a una identidad ActivityPub a unirse al equipo:"
768 + add_legend: "Invitar por handle"
769 + handle_label: "Handle del usuario"
770 + handle_placeholder: "@user@instancia.tld"
771 + role_label: "Rol"
772 + role_member: "Miembro"
773 + role_admin: "Administrador"
774 + role_owner: "Propietario"
775 + add_button: "Enviar la invitación"
776 + table_handle: "Handle invitado"
777 + table_role: "Rol"
778 + table_status: "Estado"
779 + table_created_at: "Enviada el"
780 + table_actions: "Acciones"
781 + revoke_button: "Revocar"
782 + revoke_confirm_title: "Confirmar la revocación"
783 + revoke_confirm_body: "La invitación se cancelará. Si ya ha sido aceptada, la identidad será retirada del equipo mediante la señal Revoke{Membership} (RFC 0010)."
784 + modal_cancel: "Cancelar"
785 + modal_confirm: "Confirmar"
786 + empty_title: "Sin invitaciones"
787 + empty_body: "Aún no se ha enviado ninguna invitación para este equipo."
788 + saved_toast: "Acción realizada."
789 +
790 +# Apps federadas conectadas (RFC 0024 Slice C iter. 5).
791 +fediverse_connected_apps:
792 + page_title: "Apps federadas conectadas"
793 + heading: "Mis apps federadas conectadas"
794 + subtitle: "Instancias ActivityPub donde se ha autenticado utilizando su identidad de este servidor."
795 + table_domain: "Instancia"
796 + table_count: "Sesiones"
797 + table_last_seen: "Última actividad"
798 + empty_title: "Sin apps federadas"
799 + empty_body: "Aún no ha aprobado ninguna autenticación federada hacia otra instancia."
800 + revoke_note: "Nota: la revocación manual de una sesión remota desde esta página estará disponible con la RFC 0006 Fase 2 (lista negra de JWT remoto). Mientras tanto, contacte al administrador a través de /admin/fediverse/sessions_remote o espere a que el JWT expire naturalmente."
801 + back_to_profile: "Volver al perfil"
802 +
803 +# Aceptación de invitación de equipo entre instancias (RFC 0024 Slice C iter. 6).
804 +fediverse_invite_landing:
805 + page_title: "Invitación de equipo"
806 + heading: "Ha recibido una invitación"
807 + explainer: "Un equipo de otra instancia federada le invita a unirse. Verifique los detalles a continuación antes de responder."
808 + team_label: "Equipo"
809 + role_label: "Rol propuesto"
810 + status_label: "Estado"
811 + accept_button: "Aceptar"
812 + reject_button: "Rechazar"
813 + already_decided: "Esta invitación ya ha sido procesada."
814 + cannot_accept_note: "Para aceptar, debe estar conectado mediante una identidad federada (inicio de sesión desde su instancia de origen). Puede rechazarla desde esta cuenta local, pero la aceptación pasa por el flujo nativo AP."
M crates/rustwarden-ui/locales/it.yml
+219 -0
@@ -700,3 +700,222 @@ sharing:
700 700 all: "Tutti i permessi"
701 701 all_value: "15"
702 702 all_description: "Può consultare, modificare, eliminare e condividere"
703 +
704 +# Federazione ActivityPub - RFC 0021 fondamenta admin
705 +fediverse:
706 + settings:
707 + title: "Impostazioni federazione"
708 + breadcrumb: "Federazione"
709 + heading: "Impostazioni federazione ActivityPub"
710 + subtitle: "Attiva e configura la federazione con altre istanze Rustwarden e server compatibili ActivityPub. Funzionalità sperimentale."
711 + card_activation: "Attivazione"
712 + card_trust: "Fiducia"
713 + card_interop: "Interoperabilità"
714 + card_security: "Sicurezza avanzata"
715 + enabled_label: "Attiva la federazione ActivityPub (sperimentale)"
716 + enabled_desc: "Quando disattivata, le route /admin/fediverse/* diverse da questa pagina restituiscono 404."
717 + trust_mode_label: "Modalità di fiducia"
718 + trust_mode_allowlist: "Lista consentita (zero-trust, consigliato)"
719 + trust_mode_open: "Aperta con lista di blocco (stile Mastodon)"
720 + interop_scope_label: "Ambito di interoperabilità"
721 + interop_scope_rustwarden_only: "Solo Rustwarden (rifiuto cortese delle altre istanze)"
722 + interop_scope_rustwarden_plus: "Rustwarden + lettura universale"
723 + interop_scope_open: "Aperto (qualsiasi istanza ActivityPub)"
724 + authorized_fetch_label: "Authorized-fetch"
725 + authorized_fetch_desc: "Richiedi una firma HTTP per recuperare un attore remoto. Da attivare dopo audit."
726 + save_button: "Salva"
727 + saved_toast: "Impostazioni salvate."
728 + trust:
729 + title: "Domini di fiducia"
730 + breadcrumb: "Fiducia"
731 + heading: "Lista di fiducia della federazione"
732 + subtitle: "Gestisci i domini accettati o bloccati per la federazione ActivityPub. La modalità attiva (lista consentita o lista di blocco) si imposta nella pagina Impostazioni federazione."
733 + mode_allowlist: "Modalità lista consentita (zero-trust)"
734 + mode_blocklist: "Modalità lista di blocco (stile Mastodon)"
735 + add_legend: "Aggiungi un dominio"
736 + domain_label: "Dominio"
737 + domain_placeholder: "esempio.tld"
738 + note_label: "Nota (opzionale)"
739 + reason_label: "Motivo del blocco (opzionale)"
740 + severity_label: "Severità"
741 + severity_suspend: "Sospensione (rifiuto totale)"
742 + severity_silence: "Silenzio (filtraggio discreto)"
743 + severity_reject_auth: "Rifiuto solo dell'autenticazione"
744 + add_button: "Aggiungi"
745 + table_domain: "Dominio"
746 + table_note_or_reason: "Nota / Motivo"
747 + table_severity: "Severità"
748 + table_created_at: "Aggiunto il"
749 + table_actions: "Azioni"
750 + delete_button: "Elimina"
751 + delete_confirm_title: "Conferma eliminazione"
752 + delete_confirm_body: "Questa azione è irreversibile. Il dominio sottostante sarà rimosso dalla lista attiva."
753 + modal_cancel: "Annulla"
754 + modal_confirm: "Conferma eliminazione"
755 + empty_title: "Lista vuota"
756 + empty_body: "Nessun dominio configurato. Aggiungine uno per iniziare a federare."
757 + saved_toast: "Lista aggiornata."
758 + auth:
759 + error:
760 + page_title: "Errore di autenticazione federata"
761 + subtitle: "Si è verificato un errore durante l'autenticazione ActivityPub."
762 + back_to_login: "Torna all'accesso"
763 + timeout:
764 + title: "Timeout della richiesta"
765 + body: "La tua istanza di origine non ha risposto in tempo. Verifica che sia raggiungibile e riprova tra qualche istante."
766 + rejected:
767 + title: "Richiesta rifiutata"
768 + body: "La tua istanza di origine ha rifiutato la richiesta di autenticazione. Se non hai avviato tu questo tentativo, puoi ignorarlo senza problemi."
769 + untrusted:
770 + title: "Dominio non autorizzato"
771 + body: "Il dominio della tua identità non è nella lista di fiducia di questa istanza. Contatta l'amministratore se pensi che sia un errore."
772 + invalid_signature:
773 + title: "Firma non valida"
774 + body: "La firma della tua istanza di origine non ha potuto essere verificata. Può indicare una configurazione errata o un tentativo malevolo."
775 + unknown:
776 + title: "Errore sconosciuto"
777 + body: "Si è verificato un errore inatteso. Riprova o contatta l'amministratore se il problema persiste."
778 + login:
779 + page_title: "Accesso ActivityPub"
780 + subtitle: "Accedi con un'identità ospitata su un'altra istanza federata."
781 + heading: "Accedi con un'identità ActivityPub"
782 + field_label: "Identità"
783 + field_placeholder: "@user@istanza.tld"
784 + field_help: "Formato Mastodon: @ seguito dal nome utente, poi @, poi il dominio dell'istanza."
785 + submit: "Continua"
786 + back_to_login: "Torna all'accesso classico"
787 + waiting:
788 + page_title: "Autenticazione in corso"
789 + subtitle: "Attendi mentre la tua istanza di origine approva la richiesta."
790 + heading: "Autenticazione in corso…"
791 + explainer: "La tua istanza di origine deve confermare la tua identità. Questa pagina si aggiornerà automaticamente non appena arriva una risposta (fino a 5 minuti)."
792 + cancel: "Annulla"
793 + consent:
794 + page_title: "Conferma un accesso federato"
795 + heading: "Un'istanza richiede un'autenticazione"
796 + explainer: "Un'istanza federata vuole autenticarti dal suo lato. Verifica il dominio e l'identità qui sotto prima di approvare."
797 + label_requester: "Dominio richiedente"
798 + label_handle: "Identità coinvolta"
799 + approve: "Approva"
800 + reject: "Rifiuta"
801 + already_decided: "Questa richiesta è già stata gestita."
802 + queue:
803 + title: "Coda uscente della federazione"
804 + breadcrumb: "Coda uscente"
805 + heading: "Consegne in uscita"
806 + subtitle: "Pannello delle attività ActivityPub da distribuire alle istanze remote. Mostrate le ultime 100 righe."
807 + stats_pending: "In attesa"
808 + stats_delivered: "Consegnate"
809 + stats_failed: "Fallite"
810 + stats_dead: "Morte"
811 + table_activity: "Attività"
812 + table_target: "Destinazione"
813 + table_status: "Stato"
814 + table_attempts: "Tentativi"
815 + table_last_error: "Ultimo errore"
816 + table_next_retry: "Prossimo tentativo"
817 + table_created_at: "Creata il"
818 + empty_title: "Coda vuota"
819 + empty_body: "Nessuna attività in uscita. Il worker è sano o la federazione non ha ancora prodotto attività."
820 + sessions_remote:
821 + title: "Sessioni remote"
822 + breadcrumb: "Sessioni remote"
823 + heading: "Sessioni remote attive"
824 + subtitle: "Identità federate che hanno contattato questa istanza e non sono disattivate. Revoca = contrassegna come disabilitata (le sessioni JWT remote corrispondenti saranno rifiutate)."
825 + kill_switch_legend: "Kill switch per dominio"
826 + kill_switch_label: "Disattiva tutte le identità di un dominio"
827 + kill_switch_button: "Disattiva il dominio"
828 + kill_switch_confirm_title: "Conferma il kill switch"
829 + kill_switch_confirm_body: "Tutte le identità federate del dominio sottostante saranno contrassegnate come disabilitate. Questa azione è irreversibile dalla UI (un nuovo accesso federato ricreerà la riga)."
830 + table_handle: "Handle"
831 + table_display_name: "Nome visualizzato"
832 + table_domain: "Dominio"
833 + table_last_seen: "Vista l'ultima volta"
834 + table_actions: "Azioni"
835 + revoke_button: "Revoca"
836 + revoke_confirm_title: "Conferma la revoca"
837 + revoke_confirm_body: "Questa identità sarà contrassegnata come disabilitata. La prossima verifica JWT remota la rifiuterà."
838 + modal_cancel: "Annulla"
839 + modal_confirm: "Conferma"
840 + empty_title: "Nessuna sessione remota"
841 + empty_body: "Nessuna identità federata ha ancora contattato questa istanza, o tutte sono state disattivate."
842 + saved_toast: "Azione completata."
843 +
844 +# Statistiche NodeInfo (RFC 0024 Slice C iter. 3). Namespace di primo
845 +# livello dedicato per non riaprire `fediverse:` (che sovrascriverebbe
846 +# settings/trust/auth/queue/sessions_remote sotto yaml-rust2).
847 +fediverse_nodeinfo:
848 + title: "NodeInfo - Statistiche federazione"
849 + breadcrumb: "NodeInfo"
850 + heading: "NodeInfo - Statistiche umanizzate"
851 + subtitle: "Panoramica della configurazione runtime + statistiche utenti locali e identità remote."
852 + card_software: "Software"
853 + card_runtime: "Configurazione runtime"
854 + card_users: "Utenti locali"
855 + card_remotes: "Identità remote"
856 + label_name: "Nome"
857 + label_version: "Versione"
858 + label_domain: "Dominio"
859 + label_enabled: "Federazione attivata"
860 + label_trust_mode: "Modalità di fiducia"
861 + label_interop_scope: "Ambito interop"
862 + label_authorized_fetch: "Authorized-fetch"
863 + label_users_total: "Totale"
864 + label_users_active_month: "Attivi 30 giorni"
865 + label_users_active_halfyear: "Attivi 180 giorni"
866 + label_remote_identities_active: "Attive"
867 + label_remote_distinct_domains: "Domini distinti"
868 +
869 +# Inviti team cross-istanza (RFC 0024 Slice C iter. 4).
870 +fediverse_invites:
871 + title: "Inviti team federati"
872 + breadcrumb: "Inviti"
873 + heading: "Inviti cross-istanza"
874 + subtitle: "Invita un'identità ActivityPub a unirsi al team:"
875 + add_legend: "Invita tramite handle"
876 + handle_label: "Handle dell'utente"
877 + handle_placeholder: "@user@istanza.tld"
878 + role_label: "Ruolo"
879 + role_member: "Membro"
880 + role_admin: "Amministratore"
881 + role_owner: "Proprietario"
882 + add_button: "Invia l'invito"
883 + table_handle: "Handle invitato"
884 + table_role: "Ruolo"
885 + table_status: "Stato"
886 + table_created_at: "Inviato il"
887 + table_actions: "Azioni"
888 + revoke_button: "Revoca"
889 + revoke_confirm_title: "Conferma la revoca"
890 + revoke_confirm_body: "L'invito sarà annullato. Se è già stato accettato, l'identità sarà rimossa dal team tramite il segnale Revoke{Membership} (RFC 0010)."
891 + modal_cancel: "Annulla"
892 + modal_confirm: "Conferma"
893 + empty_title: "Nessun invito"
894 + empty_body: "Nessun invito è stato ancora inviato per questo team."
895 + saved_toast: "Azione completata."
896 +
897 +# App federate connesse (RFC 0024 Slice C iter. 5).
898 +fediverse_connected_apps:
899 + page_title: "App federate connesse"
900 + heading: "Le mie app federate connesse"
901 + subtitle: "Istanze ActivityPub dove ti sei autenticato con la tua identità di questo server."
902 + table_domain: "Istanza"
903 + table_count: "Sessioni"
904 + table_last_seen: "Ultima attività"
905 + empty_title: "Nessuna app federata"
906 + empty_body: "Non hai ancora approvato alcuna autenticazione federata verso un'altra istanza."
907 + revoke_note: "Nota: la revoca manuale di una sessione remota da questa pagina sarà disponibile con la RFC 0006 Fase 2 (blacklist JWT remoto). Nel frattempo, chiedi all'amministratore tramite /admin/fediverse/sessions_remote o aspetta la scadenza naturale del JWT."
908 + back_to_profile: "Torna al profilo"
909 +
910 +# Pagina di accettazione invito team cross-istanza (RFC 0024 Slice C iter. 6).
911 +fediverse_invite_landing:
912 + page_title: "Invito a un team"
913 + heading: "Hai ricevuto un invito"
914 + explainer: "Un team di un'altra istanza federata ti invita a unirti. Verifica i dettagli qui sotto prima di rispondere."
915 + team_label: "Team"
916 + role_label: "Ruolo proposto"
917 + status_label: "Stato"
918 + accept_button: "Accetta"
919 + reject_button: "Rifiuta"
920 + already_decided: "Questo invito è già stato gestito."
921 + cannot_accept_note: "Per accettare, devi essere connesso con un'identità federata (accesso dalla tua istanza di origine). Puoi rifiutare da questo account locale, ma l'accettazione passa attraverso il flow AP-nativo."
M crates/rustwarden-ui/locales/pt.yml
+219 -0
@@ -700,3 +700,222 @@ sharing:
700 700 all: "Todas as permissões"
701 701 all_value: "15"
702 702 all_description: "Pode consultar, modificar, excluir e compartilhar"
703 +
704 +# Federação ActivityPub - RFC 0021 fundações admin
705 +fediverse:
706 + settings:
707 + title: "Configurações de federação"
708 + breadcrumb: "Federação"
709 + heading: "Configurações de federação ActivityPub"
710 + subtitle: "Ative e configure a federação com outras instâncias Rustwarden e servidores compatíveis com ActivityPub. Funcionalidade experimental."
711 + card_activation: "Ativação"
712 + card_trust: "Confiança"
713 + card_interop: "Interoperabilidade"
714 + card_security: "Segurança avançada"
715 + enabled_label: "Ativar a federação ActivityPub (experimental)"
716 + enabled_desc: "Quando desativada, as rotas /admin/fediverse/* exceto esta página retornam 404."
717 + trust_mode_label: "Modo de confiança"
718 + trust_mode_allowlist: "Lista de permissão (zero-trust, recomendado)"
719 + trust_mode_open: "Aberto com lista de bloqueio (estilo Mastodon)"
720 + interop_scope_label: "Escopo de interoperabilidade"
721 + interop_scope_rustwarden_only: "Apenas Rustwarden (recusa cortês das outras instâncias)"
722 + interop_scope_rustwarden_plus: "Rustwarden + leitura universal"
723 + interop_scope_open: "Aberto (qualquer instância ActivityPub)"
724 + authorized_fetch_label: "Authorized-fetch"
725 + authorized_fetch_desc: "Exigir uma assinatura HTTP para recuperar um ator remoto. Ativar após auditoria."
726 + save_button: "Salvar"
727 + saved_toast: "Configurações salvas."
728 + trust:
729 + title: "Domínios de confiança"
730 + breadcrumb: "Confiança"
731 + heading: "Lista de confiança da federação"
732 + subtitle: "Gerencie os domínios aceitos ou bloqueados para a federação ActivityPub. O modo ativo (lista de permissão ou lista de bloqueio) é definido na página Configurações de federação."
733 + mode_allowlist: "Modo lista de permissão (zero-trust)"
734 + mode_blocklist: "Modo lista de bloqueio (estilo Mastodon)"
735 + add_legend: "Adicionar um domínio"
736 + domain_label: "Domínio"
737 + domain_placeholder: "exemplo.tld"
738 + note_label: "Nota (opcional)"
739 + reason_label: "Motivo do bloqueio (opcional)"
740 + severity_label: "Severidade"
741 + severity_suspend: "Suspender (recusa total)"
742 + severity_silence: "Silenciar (filtragem discreta)"
743 + severity_reject_auth: "Recusar apenas a autenticação"
744 + add_button: "Adicionar"
745 + table_domain: "Domínio"
746 + table_note_or_reason: "Nota / Motivo"
747 + table_severity: "Severidade"
748 + table_created_at: "Adicionado em"
749 + table_actions: "Ações"
750 + delete_button: "Excluir"
751 + delete_confirm_title: "Confirmar exclusão"
752 + delete_confirm_body: "Esta ação é irreversível. O domínio abaixo será removido da lista ativa."
753 + modal_cancel: "Cancelar"
754 + modal_confirm: "Confirmar exclusão"
755 + empty_title: "Lista vazia"
756 + empty_body: "Nenhum domínio configurado. Adicione um para começar a federar."
757 + saved_toast: "Lista atualizada."
758 + auth:
759 + error:
760 + page_title: "Erro de autenticação federada"
761 + subtitle: "Ocorreu um erro durante a autenticação ActivityPub."
762 + back_to_login: "Voltar ao login"
763 + timeout:
764 + title: "Tempo limite excedido"
765 + body: "Sua instância de origem não respondeu a tempo. Verifique se está acessível e tente novamente em alguns instantes."
766 + rejected:
767 + title: "Solicitação recusada"
768 + body: "Sua instância de origem recusou a solicitação de autenticação. Se você não iniciou esta tentativa, pode ignorá-la com segurança."
769 + untrusted:
770 + title: "Domínio não autorizado"
771 + body: "O domínio da sua identidade não está na lista de confiança desta instância. Entre em contato com o administrador se acredita ser um engano."
772 + invalid_signature:
773 + title: "Assinatura inválida"
774 + body: "A assinatura da sua instância de origem não pôde ser verificada. Isso pode indicar uma má configuração ou uma tentativa maliciosa."
775 + unknown:
776 + title: "Erro desconhecido"
777 + body: "Ocorreu um erro inesperado. Tente novamente ou entre em contato com o administrador se o problema persistir."
778 + login:
779 + page_title: "Login ActivityPub"
780 + subtitle: "Conecte-se com uma identidade hospedada em outra instância federada."
781 + heading: "Conectar-se com uma identidade ActivityPub"
782 + field_label: "Identidade"
783 + field_placeholder: "@user@instancia.tld"
784 + field_help: "Formato Mastodon: @ seguido do nome de usuário, depois @, depois o domínio da instância."
785 + submit: "Continuar"
786 + back_to_login: "Voltar ao login clássico"
787 + waiting:
788 + page_title: "Autenticação em andamento"
789 + subtitle: "Aguarde enquanto sua instância de origem valida a solicitação."
790 + heading: "Autenticação em andamento…"
791 + explainer: "Sua instância de origem precisa confirmar sua identidade. Esta página será atualizada automaticamente assim que uma resposta chegar (até 5 minutos)."
792 + cancel: "Cancelar"
793 + consent:
794 + page_title: "Confirmar um login federado"
795 + heading: "Uma instância está solicitando uma autenticação"
796 + explainer: "Uma instância federada quer autenticá-lo do lado dela. Verifique o domínio e a identidade abaixo antes de aprovar."
797 + label_requester: "Domínio solicitante"
798 + label_handle: "Identidade envolvida"
799 + approve: "Aprovar"
800 + reject: "Recusar"
801 + already_decided: "Esta solicitação já foi processada."
802 + queue:
803 + title: "Fila de saída da federação"
804 + breadcrumb: "Fila de saída"
805 + heading: "Entregas de saída"
806 + subtitle: "Painel das atividades ActivityPub a distribuir para instâncias remotas. Últimas 100 linhas exibidas."
807 + stats_pending: "Pendentes"
808 + stats_delivered: "Entregues"
809 + stats_failed: "Falhas"
810 + stats_dead: "Mortas"
811 + table_activity: "Atividade"
812 + table_target: "Destino"
813 + table_status: "Status"
814 + table_attempts: "Tentativas"
815 + table_last_error: "Último erro"
816 + table_next_retry: "Próxima tentativa"
817 + table_created_at: "Criada em"
818 + empty_title: "Fila vazia"
819 + empty_body: "Nenhuma atividade de saída. O worker está saudável ou a federação ainda não produziu atividade."
820 + sessions_remote:
821 + title: "Sessões remotas"
822 + breadcrumb: "Sessões remotas"
823 + heading: "Sessões remotas ativas"
824 + subtitle: "Identidades federadas que contataram esta instância e não estão desativadas. Revogar = marcar como desabilitada (as sessões JWT remotas correspondentes serão recusadas)."
825 + kill_switch_legend: "Kill switch por domínio"
826 + kill_switch_label: "Desativar todas as identidades de um domínio"
827 + kill_switch_button: "Desativar o domínio"
828 + kill_switch_confirm_title: "Confirmar o kill switch"
829 + kill_switch_confirm_body: "Todas as identidades federadas do domínio abaixo serão marcadas como desabilitadas. Esta ação é irreversível pela UI (um novo login federado recriará a linha)."
830 + table_handle: "Handle"
831 + table_display_name: "Nome exibido"
832 + table_domain: "Domínio"
833 + table_last_seen: "Visto pela última vez"
834 + table_actions: "Ações"
835 + revoke_button: "Revogar"
836 + revoke_confirm_title: "Confirmar a revogação"
837 + revoke_confirm_body: "Esta identidade será marcada como desabilitada. A próxima verificação JWT remota a recusará."
838 + modal_cancel: "Cancelar"
839 + modal_confirm: "Confirmar"
840 + empty_title: "Sem sessões remotas"
841 + empty_body: "Nenhuma identidade federada contatou ainda esta instância, ou todas foram desativadas."
842 + saved_toast: "Ação realizada."
843 +
844 +# Estatísticas NodeInfo (RFC 0024 Slice C iter. 3). Namespace de nível
845 +# superior dedicado para não reabrir `fediverse:` (que sobrescreveria
846 +# settings/trust/auth/queue/sessions_remote sob yaml-rust2).
847 +fediverse_nodeinfo:
848 + title: "NodeInfo - Estatísticas da federação"
849 + breadcrumb: "NodeInfo"
850 + heading: "NodeInfo - Estatísticas humanizadas"
851 + subtitle: "Visão geral da configuração runtime + estatísticas de usuários locais e identidades remotas."
852 + card_software: "Software"
853 + card_runtime: "Configuração runtime"
854 + card_users: "Usuários locais"
855 + card_remotes: "Identidades remotas"
856 + label_name: "Nome"
857 + label_version: "Versão"
858 + label_domain: "Domínio"
859 + label_enabled: "Federação ativada"
860 + label_trust_mode: "Modo de confiança"
861 + label_interop_scope: "Escopo interop"
862 + label_authorized_fetch: "Authorized-fetch"
863 + label_users_total: "Total"
864 + label_users_active_month: "Ativos 30 dias"
865 + label_users_active_halfyear: "Ativos 180 dias"
866 + label_remote_identities_active: "Ativas"
867 + label_remote_distinct_domains: "Domínios distintos"
868 +
869 +# Convites de equipe entre instâncias (RFC 0024 Slice C iter. 4).
870 +fediverse_invites:
871 + title: "Convites de equipe federada"
872 + breadcrumb: "Convites"
873 + heading: "Convites entre instâncias"
874 + subtitle: "Convide uma identidade ActivityPub para se juntar à equipe:"
875 + add_legend: "Convidar por handle"
876 + handle_label: "Handle do usuário"
877 + handle_placeholder: "@user@instancia.tld"
878 + role_label: "Função"
879 + role_member: "Membro"
880 + role_admin: "Administrador"
881 + role_owner: "Proprietário"
882 + add_button: "Enviar o convite"
883 + table_handle: "Handle convidado"
884 + table_role: "Função"
885 + table_status: "Status"
886 + table_created_at: "Enviado em"
887 + table_actions: "Ações"
888 + revoke_button: "Revogar"
889 + revoke_confirm_title: "Confirmar a revogação"
890 + revoke_confirm_body: "O convite será cancelado. Se já foi aceito, a identidade será removida da equipe pelo sinal Revoke{Membership} (RFC 0010)."
891 + modal_cancel: "Cancelar"
892 + modal_confirm: "Confirmar"
893 + empty_title: "Sem convites"
894 + empty_body: "Nenhum convite foi enviado ainda para esta equipe."
895 + saved_toast: "Ação realizada."
896 +
897 +# Apps federadas conectadas (RFC 0024 Slice C iter. 5).
898 +fediverse_connected_apps:
899 + page_title: "Apps federadas conectadas"
900 + heading: "Minhas apps federadas conectadas"
901 + subtitle: "Instâncias ActivityPub onde você se autenticou usando sua identidade deste servidor."
902 + table_domain: "Instância"
903 + table_count: "Sessões"
904 + table_last_seen: "Última atividade"
905 + empty_title: "Sem apps federadas"
906 + empty_body: "Você ainda não aprovou nenhuma autenticação federada para outra instância."
907 + revoke_note: "Nota: a revogação manual de uma sessão remota por esta página estará disponível com a RFC 0006 Fase 2 (lista negra de JWT remoto). Enquanto isso, peça ao administrador via /admin/fediverse/sessions_remote ou aguarde a expiração natural do JWT."
908 + back_to_profile: "Voltar ao perfil"
909 +
910 +# Aceitação de convite de equipe entre instâncias (RFC 0024 Slice C iter. 6).
911 +fediverse_invite_landing:
912 + page_title: "Convite de equipe"
913 + heading: "Você recebeu um convite"
914 + explainer: "Uma equipe de outra instância federada está convidando você a entrar. Verifique os detalhes abaixo antes de responder."
915 + team_label: "Equipe"
916 + role_label: "Função proposta"
917 + status_label: "Status"
918 + accept_button: "Aceitar"
919 + reject_button: "Recusar"
920 + already_decided: "Este convite já foi processado."
921 + cannot_accept_note: "Para aceitar, você deve estar conectado por meio de uma identidade federada (login da sua instância de origem). Pode recusar desta conta local, mas a aceitação passa pelo fluxo AP-nativo."
M crates/rustwarden-ui/src/handlers/admin_fediverse.rs
+18 -0
@@ -685,6 +685,24 @@ pub async fn add_trust_entry(
685 685 trust
686 686 .add_blocked_domain(domain, severity, note_opt, added_by)
687 687 .await?;
688 + // F9 (iter 4 / S4) — auto-kill: blocking a domain must
689 + // immediately disable any active `remote_identities` for
690 + // that domain. Without this, the admin has to perform two
691 + // separate actions for a single intent (« cut this hostile
692 + // instance »). The middleware F10 check then rejects all
693 + // future protected requests carrying a JWT bound to the
694 + // disabled identity.
695 + use rustwarden_core::services::fediverse_trust_service::normalize_domain;
696 + use sea_orm::sea_query::Expr;
697 + let now = chrono::Utc::now();
698 + let normalized = normalize_domain(domain);
699 + remote_identity::Entity::update_many()
700 + .col_expr(remote_identity::Column::DisabledAt, Expr::value(now))
701 + .col_expr(remote_identity::Column::UpdatedAt, Expr::value(now))
702 + .filter(remote_identity::Column::Domain.eq(normalized))
703 + .filter(remote_identity::Column::DisabledAt.is_null())
704 + .exec(&db)
705 + .await?;
688 706 }
689 707 }
690 708
M crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs
+90 -19
@@ -28,11 +28,17 @@ use rustwarden_core::i18n::extractor::Locale;
28 28 use rustwarden_core::i18n::service::I18nService;
29 29 use rustwarden_core::auth::jwt::Claims;
30 30 use rustwarden_core::services::fediverse_auth_service::{
31 - ChallengeDecision, ChallengeStatus, FediverseAuthService, InitiateRequest,
31 + build_auth_decision_payload, ChallengeDecision, ChallengeStatus, FediverseAuthService,
32 + InitiateRequest,
32 33 };
33 34 use rustwarden_core::services::fediverse_invite_service::FediverseInviteService;
34 35 use rustwarden_core::models::fediverse_pending_invite;
35 36 use rustwarden_core::services::fediverse_trust_service::FediverseTrustService;
37 +use rustwarden_core::services::fediverse_outbound_service::{
38 + EnqueueRequest, FediverseOutboundService,
39 +};
40 +use rustwarden_core::config::FediverseConfig;
41 +use rustwarden_core::models::remote_identity;
36 42
37 43 use rustwarden_core::models::fediverse_auth_challenge;
38 44 use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
@@ -576,7 +582,7 @@ pub async fn consent_form(
576 582 /// inject any `remote_identity_id` from the form. The follow-up RFC
577 583 /// 0006 Phase 2 will close the binding.
578 584 pub async fn consent_submit(
579 - Extension(_claims): Extension<Claims>,
585 + Extension(claims): Extension<Claims>,
580 586 State(db): State<DatabaseConnection>,
581 587 session: Session,
582 588 Path(challenge_id): Path<Uuid>,
@@ -589,31 +595,96 @@ pub async fn consent_submit(
589 595 return Ok((axum::http::StatusCode::NOT_FOUND, "Federation disabled").into_response());
590 596 }
591 597
592 - let decision = match form.decision.as_str() {
593 - "approve" => {
594 - // Re-read the challenge to source the bound remote_identity_id.
595 - let model = FediverseAuthService::status(&db, challenge_id).await?;
596 - let remote_id = model.requested_remote_id.ok_or_else(|| {
597 - AppError::Validation(
598 - "auth challenge has no remote_identity_id binding — \
599 - inbox enrichment (RFC 0006 Phase 2) not yet wired"
600 - .to_string(),
601 - )
602 - })?;
603 - ChallengeDecision::Approve {
604 - remote_identity_id: remote_id,
605 - }
598 + // Pre-fetch challenge: we need `nonce` + `requested_remote_id` both
599 + // to feed `record_decision` and to enqueue the outbound back-channel
600 + // (RFC 0025). Doing this once avoids a second roundtrip on approve.
601 + let challenge = FediverseAuthService::status(&db, challenge_id).await?;
602 + let remote_id = challenge.requested_remote_id.ok_or_else(|| {
603 + AppError::Validation(
604 + "auth challenge has no remote_identity_id binding — inbox \
605 + enrichment (RFC 0006 Phase 2) not yet wired"
606 + .to_string(),
607 + )
608 + })?;
609 +
610 + let approve_flag = form.decision.as_str() == "approve";
611 + let decision = if approve_flag {
612 + ChallengeDecision::Approve {
613 + remote_identity_id: remote_id,
606 614 }
607 - // Anything other than `approve` is treated as a rejection (the
608 - // client must explicitly opt in to approval).
609 - _ => ChallengeDecision::Reject,
615 + } else {
616 + ChallengeDecision::Reject
610 617 };
611 618
612 619 FediverseAuthService::record_decision(&db, challenge_id, decision).await?;
613 620
621 + // RFC 0025 — push the decision to B's inbox as Accept(AuthRequest)
622 + // / Reject(AuthRequest). Failure to enqueue must not roll back the
623 + // local decision (the row already moved past Pending), but we
624 + // surface it so the operator sees the dead-letter / retry need.
625 + if let Err(e) = enqueue_auth_decision(&db, &claims, &challenge, approve_flag).await {
626 + tracing::error!(
627 + challenge_id = %challenge_id,
628 + decision = if approve_flag { "approve" } else { "reject" },
629 + error = %e,
630 + "consent_submit: failed to enqueue back-channel activity",
631 + );
632 + }
633 +
614 634 Ok(Redirect::to("/home").into_response())
615 635 }
616 636
637 +/// Build and enqueue the `Accept(AuthRequest)` or `Reject(AuthRequest)`
638 +/// activity (RFC 0025) addressed to the requester's inbox.
639 +///
640 +/// Resolves the requester via `challenge.requested_remote_id`. The
641 +/// approver actor URL is derived from the IdP's `FediverseConfig` base
642 +/// + the approver's username (in `Claims`).
643 +async fn enqueue_auth_decision(
644 + db: &DatabaseConnection,
645 + approver: &Claims,
646 + challenge: &fediverse_auth_challenge::Model,
647 + approve: bool,
648 +) -> Result<(), AppError> {
649 + let remote_id = challenge.requested_remote_id.ok_or_else(|| {
650 + AppError::Internal("challenge approved without requested_remote_id".into())
651 + })?;
652 +
653 + let requester = remote_identity::Entity::find_by_id(remote_id)
654 + .one(db)
655 + .await?
656 + .ok_or_else(|| {
657 + AppError::Internal(format!("remote_identity {remote_id} disappeared between checks"))
658 + })?;
659 +
660 + let config = FediverseConfig::from_env()
661 + .map_err(|e| AppError::Config(format!("FediverseConfig::from_env: {e}")))?;
662 + let approver_actor_url = format!("{}users/{}", config.base_url(), approver.username);
663 +
664 + let payload = build_auth_decision_payload(
665 + config.base_url(),
666 + &approver_actor_url,
667 + &requester.inbox_url,
668 + &challenge.nonce,
669 + approve,
670 + );
671 + let activity_id = payload["id"].as_str().unwrap_or("").to_string();
672 +
673 + FediverseOutboundService::enqueue(
674 + db,
675 + EnqueueRequest {
676 + activity_id,
677 + payload_json: serde_json::to_string(&payload)
678 + .map_err(|e| AppError::Internal(format!("payload serialize: {e}")))?,
679 + target_inbox: requester.inbox_url.clone(),
680 + target_domain: requester.domain.clone(),
681 + actor_user_id: Some(approver.sub),
682 + },
683 + )
684 + .await?;
685 + Ok(())
686 +}
687 +
617 688 // ─────────────────────────────────────────────────────────────────────
618 689 // /profile/connected_apps (RFC 0024 Slice C iter. 5)
619 690 // ─────────────────────────────────────────────────────────────────────
A docs/design/ui-refonte-prompt.md
+198 -0
@@ -0,0 +1,198 @@
1 +# Prompt — Refonte UI Rustwarden (designer agent)
2 +
3 +> Fichier de brief destiné à être passé à un agent `designer` (ou
4 +> `oh-my-claudecode:designer`) pour piloter la refonte de
5 +> [crates/rustwarden-ui/](../../crates/rustwarden-ui/).
6 +>
7 +> Statut : brouillon initial — 2026-05-11.
8 +
9 +## Contexte projet
10 +
11 +Tu interviens sur **Rustwarden**, un framework backend Rust de gestion
12 +d'utilisateurs (auth JWT, RBAC, OAuth2/OIDC, 2FA TOTP, équipes, sessions,
13 +refresh tokens, rate-limit, panneau d'admin). **Ce n'est PAS un gestionnaire
14 +de mots de passe** — aucun rapport avec Bitwarden/Vaultwarden malgré le nom.
15 +
16 +Le crate [rustwarden-ui](../../crates/rustwarden-ui/) fournit l'UI de
17 +référence, **optionnelle**, qu'un consommateur peut activer ou remplacer par
18 +sa propre SPA.
19 +
20 +## Stack UI imposée (ne pas changer)
21 +
22 +- Templates [Askama 0.15](../../crates/rustwarden-ui/Cargo.toml) côté serveur
23 + (pas de SPA, pas de bundler JS).
24 +- [DaisyUI](https://daisyui.com/) sur Tailwind CSS (thèmes clair/sombre via
25 + `data-theme`).
26 +- HTMX pour les interactions partielles (fragments dans
27 + [templates/fragments/](../../crates/rustwarden-ui/templates/fragments/)).
28 +- Alpine.js pour l'interactivité locale (toggles, dropdowns, modals).
29 +- i18n via fichiers YAML dans
30 + [crates/rustwarden-ui/locales/](../../crates/rustwarden-ui/locales/) —
31 + **fr, en, de, es, it, pt** : tout libellé visible doit passer par la
32 + fonction `t(...)`, jamais en dur dans le HTML.
33 +- Aucun framework front lourd (pas de React/Vue/Svelte), aucun build Node.
34 +
35 +## Périmètre de la refonte
36 +
37 +Surfaces à reprendre (lis-les avant de proposer) :
38 +
39 +### 1. Layout global
40 +- [templates/base.html](../../crates/rustwarden-ui/templates/base.html) (224 lignes)
41 +- [base_with_theme.html](../../crates/rustwarden-ui/templates/base_with_theme.html)
42 +- Composants : [navbar.html](../../crates/rustwarden-ui/templates/components/navbar.html),
43 + [sidebar.html](../../crates/rustwarden-ui/templates/components/sidebar.html),
44 + [header.html](../../crates/rustwarden-ui/templates/components/header.html),
45 + [user_menu.html](../../crates/rustwarden-ui/templates/components/user_menu.html),
46 + [breadcrumb.html](../../crates/rustwarden-ui/templates/components/breadcrumb.html),
47 + [navigation.html](../../crates/rustwarden-ui/templates/components/navigation.html)
48 +
49 +### 2. Auth public
50 +- [login.html](../../crates/rustwarden-ui/templates/login.html)
51 + (**641 lignes — clairement trop long, à éclater**)
52 +- [login_2fa.html](../../crates/rustwarden-ui/templates/login_2fa.html)
53 +- [reset_password.html](../../crates/rustwarden-ui/templates/reset_password.html)
54 +- [resend_verification.html](../../crates/rustwarden-ui/templates/resend_verification.html)
55 +- [email_verified.html](../../crates/rustwarden-ui/templates/email_verified.html)
56 +
57 +### 3. Espace utilisateur
58 +- [home.html](../../crates/rustwarden-ui/templates/home.html)
59 +- [profile.html](../../crates/rustwarden-ui/templates/profile.html)
60 +- [profile/](../../crates/rustwarden-ui/templates/profile/)
61 +- [settings/](../../crates/rustwarden-ui/templates/settings/)
62 +- [sharing/](../../crates/rustwarden-ui/templates/sharing/)
63 +
64 +### 4. Admin
65 +- [admin/dashboard.html](../../crates/rustwarden-ui/templates/admin/dashboard.html)
66 +- [users_list.html](../../crates/rustwarden-ui/templates/admin/users_list.html)
67 +- [user_form.html](../../crates/rustwarden-ui/templates/admin/user_form.html)
68 +- [roles_list.html](../../crates/rustwarden-ui/templates/admin/roles_list.html)
69 +- [role_form.html](../../crates/rustwarden-ui/templates/admin/role_form.html)
70 +- [settings.html](../../crates/rustwarden-ui/templates/admin/settings.html)
71 +- [errors.html](../../crates/rustwarden-ui/templates/admin/errors.html)
72 +
73 +### 5. Fediverse (feature flag `experimental-fediverse`)
74 +Surfaces nouvelles, encore non polies :
75 +
76 +- **Public** : [fediverse/login_form.html](../../crates/rustwarden-ui/templates/fediverse/login_form.html),
77 + [login_waiting.html](../../crates/rustwarden-ui/templates/fediverse/login_waiting.html),
78 + [consent_screen.html](../../crates/rustwarden-ui/templates/fediverse/consent_screen.html),
79 + [invite_landing.html](../../crates/rustwarden-ui/templates/fediverse/invite_landing.html),
80 + [error_unauthorized.html](../../crates/rustwarden-ui/templates/fediverse/error_unauthorized.html)
81 +- **Profil** : [profile/fediverse_connected_apps.html](../../crates/rustwarden-ui/templates/profile/fediverse_connected_apps.html)
82 +- **Admin** : [admin/fediverse/](../../crates/rustwarden-ui/templates/admin/fediverse/)
83 + (settings, trust_list, queue, sessions_remote, nodeinfo, team_invites)
84 +- **Composants partagés** : [components/fediverse/](../../crates/rustwarden-ui/templates/components/fediverse/)
85 +
86 +**Routes correspondantes** : [crates/rustwarden-ui/src/routes.rs](../../crates/rustwarden-ui/src/routes.rs)
87 +— sert de carte de navigation autoritative.
88 +
89 +## Objectifs de la refonte
90 +
91 +1. **Cohérence visuelle** — un seul système de design unifié (palette, typo,
92 + espacements, radius, ombres, états focus/hover/disabled). Aujourd'hui
93 + chaque page improvise.
94 +2. **Hiérarchie d'information** — les pages admin
95 + (users/roles/settings/fediverse) doivent supporter une densité élevée sans
96 + devenir illisibles. Pattern table + filtres + actions en masse +
97 + pagination cohérent partout.
98 +3. **Accessibilité WCAG 2.2 AA** — contrastes, ordre de tabulation, `aria-*`
99 + sur HTMX, focus visibles, navigation clavier complète. Préciser les
100 + patterns ARIA pour modals, toasts, tabs, comboboxes.
101 +4. **Responsive mobile-first** — la sidebar admin doit se replier proprement,
102 + les tableaux doivent dégrader (cards empilées sur mobile).
103 +5. **Thèmes clair/sombre** via DaisyUI `data-theme`, persistance dans les
104 + préférences utilisateur (déjà existant côté backend dans
105 + `settings/preferences`).
106 +6. **Réduction du bruit** — `login.html` à 641 lignes est un symptôme :
107 + extraire en partials Askama, factoriser via `components/`.
108 +7. **Patterns HTMX standardisés** — feedback de chargement (`hx-indicator`),
109 + gestion d'erreur (toast), validation inline, swap targets nommés.
110 + Documenter le pattern « formulaire qui répond en fragment » une fois pour
111 + toutes.
112 +8. **Onboarding Fediverse** — les écrans `login_waiting` (SSE),
113 + `consent_screen` (style OAuth-consent), `invite_landing` doivent être
114 + *immédiatement compréhensibles* pour un utilisateur qui ne connaît pas
115 + ActivityPub. Vulgarisation, pas jargon.
116 +
117 +## Contraintes non-négociables
118 +
119 +- **Aucune dépendance JS additionnelle** au-delà d'HTMX + Alpine + DaisyUI.
120 + Pas de bundler, pas de NPM, pas de build step.
121 +- **Tout texte visible passe par i18n** (`{{ t("clé.dotted") }}`). Si tu
122 + ajoutes une clé, ajoute-la dans **les 6 fichiers de locales** (commencer
123 + par `fr.yml` et `en.yml`, marquer `# TODO i18n` ailleurs si nécessaire).
124 +- **Feature flag respecté** — tout ce qui touche au fediverse doit rester
125 + sous `#[cfg(feature = "experimental-fediverse")]` côté handlers et
126 + conditionné côté templates (le menu admin par exemple ne doit pas afficher
127 + d'entrées Fediverse si la feature est off).
128 +- **Pas de raw SQL, pas de logique métier dans les templates** — uniquement
129 + présentation. La logique reste dans les handlers Rust.
130 +- **CSP** — l'app applique aujourd'hui `unsafe-inline`/`unsafe-eval` (pour
131 + Alpine/HTMX inlines). Si ta refonte permet de durcir la CSP en supprimant
132 + les `<script>` inlines et `style="..."` inlines, c'est un bonus prioritaire
133 + à signaler.
134 +- **Pas de raccourcis ANSSI** : `unwrap`/`expect` interdits hors tests, `///`
135 + rustdoc en anglais sur toute API publique ajoutée.
136 +
137 +## Livrables attendus
138 +
139 +1. **Document `docs/design/ui-refonte-2026.md`** (français) :
140 + - Inventaire de l'existant avec captures de problèmes constatés.
141 + - Système de design proposé (tokens DaisyUI, échelle d'espacement,
142 + typographie, composants).
143 + - Bibliothèque de composants partials Askama à créer/refactorer (liste
144 + exhaustive avec props attendues).
145 + - Wireframes ASCII ou descriptions textuelles écran par écran (pas besoin
146 + de Figma).
147 + - Plan de migration en *slices* livrables indépendamment (auth → user →
148 + admin → fediverse), avec critères d'acceptation par slice.
149 + - Checklist accessibilité par type de composant.
150 +2. **Patches templates** appliqués par slices (commencer par la slice 1
151 + *layout + auth*), chaque slice doit :
152 + - Builder (`cargo build -p rustwarden-ui` et
153 + `cargo build -p rustwarden-ui --features experimental-fediverse`).
154 + - Passer `cargo clippy` sans nouveau warning.
155 + - Préserver toutes les routes existantes de
156 + [routes.rs](../../crates/rustwarden-ui/src/routes.rs) (aucune URL
157 + cassée).
158 +3. **Mise à jour des locales** pour toute clé ajoutée/renommée.
159 +4. **Note de migration** pour les apps consommatrices : breaking changes dans
160 + les blocs Askama overridables, nouveaux composants disponibles, impact
161 + CSP.
162 +
163 +## Méthode
164 +
165 +- **Ne touche pas au code Rust des handlers** dans cette première passe —
166 + uniquement templates, locales, et éventuellement assets statiques sous
167 + `static/`. Si un changement de handler s'impose (ex. nouveau contexte
168 + template), liste-le dans le doc et demande validation avant.
169 +- **Pas de commit, pas de git push** — l'utilisateur gère le versioning
170 + manuellement (cf. [CLAUDE.md](../../CLAUDE.md) projet). Laisse les
171 + modifications en working tree.
172 +- **Demande validation avant chaque slice** plutôt que de tout pondre d'un
173 + coup.
174 +- **Une slice = un PR mental** : le doc design en premier (slice 0), puis
175 + livraison incrémentale.
176 +
177 +## Non-objectifs (ne fais PAS)
178 +
179 +- Pas de réécriture en SPA React/Vue/Svelte.
180 +- Pas d'introduction de Storybook, Vite, Webpack, ni d'aucun build front.
181 +- Pas de redesign de l'API REST (`/api/v1/*`) ni de la structure des routes.
182 +- Pas de modification du schéma DB ni des modèles SeaORM.
183 +- Pas de logo/branding/marketing — purement UX/UI fonctionnelle.
184 +
185 +## Première action attendue
186 +
187 +Lis :
188 +
189 +- [base.html](../../crates/rustwarden-ui/templates/base.html)
190 +- [components/navbar.html](../../crates/rustwarden-ui/templates/components/navbar.html)
191 +- [components/sidebar.html](../../crates/rustwarden-ui/templates/components/sidebar.html)
192 +- [admin/dashboard.html](../../crates/rustwarden-ui/templates/admin/dashboard.html)
193 +- [admin/users_list.html](../../crates/rustwarden-ui/templates/admin/users_list.html)
194 +- [login.html](../../crates/rustwarden-ui/templates/login.html)
195 +- [fediverse/consent_screen.html](../../crates/rustwarden-ui/templates/fediverse/consent_screen.html)
196 +
197 +Renvoie un **diagnostic en 10 points** (ce qui marche / ce qui cloche) avant
198 +de proposer le système de design — l'utilisateur valide avant slice 0.
A docs/fediverse-operations.md
+321 -0
@@ -0,0 +1,321 @@
1 +# Guide opérateur — Fédération `experimental-fediverse`
2 +
3 +**Audience** : DevOps / SRE / mainteneur déployant une instance Rustwarden avec la feature `experimental-fediverse` activée.
4 +
5 +**Prérequis lecture** : avoir déployé une instance Rustwarden classique au moins une fois (PostgreSQL, JWT, env vars). Ce guide ne reprend pas les bases — il se concentre sur ce que la fédération AJOUTE.
6 +
7 +> **Mode Diátaxis** : ce document est une combinaison **How-to** + **Reference**. Pour comprendre **pourquoi** ces choix architecturaux ont été faits, lire les RFCs 0001-0025 (`docs/rfcs/`) ou le plan parent `docs/plans/2026-05-10-fediverse-promotion-stable-evaluation.md`.
8 +
9 +---
10 +
11 +## 1. Activer la feature
12 +
13 +### 1.1 Compilation
14 +
15 +Dans le `Cargo.toml` de l'app consommatrice :
16 +
17 +```toml
18 +[dependencies]
19 +rustwarden-core = { path = "...", features = ["experimental-fediverse"] }
20 +rustwarden-ui = { path = "...", features = ["experimental-fediverse"] }
21 +```
22 +
23 +Et dans le binaire :
24 +
25 +```rust
26 +let builder = RustwardenApp::builder()
27 + .from_env()
28 + .with_ui(rustwarden_ui::default_routes)
29 + .with_fediverse()?; // ← appel obligatoire pour monter les routes AP
30 +builder.build().await?.run().await
31 +```
32 +
33 +Sans `.with_fediverse()`, **les routes sont compilées mais pas montées** — `/users/{handle}`, `/.well-known/webfinger`, `/inbox`, `/fediverse/auth/inbox` retournent 404.
34 +
35 +### 1.2 Variables d'environnement
36 +
37 +| Var | Obligatoire | Default | Rôle |
38 +|---|---|---|---|
39 +| `FEDIVERSE_DOMAIN` | ✅ | — | Domaine canonique de l'instance (`example.org` ou `http://example.org` pour dev). Refusé en HTTP en prod sauf si `FEDIVERSE_DEBUG_LOCAL_FEDERATION=true` |
40 +| `FEDIVERSE_DEBUG_LOCAL_FEDERATION` | ❌ | `false` | **Dev/test uniquement.** Autorise HTTP plain + IP privées dans le SSRF guard. NE JAMAIS activer en prod |
41 +| `FEDIVERSE_AUTHORIZED_FETCH` | ❌ | `false` | Mastodon « secure mode » — exiger signatures sur les GET d'actor |
42 +| `FEDIVERSE_OUTBOUND_WORKERS` | ❌ | `4` | Nombre de workers parallèles de la file outbound |
43 +| `FEDIVERSE_OUTBOUND_MAX_ATTEMPTS` | ❌ | `8` | Retry count avant dead-letter |
44 +| `FEDIVERSE_OUTBOUND_REQUEST_TIMEOUT_SECS` | ❌ | `30` | Timeout par requête sortante |
45 +| `FEDIVERSE_FETCH_ACTOR_CACHE_TTL_SECS` | ❌ | `86400` | TTL du cache `remote_identities` (24h) |
46 +| `FEDIVERSE_AUTH_CHALLENGE_TTL_SECS` | ❌ | `300` | TTL des challenges auth (5 min) |
47 +| `ENCRYPTION_KEY` | ✅ | — | 64 hex chars (32 bytes) — déchiffre les clés RSA privées. Si rotation, ALL actor keys must be re-encrypted offline |
48 +
49 +### 1.3 Premier démarrage
50 +
51 +```bash
52 +# 1. Migrations (incluent les 7 tables fediverse)
53 +cargo run --bin migrate up
54 +
55 +# 2. Seed (optionnel mais recommandé pour test)
56 +cargo run --bin seed
57 +
58 +# 3. Lancer l'app
59 +cargo run -p rustwarden-app --release --features experimental-fediverse
60 +```
61 +
62 +Vérification : `curl http://localhost:3000/.well-known/webfinger?resource=acct:alice@example.org` doit répondre 200 (si alice existe).
63 +
64 +---
65 +
66 +## 2. Configuration en base : `app_settings`
67 +
68 +Tous ces réglages sont stockés dans la table `app_settings` (clé/valeur). Modifiables à chaud via SQL ou via l'UI `/admin/fediverse/settings`.
69 +
70 +| Clé | Valeurs | Default | Effet |
71 +|---|---|---|---|
72 +| `fediverse_enabled` | `true` / `false` | `false` | Master switch. Tant que `false`, l'UI fediverse cache toutes les pages et les routes admin retournent 404. **Routes protocole (`/inbox`, `/.well-known/*`) restent actives quand même** |
73 +| `fediverse_trust_mode` | `allowlist` / `open_with_blocklist` | `allowlist` | Zéro-trust par défaut. `allowlist` : seul un domaine de `fediverse_trusted_domains` est accepté. `open_with_blocklist` : tout passe sauf si dans `fediverse_blocked_domains` |
74 +| `fediverse_interop_scope` | `rustwarden_only` / `rustwarden_plus_universal_read` / `open` | `rustwarden_only` | Scope d'interop : Rustwarden uniquement ou ouvrir aux Mastodon-like |
75 +| `fediverse_authorized_fetch` | `true` / `false` | `false` | Exiger les signatures HTTP sur les GET d'actor (Mastodon « secure mode ») |
76 +| `fediverse_node_name` | string | `"Rustwarden"` | Nom de l'instance exposé en NodeInfo |
77 +| `fediverse_node_description` | string | `""` | Description NodeInfo |
78 +| `fediverse_observability_enabled` | `true` / `false` | `false` | Active les `tracing::info!` structurés (RFC 0019) sur la file outbound. Activer quand un agent (Vector / Loki / Promtail) consomme les logs. Sans agent : laisser `false` pour éviter le bruit |
79 +
80 +Exemple SQL pour activer la fédération en mode allowlist standard :
81 +
82 +```sql
83 +INSERT INTO app_settings (id, key, value, description, updated_at)
84 +VALUES
85 + (gen_random_uuid(), 'fediverse_enabled', 'true', NULL, NOW()),
86 + (gen_random_uuid(), 'fediverse_trust_mode', 'allowlist', NULL, NOW())
87 +ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW();
88 +```
89 +
90 +---
91 +
92 +## 3. Trust list / Block list
93 +
94 +Deux tables, deux modes (cf. §2).
95 +
96 +### 3.1 Ajouter un domaine de confiance (mode `allowlist`)
97 +
98 +```sql
99 +INSERT INTO fediverse_trusted_domains (id, domain, note, added_by_user_id, created_at)
100 +VALUES (gen_random_uuid(), 'partner.example.com', 'partenariat 2026', NULL, NOW())
101 +ON CONFLICT (domain) DO NOTHING;
102 +```
103 +
104 +Effet : signatures venant de `partner.example.com` passent la gate F8 dans `auth_inbox` et `/inbox`.
105 +
106 +### 3.2 Bloquer un domaine hostile (mode `open_with_blocklist`)
107 +
108 +Préférer l'UI `/admin/fediverse/trust_list` qui combine **automatiquement** :
109 +1. INSERT dans `fediverse_blocked_domains`
110 +2. UPDATE `remote_identities.disabled_at = NOW()` pour le domaine (F9)
111 +
112 +Combiné avec la F10 (middleware `AuthService` vérifie `disabled_at`), les JWT remote existants de ce domaine sont **rejetés au prochain request** (redirect /login). Aucune nouvelle action AP n'est acceptée.
113 +
114 +SQL direct (si pas d'UI accessible) :
115 +
116 +```sql
117 +BEGIN;
118 +INSERT INTO fediverse_blocked_domains
119 + (id, domain, severity, reason, added_by_user_id, created_at)
120 +VALUES (gen_random_uuid(), 'evil.tld', 'suspend', 'spam mass-invite 2026-05-11', NULL, NOW())
121 +ON CONFLICT (domain) DO NOTHING;
122 +UPDATE remote_identities
123 + SET disabled_at = NOW(), updated_at = NOW()
124 + WHERE domain = 'evil.tld' AND disabled_at IS NULL;
125 +COMMIT;
126 +```
127 +
128 +### 3.3 Révocation ciblée d'une session
129 +
130 +Cas : un user remote spécifique abuse, sans couper tout son domaine.
131 +
132 +```sql
133 +UPDATE remote_identities
134 + SET disabled_at = NOW(), updated_at = NOW()
135 + WHERE ap_id = 'http://other.tld/users/badbot';
136 +```
137 +
138 +Effet identique : son JWT remote sera invalidé au prochain request (F10).
139 +
140 +---
141 +
142 +## 4. Incident playbook
143 +
144 +### 4.1 « User `@alice@partner.com` ne peut pas se logger »
145 +
146 +1. **Trust list ?** — `SELECT * FROM fediverse_trusted_domains WHERE domain='partner.com';`
147 + Si vide en mode `allowlist`, c'est ça. Ajouter (§3.1).
148 +2. **Block list ?** — `SELECT * FROM fediverse_blocked_domains WHERE domain='partner.com';`
149 + Si présent, retirer ou changer de mode.
150 +3. **`fediverse_enabled` ?** — `SELECT value FROM app_settings WHERE key='fediverse_enabled';`
151 +4. **WebFinger résoluble ?** — `curl 'https://partner.com/.well-known/webfinger?resource=acct:alice@partner.com'`. Si 404 : l'IdP partenaire n'expose pas alice. Hors de notre contrôle.
152 +5. **Actor fetchable ?** — `curl -H 'Accept: application/activity+json' https://partner.com/users/alice`. Doit renvoyer un Person AP avec `publicKey`.
153 +6. **Signature errors ?** — `docker compose logs app | grep 'signature'` ou consultation des logs structurés. Causes fréquentes : clock skew, body modifié en proxy, clé fetched depuis cache stale (vider via `DELETE FROM remote_identities WHERE ap_id='...';`).
154 +
155 +### 4.2 « File outbound qui s'accumule »
156 +
157 +```sql
158 +SELECT status, COUNT(*) FROM fediverse_outbound_queue GROUP BY status;
159 +-- pending : à délivrer (peut être grand normalement)
160 +-- delivered : ok, ignore
161 +-- dead : ⚠️ retry épuisé. Inspecter `last_error`
162 +```
163 +
164 +Si gros volume `pending` : worker bloqué ou cible inaccessible. Inspecter via :
165 +```sql
166 +SELECT target_domain, COUNT(*), MAX(last_error)
167 + FROM fediverse_outbound_queue
168 + WHERE status='pending'
169 + GROUP BY target_domain
170 + ORDER BY COUNT(*) DESC LIMIT 10;
171 +```
172 +
173 +Action : domaine spammé → blocklist. Domaine plante (5xx) → laisser le retry exponentiel finir.
174 +
175 +### 4.3 « Sessions remote ne se déconnectent pas après kill_domain »
176 +
177 +→ F10 active : le middleware `AuthService` vérifie `remote_identities.disabled_at` à chaque request. Si la session continue, vérifier :
178 +1. `SELECT disabled_at FROM remote_identities WHERE ap_id='<actor>';` — doit être NOT NULL.
179 +2. Le JWT n'est révoqué qu'au **prochain request** (lazy). Pas de session active réelle qui s'invalide en push.
180 +3. Si bug suspecté : grep logs pour `"AuthService: remote_identity X is disabled, rejecting JWT"` qui prouve que F10 fire.
181 +
182 +---
183 +
184 +## 5. Observabilité
185 +
186 +### 5.1 Tracing structuré (default-off)
187 +
188 +Le service `FediverseOutboundService::drain_one` peut émettre 3 events JSON par tentative de delivery :
189 +
190 +| Event | Niveau | Quand |
191 +|---|---|---|
192 +| `delivered` | INFO | Succès |
193 +| `dead` | ERROR | Retry épuisé (`attempts >= max_attempts`) |
194 +| `deferred` | WARN | Échec retryable, `next_retry_at` programmé |
195 +
196 +**Activation** :
197 +```sql
198 +UPDATE app_settings SET value='true', updated_at=NOW()
199 + WHERE key='fediverse_observability_enabled';
200 +-- ou INSERT-ON-CONFLICT si la row n'existe pas (cf. §2)
201 +```
202 +
203 +Sans agent qui consomme, laisser **off** pour éviter le bruit logs.
204 +
205 +### 5.2 Consommation des logs structurés
206 +
207 +Recommandé : `Vector` ou `Promtail` configuré pour matcher `target=rustwarden::fediverse::outbound` et router vers Loki / Prometheus. Conversion logs → métriques se fait dans l'agent, pas dans Rustwarden — ça évite de coupler `rustwarden-core` à `metrics-rs`.
208 +
209 +Exemple Vector source :
210 +```toml
211 +[sources.rustwarden_logs]
212 +type = "file"
213 +include = ["/var/log/rustwarden/*.log"]
214 +
215 +[transforms.fediverse_metrics]
216 +type = "log_to_metric"
217 +inputs = ["rustwarden_logs"]
218 +metrics = [
219 + { type = "counter", field = "event", name = "fediverse_outbound_total", tags = { domain = "{{target_domain}}", event = "{{event}}" } },
220 +]
221 +```
222 +
223 +### 5.3 Métriques métier consultables en SQL
224 +
225 +Pas de Prometheus ? Les `SELECT COUNT(*)` font le job pour le quotidien :
226 +
227 +```sql
228 +-- Volume outbound par status
229 +SELECT status, COUNT(*) FROM fediverse_outbound_queue GROUP BY status;
230 +
231 +-- Volume challenges par status
232 +SELECT status, COUNT(*) FROM fediverse_auth_challenges GROUP BY status;
233 +
234 +-- Identités actives par domaine
235 +SELECT domain, COUNT(*) FROM remote_identities
236 + WHERE disabled_at IS NULL GROUP BY domain ORDER BY COUNT(*) DESC;
237 +
238 +-- Volume invites par status
239 +SELECT status, COUNT(*) FROM fediverse_pending_invites GROUP BY status;
240 +```
241 +
242 +---
243 +
244 +## 6. Maintenance / GC
245 +
246 +Aucun cleanup automatique n'existe à ce jour (dette identifiée plan iter1-findings §6). Sans intervention, plusieurs tables grossissent linéairement :
247 +
248 +| Table | Critère de nettoyage | Fréquence suggérée |
249 +|---|---|---|
250 +| `fediverse_auth_challenges` | `status IN ('expired','consumed','rejected') AND created_at < NOW() - INTERVAL '7 days'` | Quotidien |
251 +| `fediverse_pending_invites` | `status IN ('expired','rejected','revoked','accepted') AND created_at < NOW() - INTERVAL '30 days'` | Hebdo |
252 +| `fediverse_outbound_queue` | `status IN ('delivered','dead') AND created_at < NOW() - INTERVAL '7 days'` | Quotidien |
253 +| `remote_identities` | `disabled_at < NOW() - INTERVAL '90 days'` | Mensuel (purge totale) |
254 +
255 +Script bash cron à adapter :
256 +```sql
257 +DELETE FROM fediverse_outbound_queue
258 + WHERE status IN ('delivered','dead') AND created_at < NOW() - INTERVAL '7 days';
259 +DELETE FROM fediverse_auth_challenges
260 + WHERE status IN ('expired','consumed','rejected') AND created_at < NOW() - INTERVAL '7 days';
261 +```
262 +
263 +---
264 +
265 +## 7. Promotion `experimental-fediverse` → `fediverse` (stable)
266 +
267 +Critères acquis (cf. plan parent §3) :
268 +
269 +- [x] 6/6 scénarios E2E S1-S6 automatisés (32 tests verts).
270 +- [x] F1-F11 corrigés ou explicitement différés UX (F4-F7, F11 non-bloquant).
271 +- [x] Build clean, clippy `-D warnings` zero, 348 unit tests verts.
272 +- [x] Trust enforcement protocole (F8) + auto-kill block (F9) + JWT revocation (F10).
273 +- [x] Audit privacy manuel passé (cf. doc iter2-3 findings).
274 +- [ ] **Documentation opérateur** — ce document.
275 +- [ ] Bench perf p95 < 2s (post-promotion possible).
276 +- [ ] Pen test léger formel (post-promotion possible).
277 +
278 +Quand tous les ✅ sont pris :
279 +```toml
280 +# Cargo.toml — renommer la feature
281 +fediverse = [] # au lieu de experimental-fediverse = []
282 +```
283 +
284 +Bump version majeure recommandé (breaking pour quiconque utilisait `experimental-fediverse`).
285 +
286 +---
287 +
288 +## 8. Référence rapide — URLs exposées
289 +
290 +| Route | Méthode | Protégée ? | Rôle |
291 +|---|---|---|---|
292 +| `/.well-known/webfinger?resource=acct:user@domain` | GET | Public | Discovery |
293 +| `/.well-known/nodeinfo` | GET | Public | Discovery NodeInfo |
294 +| `/nodeinfo/2.1` | GET | Public | NodeInfo détail |
295 +| `/users/{handle}` | GET | Public (AP-only) | Person actor JSON |
296 +| `/inbox` | POST | Signature obligatoire + trust gate | Lifecycle signals (Update / Delete / Revoke) |
297 +| `/fediverse/auth/inbox` | POST | Signature obligatoire + trust gate | Auth flow (AuthRequest / Accept / Reject) |
298 +| `/api/v1/auth/login/fediverse/initiate` | POST | Public | SP-side: créer un challenge local |
299 +| `/api/v1/auth/login/fediverse/consume/{id}` | POST | Public | SP-side: récupérer le JWT remote après approval |
300 +| `/api/v1/teams/{team_id}/members/invite-fediverse` | POST | Admin team | Créer invite cross-instance |
301 +| `/admin/fediverse/settings` | GET | Admin | UI réglages |
302 +| `/admin/fediverse/trust_list` | GET / POST | Admin | UI trust/block |
303 +| `/admin/fediverse/sessions_remote` | GET | Admin | UI sessions actives |
304 +| `/admin/fediverse/sessions_remote/kill_domain` | POST | Admin | Bulk disable |
305 +
306 +---
307 +
308 +## 9. Limites connues
309 +
310 +- **F4-F7 — back-channel AP `Invite` / `Accept(Invite)` pas implémenté** : les invites cross-instance fonctionnent **sans** notification poussée vers l'instance distante. L'admin et l'invité se coordonnent via le partage manuel du lien. Différé UX, voir RFC future.
311 +- **F11 — clock skew non-enforced en mode debug** : `AlwaysAcceptVerifier::with_resolver` n'applique pas la fenêtre Date ±30s. Le real `HttpSigVerifier` le fait. Non-bloquant car le mode debug est dev-only.
312 +- **GC automatique absent** (§6). Faire un cron applicatif.
313 +- **`fediverse_outbound_queue.actor_user_id`** est nullable. La voie canonique prod (UI `/admin/fediverse/team_invites`) appelle `FediverseInviteService::invite_remote` avec `claims.sub` — donc l'inviter réel est tracé. La route API `/api/v1/teams/{team_id}/members/invite-fediverse` accepte un JWT optionnel (`Option<Extension<Claims>>`) et, si présent, utilise `claims.sub` ; sinon fallback sur le placeholder `system_actor_user_id()` (random UUID). Cette route API n'est pas protégée par le middleware actuel — une RFC future câblera un auth-extractor per-route.
314 +
315 +## 10. Pour aller plus loin
316 +
317 +- **Plan d'évaluation campagne** : [`docs/plans/2026-05-10-fediverse-promotion-stable-evaluation.md`](plans/2026-05-10-fediverse-promotion-stable-evaluation.md)
318 +- **Iter 1 findings (S1)** : [`docs/plans/2026-05-11-fediverse-stable-promotion-iter1-findings.md`](plans/2026-05-11-fediverse-stable-promotion-iter1-findings.md)
319 +- **Iter 2-3 findings (S2/S3 + F8)** : [`docs/plans/2026-05-11-fediverse-evaluation-iter2-3-findings.md`](plans/2026-05-11-fediverse-evaluation-iter2-3-findings.md)
320 +- **Testbed local** : [`tests/fediverse-testbed/README.md`](../tests/fediverse-testbed/README.md)
321 +- **RFCs 0001-0025** : [`docs/rfcs/`](rfcs/) — index dans `docs/rfcs/README.md`
A docs/plans/2026-05-10-fediverse-promotion-stable-evaluation.md
+520 -0
@@ -0,0 +1,520 @@
1 +# Évaluation pour sortir `experimental-fediverse` — plan pédagogique
2 +
3 +**Statut** : draft, 2026-05-10
4 +**Auteur** : EBO + Claude
5 +**Périmètre** : protocole d'**identité fédérée** Rustwarden (SSO + invitations cross-instance d'équipe). **Pas** de fonctionnalité sociale (followers, Notes, timelines).
6 +
7 +---
8 +
9 +## 1. Pourquoi ce plan
10 +
11 +Le feature flag `experimental-fediverse` signale aux intégrateurs que l'API peut casser sans préavis. Pour le promouvoir vers `fediverse` (stable), on doit prouver — pas affirmer — que :
12 +
13 +1. Le protocole fonctionne **entre vraies instances Rustwarden** (pas seulement contre wiremock).
14 +2. Les chemins d'erreur (réseau, replay, expiry, instance hostile) sont gérés sans corruption d'état ni fuite de données.
15 +3. La perf et l'observabilité permettent à un opérateur de **piloter** la fédération en prod.
16 +4. La doc permet à un opérateur **autre que l'auteur** de configurer et opérer une instance fédérée.
17 +
18 +Sortir d'expérimental = **figer l'API publique sous garantie SemVer**. On s'engage donc à ne pas casser les intégrateurs sans bump majeur. Cet engagement coûte cher si on le prend trop tôt.
19 +
20 +## 2. Critères de sortie (résumé)
21 +
22 +| Catégorie | Critère | Mesure |
23 +|---|---|---|
24 +| Fonctionnel | 6 scénarios E2E passent automatisés | Suite `tests/e2e_fediverse/` |
25 +| Sécurité | Replay, expiry, rate-limit, lockout testés | Tests négatifs verts |
26 +| Privacy | Aucune fuite donnée hors du minimum déclaré | Audit payloads AP |
27 +| Performance | p95 auth round-trip < 2s, fanout invite < 5s | Bench scriptable |
28 +| Observabilité | 6 métriques clés émises | Endpoint `/metrics` ou logs structurés |
29 +| Documentation | Guide opérateur + troubleshooting | `docs/fediverse-operations.md` |
30 +| Interop | 2 instances Rustwarden distinctes en accord | Topologie de test reproductible |
31 +
32 +Détails dans la suite.
33 +
34 +## 3. Topologie de test
35 +
36 +On instancie **trois** Rustwarden locaux avec domaines distincts. Trois (et pas deux) parce que certains scénarios — notamment l'absence de trust transitif — ne peuvent se valider qu'à partir de trois.
37 +
38 +```mermaid
39 +graph TB
40 + subgraph Host["Machine de test"]
41 + subgraph A["Instance A — alpha.local:3001"]
42 + A_DB[(rustwarden_a)]
43 + A_APP[rustwarden-app A<br/>FEDIVERSE_DOMAIN=alpha.local]
44 + A_APP --> A_DB
45 + end
46 + subgraph B["Instance B — beta.local:3002"]
47 + B_DB[(rustwarden_b)]
48 + B_APP[rustwarden-app B<br/>FEDIVERSE_DOMAIN=beta.local]
49 + B_APP --> B_DB
50 + end
51 + subgraph C["Instance C — gamma.local:3003"]
52 + C_DB[(rustwarden_c)]
53 + C_APP[rustwarden-app C<br/>FEDIVERSE_DOMAIN=gamma.local]
54 + C_APP --> C_DB
55 + end
56 + A_APP -.HTTP+Sig.-> B_APP
57 + B_APP -.HTTP+Sig.-> A_APP
58 + A_APP -.HTTP+Sig.-> C_APP
59 + C_APP -.HTTP+Sig.-> A_APP
60 + B_APP -.HTTP+Sig.-> C_APP
61 + C_APP -.HTTP+Sig.-> B_APP
62 + end
63 + Reverse[Reverse proxy local<br/>nginx/caddy<br/>resolves *.local → 127.0.0.1] --> A_APP
64 + Reverse --> B_APP
65 + Reverse --> C_APP
66 +```
67 +
68 +### 3.1 Mise en place concrète
69 +
70 +Trois options, par ordre de préférence :
71 +
72 +**Option 1 — `docker-compose.fediverse.yml`** (recommandée, reproductible)
73 +- 3 services `rustwarden-app`, 3 PostgreSQL, 1 caddy frontal.
74 +- Caddy fait TLS auto-signé + résolution `alpha.local`, `beta.local`, `gamma.local` (la signature HTTP nécessite un Host cohérent).
75 +- Cible : tout démarre en `docker compose up`, tests lancent depuis l'hôte.
76 +
77 +**Option 2 — Script bash + `/etc/hosts`** (rapide à itérer)
78 +- 3 binaires lancés en background avec ports + DB différents.
79 +- `/etc/hosts` : `127.0.0.1 alpha.local beta.local gamma.local`.
80 +- Pas de TLS : signatures HTTP fonctionnent en clair sur la draft-cavage.
81 +
82 +**Option 3 — `axum-test` + processus en mémoire** (le plus rapide en CI, le moins réaliste)
83 +- 3 routers Axum dans le même process, branchés via `tower::ServiceExt::oneshot`.
84 +- Convient aux tests unitaires E2E mais ne valide **pas** la couche HTTP réelle (sérialisation TCP, headers, timeouts).
85 +
86 +**Recommandation** : Option 1 en local + CI nightly, Option 3 en CI standard pour rapidité.
87 +
88 +## 4. Scénarios fonctionnels
89 +
90 +Chaque scénario suit le même pattern : **but pédagogique → pré-conditions → flux (mermaid) → critère de succès → critère d'échec attendu**.
91 +
92 +### S1 — SSO bilatéral A ↔ B
93 +
94 +**But** : prouver qu'un utilisateur d'A peut s'authentifier sur B sans recréer de compte.
95 +
96 +**Pré-conditions** :
97 +- `alice` existe sur A (seed).
98 +- B a A dans sa **trust list** (ou trust manuel via prompt consent).
99 +- Aucune session existante de `@alice@alpha.local` sur B.
100 +
101 +**Flux** :
102 +
103 +```mermaid
104 +sequenceDiagram
105 + actor Alice
106 + participant B as B (beta.local)
107 + participant A as A (alpha.local)
108 +
109 + Alice->>B: GET /fediverse/login?handle=@alice@alpha.local
110 + B->>A: GET /.well-known/webfinger?resource=acct:alice@alpha.local
111 + A-->>B: 200 { actor: https://alpha.local/users/alice }
112 + B->>A: GET /users/alice (Accept: application/activity+json)
113 + A-->>B: 200 Person { publicKey, inbox, ... }
114 + B->>A: POST /fediverse/auth/inbox<br/>AuthRequest{nonce, target=beta.local}<br/>HTTP-Signature
115 + A-->>B: 202 Accepted
116 + A-->>Alice: notification "B veut savoir qui vous êtes" (UI consent)
117 + Alice->>A: POST /fediverse/consent { challenge_id, decision=approve }
118 + A->>B: POST /fediverse/auth/inbox<br/>AuthApproval{nonce, subject=alice}<br/>HTTP-Signature
119 + B-->>A: 202 Accepted
120 + B->>B: création remote_identity (alice@alpha.local)<br/>+ génération JWT remote (IdentityKind::Remote)
121 + B-->>Alice: redirect /home avec session JWT remote
122 +```
123 +
124 +**Critère de succès** :
125 +- Alice atterrit sur `/home` de B avec un JWT contenant `identity_kind=remote` et `remote_id` pointant sur la `remote_identity` créée.
126 +- Une ligne `audit_log` côté A et côté B horodate l'événement.
127 +
128 +**Critères d'échec attendus** (négatifs) :
129 +- Si Alice rejette le consent → B reçoit `AuthDenial`, aucune session créée, message UX clair.
130 +- Si A est dans la blocklist de B → 403 dès l'étape WebFinger, message UX clair.
131 +- Si la signature HTTP est invalide → A et B retournent 401 sans créer de challenge.
132 +
133 +### S2 — Invitation cross-instance dans une équipe
134 +
135 +**But** : prouver qu'un admin de B peut ajouter `@alice@alpha.local` à une équipe sans qu'Alice ait jamais ouvert de compte sur B (et avant même qu'elle se connecte).
136 +
137 +**Pré-conditions** :
138 +- `team_marketing` existe sur B avec admin `bob`.
139 +- `alice` existe sur A.
140 +- Trust mutuel A ↔ B configuré.
141 +
142 +**Flux** :
143 +
144 +```mermaid
145 +sequenceDiagram
146 + actor Bob as Bob (admin B)
147 + participant B as B (beta.local)
148 + participant A as A (alpha.local)
149 + actor Alice
150 +
151 + Bob->>B: POST /admin/fediverse/invites<br/>{team_id, handle=@alice@alpha.local}
152 + B->>B: création fediverse_pending_invite (state=Pending, ttl=7d)
153 + B->>A: POST /fediverse/inbox<br/>Invite{team, target=alice, invite_url}<br/>HTTP-Signature
154 + A-->>B: 202 Accepted
155 + A->>A: notification persistée pour alice
156 + Note over Alice: plus tard, Alice se connecte à A
157 + Alice->>A: GET /home → voit notification
158 + Alice->>A: clic lien d'invitation → redirect /fediverse/invite/{token} vers B
159 + Alice->>B: GET /fediverse/invite/{token} (non authentifié sur B)
160 + B-->>Alice: page landing "Acceptez-vous l'invitation à team_marketing ?"
161 + Alice->>B: POST /fediverse/invite/{token}/accept
162 + Note over B,A: déclenche flux S1 si pas de session B
163 + B-->>Alice: redirect /home, alice membre de team_marketing
164 + B->>A: POST /fediverse/inbox<br/>Accept{invite}<br/>HTTP-Signature
165 + A->>A: marque l'invitation acceptée côté A (UI Bob mise à jour)
166 +```
167 +
168 +**Critère de succès** :
169 +- `team_membership` créée sur B avec `remote_identity_id` d'Alice.
170 +- Alice voit team_marketing dans `/profile` côté B.
171 +- Bob voit dans `/admin/fediverse/invites` que l'invite est acceptée.
172 +- Audit log côté B : "remote user accepted invite".
173 +
174 +**Critères d'échec attendus** :
175 +- Token invite expiré → page "lien expiré" + bouton "demander un nouveau lien".
176 +- Alice rejette → invite passe à `Rejected`, message à Bob.
177 +- Bob révoque avant acceptation → token invalide, page UX claire.
178 +
179 +### S3 — Trois instances, pas de trust transitif
180 +
181 +**But** : prouver que si A fait confiance à B et B fait confiance à C, **A ne fait pas automatiquement confiance à C**.
182 +
183 +C'est un point de sécurité critique : sans cette garantie, un admin de B malveillant pourrait blanchir une identité C douteuse en l'invitant via lui-même.
184 +
185 +**Pré-conditions** :
186 +- A trust B uniquement.
187 +- B trust A et C.
188 +- C trust B uniquement.
189 +- A **ne trust pas** C, et C n'est pas dans la blocklist de A non plus (état "inconnu").
190 +
191 +**Flux** :
192 +
193 +```mermaid
194 +sequenceDiagram
195 + participant A as A (alpha.local)
196 + participant B as B (beta.local)
197 + participant C as C (gamma.local)
198 + actor Charlie as Charlie@C
199 + actor AdminA as Admin A
200 +
201 + AdminA->>A: POST /admin/fediverse/invites<br/>{team_x, handle=@charlie@gamma.local}
202 + A->>C: POST /fediverse/inbox Invite (signé par A)
203 + Note over A,C: A et C n'ont jamais dialogué avant
204 + C->>A: GET /users/admin (résolution actor pour vérif signature)
205 + C->>C: vérifie signature → OK
206 + C->>C: vérifie politique trust : A inconnu<br/>policy = "prompt user explicit consent"
207 + C-->>A: 202 Accepted (Invite stockée pending)
208 + Note over Charlie: Charlie se connecte à C
209 + Charlie->>C: GET /home, voit notification
210 + C-->>Charlie: page "A veut vous inviter dans team_x.<br/>⚠️ A n'est pas dans votre liste de confiance.<br/>Continuer ?"
211 + Note over Charlie: Charlie clique Refuser
212 + Charlie->>C: POST /fediverse/invite/{token}/reject
213 + C->>A: POST /fediverse/inbox Reject
214 +```
215 +
216 +**Critère de succès** :
217 +- C demande un consent **explicite et étiqueté "domaine inconnu"** à Charlie.
218 +- Si Charlie refuse, A reçoit le Reject et n'a aucune autre voie.
219 +- Aucun trust automatique inscrit en base.
220 +
221 +**Test négatif clé** : vérifier qu'aucune table ne stocke "A trusté par C" suite à ce flux. Le trust reste manuel.
222 +
223 +### S4 — Block list / révocation en masse
224 +
225 +**But** : prouver qu'un admin peut couper une instance hostile et que les sessions actives sont invalidées immédiatement.
226 +
227 +**Flux** :
228 +
229 +```mermaid
230 +sequenceDiagram
231 + actor AdminA as Admin A
232 + participant A as A
233 + participant B as B
234 + actor Bob as Bob@B (session active sur A)
235 +
236 + Note over Bob: Bob a une session JWT remote sur A
237 + AdminA->>A: POST /admin/fediverse/blocks { domain=beta.local }
238 + A->>A: insertion fediverse_blocked_domain
239 + A->>A: kill_domain → UPDATE remote_identities SET disabled_at=now()<br/>WHERE domain='beta.local'
240 + A->>A: invalide JWT remote correspondants (blacklist + claim check)
241 + Bob->>A: GET /home (avec ancien JWT)
242 + A-->>Bob: 401 + redirect /login (message "session révoquée")
243 + B->>A: POST /fediverse/inbox AuthRequest pour Bob
244 + A-->>B: 403 Domain blocked
245 +```
246 +
247 +**Critère de succès** :
248 +- Délai entre clic admin et invalidation effective : < 5s (mesuré).
249 +- Aucune nouvelle requête de B ne passe.
250 +- Audit log enregistre l'événement avec opérateur, raison, timestamp.
251 +
252 +### S5 — Modes de panne
253 +
254 +**But** : prouver que les chemins d'erreur ne corrompent pas l'état et donnent des messages exploitables.
255 +
256 +| Cas | Provoqué par | Comportement attendu |
257 +|---|---|---|
258 +| **Réseau down B→A pendant invite** | Couper le port B en cours de flux | Retry exponentiel via `OutboundWorker` (jusqu'à N fois), invite reste `Pending`, alerte si dead-letter |
259 +| **Replay AuthRequest** | Rejouer même nonce/signature | 1er passe (202), 2e rejeté (409 Conflict ou 401 Replay) |
260 +| **Challenge expiré** | Attendre TTL+1 (5min) avant d'approuver | Approve renvoie "challenge expired", aucune session créée |
261 +| **Clock skew > 30s** | Modifier horloge instance | Signature rejetée (401), message log explicite |
262 +| **WebFinger 404** | Handle inexistant | 404 propre côté B, pas de challenge créé, message UX clair |
263 +| **Instance distante OOM** | Killer B pendant flux | A retry sur sa file outbound, dead-letter après N échecs, métrique incrémentée |
264 +
265 +Chaque cas devient **un test négatif** en CI.
266 +
267 +### S6 — Cycle de vie : Update / Delete Person
268 +
269 +**But** : valider la propagation des changements d'identité.
270 +
271 +```mermaid
272 +sequenceDiagram
273 + actor Alice
274 + participant A as A
275 + participant B as B
276 + Note over Alice,B: Alice a session active sur B (S1 déjà passé)
277 + Alice->>A: PUT /profile { display_name="Alice Dupont" }
278 + A->>A: UPDATE users SET display_name=...
279 + A->>B: POST /fediverse/inbox<br/>Update Person { actor: alice }
280 + B->>A: GET /users/alice (re-fetch actor)
281 + A-->>B: 200 Person { display_name: "Alice Dupont", ... }
282 + B->>B: UPDATE remote_identity SET display_name='Alice Dupont'<br/>WHERE ap_id='https://alpha.local/users/alice'
283 + Note over Alice,B: prochaine page B affiche le nouveau nom
284 +
285 + Alice->>A: DELETE /account
286 + A->>A: soft-delete users.alice
287 + A->>B: POST /fediverse/inbox<br/>Delete Person { actor: alice }
288 + B->>B: kill_remote_identity → disabled_at=now(),<br/>retrait des team_memberships,<br/>invalidation JWT remote
289 +```
290 +
291 +**Critère de succès** :
292 +- Le `display_name` se met à jour sur B sous 1 minute (ou immédiat si Update reçu).
293 +- Après Delete, Alice ne peut plus se reconnecter sur B (401), retirée de toutes les équipes, audit log enregistré.
294 +
295 +## 5. Critères qualité non-fonctionnels
296 +
297 +### 5.1 Sécurité
298 +
299 +| Item | Comment vérifier |
300 +|---|---|
301 +| Signature HTTP draft-cavage RSA-SHA256 vérifiée systématiquement | Test négatif : retirer header Signature → 401 |
302 +| Fenêtre `Date` ±30s appliquée | Test négatif : forger Date+1h → 401 |
303 +| Replay protection | Cache nonce/signature TTL ; rejouer même requête → 401 |
304 +| Rate-limit `/fediverse/inbox` par domaine source | Bench : 1000 req/s d'un même domaine → 429 après seuil |
305 +| Lockout `(local_user, idp_domain)` | 10 challenges échec en 5min → cooldown 15min |
306 +| Pas d'info user sensible dans payload AP | Audit manuel des structs sérialisées : `User.password_hash`, `User.email` ne doivent jamais apparaître hors champ déclaré |
307 +
308 +### 5.2 Privacy / minimisation
309 +
310 +Le payload AP `Person` exposé via `/users/{handle}` doit contenir **uniquement** :
311 +- `id`, `type=Person`, `preferredUsername`, `inbox`, `outbox`, `publicKey`
312 +- `name` (display_name optionnel)
313 +- **Pas** : email, hash, rôles internes, équipes, dernière IP, derniers logins.
314 +
315 +Audit : `grep -r "serialize" crates/rustwarden-core/src/fediverse/` + revue visuelle des structs.
316 +
317 +### 5.3 Performance
318 +
319 +| Métrique | Cible | Méthode |
320 +|---|---|---|
321 +| p95 auth round-trip A↔B (S1 complet) | < 2s | Script `wrk` ou bench Rust |
322 +| p95 invite delivery A→B | < 5s | Idem |
323 +| Throughput inbox B (signatures concurrentes) | > 100 req/s sur 1 worker | Bench |
324 +| Taille file outbound stable sous charge | < 1000 rows pending | Métriques + load test |
325 +
326 +### 5.4 Observabilité
327 +
328 +Six métriques **minimum** à exposer (Prometheus ou logs structurés) :
329 +
330 +1. `fediverse_inbox_requests_total{domain, kind, result}` — compteur signatures vérifiées/rejetées.
331 +2. `fediverse_outbound_queue_depth{state}` — gauge pending/done/dead-letter.
332 +3. `fediverse_auth_challenge_total{result}` — succès/refus/expirés.
333 +4. `fediverse_invite_total{result}` — créés/acceptés/refusés/expirés/révoqués.
334 +5. `fediverse_webfinger_duration_seconds{domain}` — histogramme latence discovery.
335 +6. `fediverse_signature_failures_total{domain, reason}` — détection abuse.
336 +
337 +### 5.5 Observabilité — état des challenges
338 +
339 +```mermaid
340 +stateDiagram-v2
341 + [*] --> Pending : initiate
342 + Pending --> Approved : user consent OK
343 + Pending --> Rejected : user consent KO
344 + Pending --> Expired : TTL 5min
345 + Pending --> Cancelled : revoke admin
346 + Approved --> [*]
347 + Rejected --> [*]
348 + Expired --> [*] : GC quotidien
349 + Cancelled --> [*] : GC quotidien
350 +```
351 +
352 +```mermaid
353 +stateDiagram-v2
354 + [*] --> Pending : admin invite
355 + Pending --> Accepted : remote user accept
356 + Pending --> Rejected : remote user reject
357 + Pending --> Expired : TTL 7j
358 + Pending --> Revoked : admin revoke
359 + Accepted --> [*]
360 + Rejected --> [*]
361 + Expired --> [*] : GC quotidien
362 + Revoked --> [*] : GC quotidien
363 +```
364 +
365 +Chaque transition doit incrémenter une métrique et logger un événement audit.
366 +
367 +## 6. Plan d'exécution
368 +
369 +### Itération 0 — Mise en place environnement (1j) — ✅ LIVRÉ (2026-05-10)
370 +- ✅ `tests/fediverse-testbed/` au lieu de `docker-compose.fediverse.yml` à la racine ; pas de caddy (HTTP plain via `FEDIVERSE_DEBUG_LOCAL_FEDERATION=true`).
371 +- ✅ 4 scripts cycle de vie : `setup.sh`, `start.sh`, `stop.sh`, `teardown.sh` ; seed Happy Days + trust list configurée par psql direct.
372 +- ✅ Plan détaillé : [2026-05-10-iter0-fediverse-docker-compose.md](2026-05-10-iter0-fediverse-docker-compose.md).
373 +
374 +### Itération 1 — S1 SSO bilatéral automatisé (2j) — ✅ LIVRÉ (2026-05-11)
375 +- ✅ 14 tests E2E dans `crates/rustwarden-core/tests/e2e_fediverse.rs` (1a/1b/1c/1d).
376 +- ✅ Findings F1, F2, F3 trouvés et corrigés ; RFC 0025 (back-channel `Accept(AuthRequest)`) écrite et acceptée.
377 +- ✅ Round-trip signé bout-en-bout via crypto réelle (RSA-2048 + draft-cavage).
378 +- Plan détaillé : [2026-05-11-fediverse-stable-promotion-iter1-findings.md](2026-05-11-fediverse-stable-promotion-iter1-findings.md).
379 +
380 +### Itération 2 — S2 Invite cross-instance (2j) — ✅ LIVRÉ flux B-only (2026-05-11)
381 +- ✅ 4 tests E2E (admin invite → team_member binding, reject, revoke, IdP listing).
382 +- 🟡 F4–F7 différés (back-channel AP `Invite`/`Accept(Invite)` — UX non-bloquant).
383 +- Plan détaillé : [2026-05-11-fediverse-evaluation-iter2-3-findings.md](2026-05-11-fediverse-evaluation-iter2-3-findings.md).
384 +
385 +### Itération 3 — S3 Trois instances pas de transitivité (1j) — ✅ LIVRÉ (2026-05-11)
386 +- ✅ 4 tests E2E (non-transitivité écriture, permanence, untrusted rejected 403, trusted passe).
387 +- ✅ **Finding F8 corrigé** (la trust list était décorative au niveau protocole) — bloquant promotion stable levé.
388 +- ✅ `FediverseError::Forbidden` ajouté + `FediverseTrustService::assert_domain_allowed` câblé dans `auth_inbox` et `/inbox`.
389 +- Plan détaillé : [2026-05-11-fediverse-evaluation-iter2-3-findings.md](2026-05-11-fediverse-evaluation-iter2-3-findings.md).
390 +
391 +### Itération 4 — S4 Block / révocation (1j) — ✅ LIVRÉ (2026-05-11)
392 +- ✅ 4 tests E2E (`s4_blocked_domain_payload_is_rejected_with_403`, `s4_kill_domain_marks_remote_identities_disabled`, `s4_add_blocked_domain_auto_kills_remote_identities`, `s4_kill_domain_invalidates_active_remote_jwt`).
393 +- ✅ **Finding F9** corrigé : `add_blocked_domain` UI handler effectue désormais atomiquement INSERT blocklist + UPDATE `disabled_at` sur `remote_identities`.
394 +- ✅ **Finding F10** corrigé : middleware `AuthService` vérifie `remote_identities.disabled_at` au validation JWT — un JWT remote dont l'identité backing a été désactivée est immédiatement rejeté (redirect /login). Pas besoin d'entrée `jwt_blacklist` séparée.
395 +- Plan détaillé : [2026-05-11-fediverse-evaluation-iter2-3-findings.md](2026-05-11-fediverse-evaluation-iter2-3-findings.md).
396 +
397 +### Itération 5 — S5 Modes de panne (2j) — ✅ LIVRÉ (2026-05-11)
398 +- ✅ 4 tests négatifs (`s5_replay_authrequest_with_same_nonce_rejected`, `s5_webfinger_returns_404_for_unknown_user`, `s5_initiate_rejects_malformed_handle`, `s5_finding_f11_clock_skew_not_enforced_with_debug_verifier`).
399 +- ✅ Replay protection : contrainte UNIQUE sur `fediverse_auth_challenges.nonce` empêche le rejeu.
400 +- 🟡 **Finding F11** documenté : clock skew non-enforced avec `AlwaysAcceptVerifier` (debug). Le real `HttpSigVerifier` (prod) le fait. Non-bloquant car testbed est dev-only.
401 +- Cases non couverts E2E : réseau down (nécessiterait proxy iptables ou middleware tower simulant timeout), Instance OOM. Test via observation manuelle des métriques outbound queue.
402 +
403 +### Itération 6 — S6 Lifecycle Update/Delete (1j) — ✅ LIVRÉ (2026-05-11)
404 +- ✅ 2 tests E2E (`s6_update_person_refreshes_cached_display_name`, `s6_delete_person_disables_remote_identity_and_kills_active_jwt`).
405 +- ✅ Confirme que `dispatch_update` / `dispatch_delete` côté `/inbox` (RFC 0010) tournent correctement contre les payloads AP `Update(Person)` et `Delete(Person)`.
406 +- ✅ Validation end-to-end de la chaîne **Delete → disabled_at → F10 JWT rejection** : un Delete(Person) signé d'alpha invalide instantanément les sessions actives sur beta.
407 +
408 +### Itération 7 — Bench performance (1j) — 🟡 DIFFÉRÉ post-promotion
409 +- Non-bloquant pour promotion stable v0.x ; pertinent pour SLA prod.
410 +- Action future : script `tests/bench_fediverse/` + capture p95.
411 +
412 +### Itération 8 — Audit privacy + sécurité (1j) — ✅ LIVRÉ partiel (2026-05-11)
413 +- ✅ Audit privacy manuel : Person AP / WebFinger / NodeInfo / UserSummary / logs — aucune fuite (email, password_hash, rôles, IPs, clé privée). Finding F12 latent (`summary_html` non sanitized, mais jamais rendu UI).
414 +- ✅ Replay protection validée par S5 (`s5_replay_authrequest_with_same_nonce_rejected`).
415 +- 🟡 Rate-limit + lockout — non implémentés. Action future : ajouter `tower::limit` + table `auth_attempt_lockout`. ~1 jour.
416 +- 🟡 Pen test léger formel — partiellement couvert par négatifs S1/S3/S5. Action future : 2-3h dédiées.
417 +
418 +### Itération 9 — Documentation opérateur (1j) — ✅ LIVRÉ (2026-05-11)
419 +- ✅ [`docs/fediverse-operations.md`](../fediverse-operations.md) écrit (10 sections Diátaxis how-to + reference) : activation, env vars, `app_settings`, trust/block list, incident playbook, observabilité, GC, critères promotion, URLs exposées, limites connues.
420 +- 🟡 Revue par un tiers à programmer.
421 +
422 +### Itération 10 — Promotion (0.5j)
423 +- Renommer `experimental-fediverse` → `fediverse` dans tous les `Cargo.toml`.
424 +- Entrée CHANGELOG : "BREAKING: feature renamed".
425 +- Bump de version selon politique projet.
426 +- Annonce dans README.
427 +
428 +**Total estimé** : ~12 jours-ouvrés (itérations séquentielles, certaines parallélisables).
429 +
430 +## 7. Quality gates de promotion
431 +
432 +Avant le merge final qui renomme le flag, **tous** ces points doivent être verts :
433 +
434 +```mermaid
435 +flowchart LR
436 + subgraph Tests["Tests"]
437 + T1[S1 SSO ✓]
438 + T2[S2 Invite ✓]
439 + T3[S3 No transitive ✓]
440 + T4[S4 Block ✓]
441 + T5[S5 Failures ✓]
442 + T6[S6 Lifecycle ✓]
443 + end
444 + subgraph Quality["Qualité"]
445 + Q1[Build clean]
446 + Q2[Clippy -D warnings]
447 + Q3[Tests unit + E2E green]
448 + Q4[Doc clean]
449 + Q5[cargo audit clean]
450 + end
451 + subgraph NF["Non-fonctionnel"]
452 + N1[Perf p95 OK]
453 + N2[6 métriques émises]
454 + N3[Audit privacy passé]
455 + N4[Pen test léger OK]
456 + end
457 + subgraph Doc["Documentation"]
458 + D1[Guide opérateur]
459 + D2[Troubleshooting]
460 + D3[Revue tierce passée]
461 + end
462 + Tests --> Promotion[Promotion fediverse stable]
463 + Quality --> Promotion
464 + NF --> Promotion
465 + Doc --> Promotion
466 +```
467 +
468 +Si **un seul** item est rouge → on reporte la promotion. Pas de "on verra ça après".
469 +
470 +## 8. Risques identifiés
471 +
472 +| Risque | Impact | Mitigation |
473 +|---|---|---|
474 +| Tests E2E flaky (timing réseau) | CI rouge intermittent | Retries explicites + timeouts généreux + `MockClock` pour TTL |
475 +| Bench non reproductible (charge machine) | Faux négatifs perf | Bench tagué nightly, pas blocant CI standard |
476 +| Bugs découverts tard dans l'itération 9 | Glissement | Si bug **critique** trouvé après itération 7, retour itération concernée — pas de raccourci |
477 +| Spec ActivityPub interprétée différemment de Mastodon | Interop limitée | Documenter explicitement les choix d'implémentation, pas de promesse Mastodon-compat |
478 +
479 +## 9. Hors périmètre (explicite)
480 +
481 +Ne **pas** chercher à valider dans cette campagne :
482 +- Interop Mastodon réel (autre campagne, autre coût, autre cible).
483 +- Notes / timelines / followers / fanout social (jamais dans Rustwarden).
484 +- Move activity (rare, marginal pour SSO).
485 +- Rotation de clés RSA actor (RFC future, pas bloquant pour stable v1).
486 +
487 +## 10. Annexe — commandes utiles
488 +
489 +```bash
490 +# Lancer la topologie 3 instances
491 +docker compose -f docker-compose.fediverse.yml up -d
492 +
493 +# Lancer un seul scénario
494 +cargo test --test e2e_fediverse s1_sso_bilateral --features experimental-fediverse
495 +
496 +# Lancer toute la suite E2E
497 +cargo test --test e2e_fediverse --features experimental-fediverse -- --test-threads=1
498 +
499 +# Bench perf
500 +cargo bench --bench fediverse_perf --features experimental-fediverse
501 +
502 +# Audit privacy (grep des champs sensibles)
503 +rg --type rust 'serialize|to_value' crates/rustwarden-core/src/fediverse/
504 +
505 +# Vérifier les 6 métriques
506 +curl -s http://alpha.local:3001/metrics | grep fediverse_
507 +
508 +# Lecture des logs structurés en cas d'incident
509 +docker compose logs rustwarden-app-a | jq 'select(.target | startswith("rustwarden::fediverse"))'
510 +```
511 +
512 +## 11. Critère d'arrêt anticipé
513 +
514 +Si pendant l'itération 8 (audit sécurité/privacy) on découvre une faille **fondamentale** dans le protocole tel qu'implémenté (ex. : minimisation impossible sans casser un flux, ou trust transitif accidentel non corrigeable sans refonte), **on n'avance pas vers la promotion**. On retourne sur le RFC concerné, on accepte de rester en `experimental-*` quelques mois de plus, et on documente la décision dans le CHANGELOG.
515 +
516 +L'objectif de cette campagne n'est pas "promouvoir coûte que coûte" mais "obtenir des preuves objectives qu'on peut promouvoir sans regret".
517 +
518 +---
519 +
520 +**Prochaine action concrète** : valider le plan, choisir l'option de topologie (1, 2 ou 3), ouvrir l'itération 0.
A docs/plans/2026-05-10-iter0-fediverse-docker-compose.md
+157 -0
@@ -0,0 +1,157 @@
1 +# Itération 0 — docker-compose.fediverse.yml : topologie 3 instances locale, 100 % automatisable
2 +
3 +## Contexte
4 +
5 +Pour évaluer la promotion `experimental-fediverse` → `fediverse` stable, le plan parent ([docs/plans/2026-05-09-fediverse-identity-federation.md](docs/plans/2026-05-09-fediverse-identity-federation.md) et [docs/plans/2026-05-10-fediverse-promotion-stable-evaluation.md](docs/plans/2026-05-10-fediverse-promotion-stable-evaluation.md)) requiert une topologie de 3 instances Rustwarden distinctes pour valider l'identité fédérée Rustwarden↔Rustwarden. L'utilisateur veut savoir si l'**option 1** (docker-compose) est totalement automatisable et exploitable en local.
6 +
7 +**Réponse : oui**, et avec une simplification importante par rapport à ma première proposition.
8 +
9 +### Découvertes qui changent le design
10 +
11 +| Découverte | Source | Conséquence |
12 +|---|---|---|
13 +| Flag `FEDIVERSE_DEBUG_LOCAL_FEDERATION=true` accepte HTTP plain | `crates/rustwarden-core/src/config/fediverse.rs:120` (et l.427 pour le bypass HTTPS) | **Caddy/TLS supprimés** — gain énorme en simplicité |
14 +| `APP_DEBUG=true` + `COOKIE_SECURE=false` permettent cookies HTTP plain | `crates/rustwarden-core/src/config/auth.rs:171-188` | Le frontend UI fonctionne sans HTTPS |
15 +| Aucun Dockerfile pour `rustwarden-app` n'existe | exploration | À créer (50 lignes, multi-stage) |
16 +| `/health` existe déjà | `crates/rustwarden-ui/src/handlers/health.rs` | healthchecks compose triviaux |
17 +| Migrations via `run_migrations(db)` au démarrage de l'app (pas via binaire `migrate` séparé) | `crates/rustwarden-core/migrations/src/lib.rs:83` | Pas besoin d'un service `migrate` séparé en compose |
18 +| Clés RSA AP générées à la demande (`actor_key_service::ensure_keys`) | `crates/rustwarden-core/src/services/actor_key_service.rs:95` | Le seed n'a rien à pré-générer |
19 +
20 +## Architecture cible
21 +
22 +```
23 +docker-compose.fediverse.yml (à créer à la racine)
24 +├── postgres-alpha : PostgreSQL 18, DB rustwarden_alpha, port 5433
25 +├── postgres-beta : idem, DB rustwarden_beta, port 5434
26 +├── postgres-gamma : idem, DB rustwarden_gamma, port 5435
27 +├── app-alpha : rustwarden-app, FEDIVERSE_DOMAIN=alpha.local, port 3001
28 +├── app-beta : idem, beta.local, port 3002
29 +├── app-gamma : idem, gamma.local, port 3003
30 +└── seed-alpha/beta/gamma : job one-shot qui appelle le binaire seed une fois la DB ready
31 +```
32 +
33 +Réseau docker unique `fediverse-net`. Chaque service `app-*` a un `network_alias` correspondant à son `FEDIVERSE_DOMAIN`, ce qui permet aux 3 apps de se parler **en interne** via leur nom de domaine logique (`http://alpha.local:3000`) sans `/etc/hosts` ni reverse proxy.
34 +
35 +Pour l'**hôte** (développeur ou tests CLI depuis la machine), exposition via ports : `localhost:3001` → app-alpha, etc. Si l'utilisateur veut taper `http://alpha.local:3001` dans son navigateur, **une seule ligne à ajouter à `/etc/hosts`** (alternative facultative).
36 +
37 +## Fichiers à produire
38 +
39 +**Tout vit dans un seul dossier dédié** : `tests/fediverse-testbed/`. Aucun fichier infra à la racine du repo, aucune pollution du workspace.
40 +
41 +```
42 +tests/fediverse-testbed/ ← dossier unique, isolé
43 +├── Dockerfile # multi-stage build de rustwarden-app
44 +├── docker-compose.yml # 3 PG + 3 app + 3 seed-jobs
45 +├── env/
46 +│ ├── alpha.env # env vars instance A
47 +│ ├── beta.env # env vars instance B
48 +│ └── gamma.env # env vars instance C
49 +├── scripts/
50 +│ ├── setup.sh # up --wait + post-seed trust lists
51 +│ ├── teardown.sh # down -v (purge volumes)
52 +│ └── configure-trust.sh # script curl appelé par setup.sh
53 +├── .gitignore # ignore data/, *.log, .env.local
54 +└── README.md # mode d'emploi opérateur
55 +```
56 +
57 +| Fichier | Rôle | LoC approx |
58 +|---|---|---|
59 +| `tests/fediverse-testbed/Dockerfile` | Multi-stage Rust build → image runtime mince (debian-slim ou distroless), feature `experimental-fediverse` activée | ~40 |
60 +| `tests/fediverse-testbed/docker-compose.yml` | 6 services (3 PG + 3 app) + 3 jobs seed + healthchecks + dépendances | ~150 |
61 +| `tests/fediverse-testbed/env/{alpha,beta,gamma}.env` | env vars par instance (DB, JWT, ENCRYPTION_KEY, FEDIVERSE_DOMAIN) | ~15 chacun |
62 +| `tests/fediverse-testbed/scripts/setup.sh` | `docker compose up -d --wait` + attente healthchecks + appel à configure-trust.sh | ~50 |
63 +| `tests/fediverse-testbed/scripts/teardown.sh` | `docker compose down -v` (purge volumes pour reproductibilité) | ~10 |
64 +| `tests/fediverse-testbed/scripts/configure-trust.sh` | `curl` sur les API admin des 3 instances pour cross-trust mutuel | ~40 |
65 +| `tests/fediverse-testbed/README.md` | Mode d'emploi : `cd tests/fediverse-testbed && ./scripts/setup.sh` | ~50 |
66 +| `tests/fediverse-testbed/.gitignore` | Ignore `data/`, `*.log`, fichiers locaux | ~5 |
67 +
68 +**Aucune modification du code Rust n'est nécessaire pour cette itération.** Tout est de l'infra autour du binaire existant.
69 +
70 +Les tests E2E Rust eux-mêmes (`cargo test`) viendront dans les itérations 1+ et iront dans `crates/rustwarden-core/tests/e2e_fediverse/` (intégration cargo standard), pas dans ce dossier infra. **Séparation nette : `tests/fediverse-testbed/` = infra docker, `crates/*/tests/` = code Rust de test.**
71 +
72 +## Détail des variables d'env par instance (factuel, dérivé de l'exploration)
73 +
74 +Toutes obligatoires sauf marqué défaut. Communes aux 3 instances avec valeurs distinctes :
75 +
76 +```
77 +DATABASE_URL=postgres://rustwarden:rustwarden@postgres-<alpha|beta|gamma>:5432/rustwarden_<alpha|beta|gamma>
78 +JWT_SECRET=<32+ bytes générés par seed, distincts par instance>
79 +ENCRYPTION_KEY=<64 hex chars distincts par instance>
80 +SERVER_HOST=0.0.0.0
81 +SERVER_PORT=3000 # même port en interne, mappé différemment en externe
82 +FEDIVERSE_DOMAIN=<alpha|beta|gamma>.local
83 +FEDIVERSE_DEBUG_LOCAL_FEDERATION=true # clé du dispositif : autorise HTTP
84 +APP_DEBUG=true # cookies non Secure
85 +COOKIE_SECURE=false
86 +SESSION_BACKEND=seaorm
87 +JWT_ISSUER=rustwarden-<alpha|beta|gamma>
88 +```
89 +
90 +`FEDIVERSE_DOMAIN=alpha.local` couplé à `network_aliases: [alpha.local]` sur le service `app-alpha` permet aux deux autres apps de l'atteindre via `http://alpha.local:3000` sans configuration DNS supplémentaire.
91 +
92 +## Pourquoi c'est 100 % automatisable
93 +
94 +1. **Aucune intervention manuelle entre `docker compose up` et `cargo test`.**
95 + - DB créée par image Postgres officielle via variables `POSTGRES_DB/USER/PASSWORD`.
96 + - Migrations exécutées par l'app au démarrage (`run_migrations` dans le bootstrap).
97 + - Seed admin lancé en job `depends_on` avec `condition: service_healthy`.
98 + - Trust list configurée par script `curl` post-démarrage frappant `/admin/fediverse/trust` via JWT admin.
99 +
100 +2. **Aucune dépendance hôte hors Docker.** Pas de `/etc/hosts`, pas de TLS, pas de mkcert, pas de DNS local. Seul prérequis : Docker installé. La résolution `alpha.local` se fait dans le réseau Docker via `network_aliases`.
101 +
102 +3. **Reproductible.** `teardown.sh` purge les volumes ; `setup.sh` repart à zéro. Pas de fuite d'état entre runs.
103 +
104 +4. **Exploitable en CI.** Le même `docker compose up --wait` fonctionne en GitHub Actions / GitLab CI (services Docker disponibles partout). Les seuls coûts : ~30 s de démarrage (compile Rust en image cachée) + ~10 s de démarrage runtime des 6 services.
105 +
106 +5. **Pas besoin de Mastodon ni d'instance externe.** L'objectif est Rustwarden ↔ Rustwarden ; aucune dépendance réseau externe.
107 +
108 +## Limites connues à documenter dans le README
109 +
110 +- L'accès humain au navigateur depuis l'hôte requiert soit `localhost:3001` (mais alors les URLs AP retournées par l'API ne sont pas joignables côté humain car contiennent `alpha.local`), soit ajout `/etc/hosts` ponctuel. Pour les tests automatisés, sans navigateur, **non bloquant**.
111 +- HTTP plain : à n'utiliser qu'en local. Le flag `FEDIVERSE_DEBUG_LOCAL_FEDERATION` impose un check qui refuse `debug_local_federation=true` en production (à vérifier dans `config/fediverse.rs:120-135` — si pas déjà fait, ajout à prévoir hors périmètre de cette itération).
112 +- L'image Docker à compiler à chaque modif source. Mitigation : Dockerfile multi-stage avec layer cache sur `cargo chef` ou équivalent — gain ~80 % sur recompil incrémentale.
113 +
114 +## Vérification end-to-end
115 +
116 +Une fois les fichiers produits, l'exécution attendue est :
117 +
118 +```bash
119 +cd tests/fediverse-testbed
120 +
121 +# Démarrage complet, attente readiness
122 +./scripts/setup.sh
123 +
124 +# Vérifications manuelles minimales (depuis l'hôte, ports mappés)
125 +curl http://localhost:3001/health # 200 OK
126 +curl http://localhost:3002/health # 200 OK
127 +curl http://localhost:3003/health # 200 OK
128 +
129 +# WebFinger croisé
130 +curl 'http://localhost:3001/.well-known/webfinger?resource=acct:admin@alpha.local'
131 +
132 +# Inspection trust list configurée par configure-trust.sh
133 +curl -H "Authorization: Bearer <admin_jwt_alpha>" \
134 + http://localhost:3001/admin/fediverse/trust
135 +# → doit contenir beta.local et gamma.local
136 +
137 +# Lancement de la suite E2E (itérations 1+, ailleurs dans crates/*/tests/)
138 +cd ../..
139 +cargo test --test e2e_fediverse --features experimental-fediverse -- --test-threads=1
140 +
141 +# Nettoyage (depuis tests/fediverse-testbed/)
142 +./scripts/teardown.sh
143 +```
144 +
145 +**Critère de succès de l'itération 0** :
146 +1. `setup.sh` retourne 0 sans intervention manuelle, en moins de 60 s à froid (build inclus) ou 15 s à chaud (image cachée).
147 +2. Les 3 endpoints `/health` répondent 200.
148 +3. Le WebFinger d'admin sur chaque instance répond avec un payload AP valide.
149 +4. La trust list de chaque instance contient bien les 2 autres après `setup.sh`.
150 +5. `teardown.sh` laisse zéro container et zéro volume.
151 +
152 +## Post-actions obligatoires (CLAUDE.md du projet)
153 +
154 +Une fois ce plan validé via ExitPlanMode :
155 +1. Copier ce fichier vers `docs/plans/2026-05-10-iter0-fediverse-docker-compose.md` (nom descriptif).
156 +2. Supprimer `~/.claude/plans/logical-zooming-twilight.md`.
157 +3. Travailler ensuite uniquement depuis la version `docs/plans/`.
A docs/plans/2026-05-11-extract-rustwarden-fediverse-crate.md
+371 -0
@@ -0,0 +1,371 @@
1 +# Extraction d'un crate `rustwarden-fediverse`
2 +
3 +## Contexte
4 +
5 +Le module fediverse de `rustwarden-core` a atteint ~3815 LoC (`fediverse/*` + services + models + config). En face, `rustwarden-ui` héberge **2747 lignes de handlers fediverse** (`admin_fediverse.rs`: 1694, `fediverse_user_auth.rs`: 1053), **12 templates Askama**, **5 namespaces i18n** (~250+ clés), un endpoint **SSE** stateful, des migrations dédiées, 25 RFCs et des tests e2e + interop. Phases 1–5 marquées complètes, tests passent.
6 +
7 +Coût actuel du couplage in-crate :
8 +- Feature flag `experimental-fediverse` pollue ~29 sites de `rustwarden-core` + `rustwarden-ui`
9 +- Compile time : `activitypub_federation`, `rsa`, `reqwest`, `ammonia`, `url` lient même quand le code est désactivé
10 +- Surface mentale : contributeur travaillant sur l'auth standard lit du code fediverse dans `core`
11 +- Pas d'API pour qu'un consommateur tiers étende la whitelist d'auth (cf. Q4)
12 +
13 +Analyse antérieure (RFC 0002, Alt. C) avait rejeté l'extraction sur 3 critères (~1500 LoC, monoproducteur, migration future facile) — les 3 sont retournés : volume × 2.5, multi-consommateurs (ui+app+template+cli), API stabilisée, plusieurs findings (F8) qui imposent un point d'attention à l'import des routes protocole.
14 +
15 +Objectif :
16 +1. supprimer le feature flag `experimental-fediverse` de `rustwarden-core` (le crate optionnel = la feature)
17 +2. permettre des releases découplées
18 +3. isoler les dépendances lourdes
19 +4. corriger au passage l'anti-pattern de la whitelist d'auth hardcodée
20 +5. ouvrir la voie à des intégrateurs tiers qui veulent juste l'auth+RBAC
21 +
22 +## Décisions arbitrées
23 +
24 +| # | Question | Décision |
25 +|---|---|---|
26 +| Q1 | `actor_key_service` | Déplacer dans `rustwarden-fediverse` |
27 +| Q2 | `remote_identity` (modèle + table) | Déplacer dans `rustwarden-fediverse` + trait `RemoteIdentityRevocationChecker` côté core |
28 +| Q3 | Agrégation migrations | Migrator par crate, composés par le binaire `migrate` |
29 +| Q4 | Whitelist routes protégées | Registry public extensible dans `core` (corrige aussi le défaut UI) |
30 +
31 +UI fediverse → **reste dans `rustwarden-ui`** (pas de crate `rustwarden-fediverse-ui` séparé). Les handlers consomment `rustwarden-fediverse` via dépendance optionnelle gated par feature `fediverse`.
32 +
33 +## Inventaire des modules déplacés
34 +
35 +### Vers `crates/rustwarden-fediverse/src/`
36 +
37 +**Module fediverse** (14 fichiers) :
38 +- `mod.rs`, `routes.rs`, `actor.rs`, `invite_routes.rs`, `state.rs`, `nodeinfo.rs`, `webfinger.rs`, `http_signatures.rs`, `auth_routes.rs`, `types.rs`, `error.rs`, `resolver.rs`, `fetcher.rs`, `inbox.rs`
39 +
40 +**Services** (6 fichiers) :
41 +- `fediverse_auth_service.rs`, `fediverse_invite_service.rs`, `fediverse_outbound_service.rs`, `fediverse_signal_service.rs`, `fediverse_trust_service.rs`
42 +- `actor_key_service.rs` (Q1)
43 +
44 +**Modèles SeaORM** (6 fichiers) :
45 +- `fediverse_outbound_queue.rs`, `fediverse_auth_challenge.rs`, `fediverse_trusted_domain.rs`, `fediverse_blocked_domain.rs`, `fediverse_pending_invite.rs`
46 +- `remote_identity.rs` (Q2)
47 +
48 +**Config** : `config/fediverse.rs` → `src/config.rs`
49 +
50 +**Migrations** (4 fichiers) → `crates/rustwarden-fediverse/migrations/` :
51 +- `m20260510_000010_fediverse_trust_and_blocks.rs`
52 +- `m20260510_000012_fediverse_outbound_queue.rs`
53 +- `m20260510_000013_fediverse_auth_challenges.rs`
54 +- `m20260510_000015_fediverse_pending_invites.rs`
55 +
56 +**Tests** → `crates/rustwarden-fediverse/tests/` :
57 +- `e2e_fediverse.rs`
58 +- `fediverse_interop.rs`
59 +
60 +### Reste dans `rustwarden-core`
61 +
62 +- Modèles partagés : `user`, `team`, `team_member`, `app_settings`, etc.
63 +- Services partagés : `team_service`, `app_settings_service`, `jwt_blacklist_service`
64 +- `middleware/auth_service.rs` — refactor pour registry + trait `RemoteIdentityRevocationChecker`
65 +- `config/auth.rs` (`AuthConfig`), `error::AppError`, traits `Identity`/`IdentityResolver`, JWT claims, `i18n`
66 +
67 +### Reste dans `rustwarden-ui` (mais dépend désormais de `rustwarden-fediverse`)
68 +
69 +- `handlers/admin_fediverse.rs` (1694 lignes)
70 +- `handlers/fediverse_user_auth.rs` (1053 lignes)
71 +- 12 templates Askama (cf. section UI ci-dessous)
72 +- 5 namespaces i18n dans `crates/rustwarden-ui/locales/{fr,en,…}.yml`
73 +- Routes gated dans `crates/rustwarden-ui/src/routes.rs`
74 +
75 +## Direction de dépendance
76 +
77 +```
78 +rustwarden-fediverse ──depends on──> rustwarden-core
79 + ▲ ▲
80 + │ │
81 + └──── rustwarden-ui (feature "fediverse") ─┘
82 + ▲
83 + │
84 + app/main.rs
85 +```
86 +
87 +Acyclique. `rustwarden-fediverse` importe `User`, `Team`, `TeamMember`, `AuthConfig`, `AppError`, traits `Identity`/`IdentityResolver`, `JwtBlacklistService`, `csrf`, `i18n`. `rustwarden-ui` ajoute une dep `rustwarden-fediverse` derrière sa feature.
88 +
89 +## Surface UI fediverse à migrer (rustwarden-ui)
90 +
91 +### Handlers admin (RFC 0021 + Slice C — 13 fonctions)
92 +[crates/rustwarden-ui/src/handlers/admin_fediverse.rs](crates/rustwarden-ui/src/handlers/admin_fediverse.rs)
93 +
94 +| Fonction | Route | Verbe | RFC |
95 +|---|---|---|---|
96 +| `get_settings` | `/admin/fediverse/settings` | GET | 0021 |
97 +| `update_settings` | `/admin/fediverse/settings` | POST | 0021 |
98 +| `get_trust_list` | `/admin/fediverse/trust_list` | GET | 0021 |
99 +| `add_trust_entry` | `/admin/fediverse/trust_list/add` | POST | 0021 |
100 +| `delete_trust_entry` | `/admin/fediverse/trust_list/delete` | POST | 0021 |
101 +| `get_queue` | `/admin/fediverse/queue` | GET | 0024 |
102 +| `get_sessions_remote` | `/admin/fediverse/sessions_remote` | GET | 0024 |
103 +| `revoke_remote_session` | `/admin/fediverse/sessions_remote/revoke` | POST | 0024 |
104 +| `kill_domain` | `/admin/fediverse/sessions_remote/kill_domain` | POST | 0024 |
105 +| `get_nodeinfo` | `/admin/fediverse/nodeinfo` | GET | 0024 |
106 +| `get_team_invites` | `/admin/teams/{team_id}/invites` | GET | 0024 |
107 +| `create_team_invite` | `/admin/teams/{team_id}/invites/add` | POST | 0024 |
108 +| `revoke_team_invite` | `/admin/teams/{team_id}/invites/revoke` | POST | 0024 |
109 +
110 +### Handlers user-auth (RFC 0023 + Slice C — 10 fonctions)
111 +[crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs](crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs)
112 +
113 +| Fonction | Route | Verbe | RFC |
114 +|---|---|---|---|
115 +| `error` | `/login/fediverse/error` | GET | 0023 |
116 +| `login_form` | `/login/fediverse` | GET | 0023 |
117 +| `login_initiate` | `/login/fediverse` | POST | 0023 |
118 +| `waiting` | `/login/fediverse/waiting/{id}` | GET | 0023 |
119 +| `sse_status` | `/login/fediverse/sse/{id}` | GET (SSE) | 0023 |
120 +| `consent_form` | `/fediverse/consent/{id}` | GET | 0023 |
121 +| `consent_submit` | `/fediverse/consent/{id}` | POST | 0023 |
122 +| `connected_apps` | `/profile/connected_apps` | GET | 0024 |
123 +| `invite_landing_form` | `/fediverse/invite/{id}` | GET | 0024 |
124 +| `invite_landing_submit` | `/fediverse/invite/{id}` | POST | 0024 |
125 +
126 +### Templates Askama (12 fichiers)
127 +[crates/rustwarden-ui/templates/admin/fediverse/](crates/rustwarden-ui/templates/admin/fediverse/) — `settings.html`, `trust_list.html`, `queue.html`, `sessions_remote.html`, `nodeinfo.html`, `team_invites.html`
128 +[crates/rustwarden-ui/templates/fediverse/](crates/rustwarden-ui/templates/fediverse/) — `login_form.html`, `login_waiting.html` (contient le client EventSource SSE), `consent_screen.html`, `error_unauthorized.html`, `invite_landing.html`
129 +[crates/rustwarden-ui/templates/profile/fediverse_connected_apps.html](crates/rustwarden-ui/templates/profile/fediverse_connected_apps.html)
130 +
131 +Aucun template ne nécessite de déplacement de fichier (ils restent dans `rustwarden-ui`), mais leurs structs `#[derive(Template)]` doivent être réimportées si elles référencent des types `rustwarden_core::fediverse::*`.
132 +
133 +### i18n
134 +[crates/rustwarden-ui/locales/](crates/rustwarden-ui/locales/) — 5 namespaces fediverse dans `fr.yml` + `en.yml` (à confirmer : `de.yml`, `es.yml`, `it.yml`, `pt.yml` semblent ne pas avoir ces clés — **pré-requis à vérifier avant extraction**) :
135 +- `fediverse:` (settings/trust/auth/queue/sessions_remote — ~138 clés)
136 +- `fediverse_nodeinfo:` (~21 clés)
137 +- `fediverse_invites:` (~26 clés)
138 +- `fediverse_connected_apps:`
139 +- `fediverse_invite_landing:`
140 +
141 +**Décision i18n** : les clés restent dans `rustwarden-ui/locales/` (l'UI les consomme, le crate fediverse est headless). Pas de duplication.
142 +
143 +### SSE — endpoint stateful
144 +[fediverse_user_auth.rs](crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs) `sse_status` (~L402-448) : `axum::response::sse::{Event, KeepAlive, Sse}`, boucle DB poll sur `fediverse_auth_challenge`, émet `event: status` avec JSON `{state: pending|approved|rejected|expired|consumed}`. Client EventSource natif JS dans `login_waiting.html:21-76` (pas Alpine, pas HTMX).
145 +
146 +→ Le handler reste dans `rustwarden-ui` ; sa dep `rustwarden_fediverse::services::FediverseAuthService` + `rustwarden_fediverse::models::fediverse_auth_challenge` doit être rebindée Phase 3.
147 +
148 +### Imports à rebinder dans les 2 handlers UI
149 +
150 +**admin_fediverse.rs** (12 imports à changer) :
151 +- `rustwarden_core::services::fediverse_trust_service` → `rustwarden_fediverse::services::trust`
152 +- `rustwarden_core::services::fediverse_invite_service` → `rustwarden_fediverse::services::invite`
153 +- `rustwarden_core::services::fediverse_outbound_service` → `rustwarden_fediverse::services::outbound`
154 +- `rustwarden_core::models::fediverse_outbound_queue` → `rustwarden_fediverse::models::outbound_queue`
155 +- `rustwarden_core::models::remote_identity` → `rustwarden_fediverse::models::remote_identity`
156 +- `rustwarden_core::models::fediverse_pending_invite` → `rustwarden_fediverse::models::pending_invite`
157 +- inchangés : `auth::jwt::Claims`, `error::AppError`, `i18n::*`, `middleware::csrf`, `services::app_settings_service`, `services::team_service::TeamRole`, `models::{user, team}`
158 +
159 +**fediverse_user_auth.rs** (8 imports à changer) :
160 +- `rustwarden_core::services::fediverse_auth_service` → `rustwarden_fediverse::services::auth`
161 +- `rustwarden_core::services::fediverse_invite_service` → `rustwarden_fediverse::services::invite`
162 +- `rustwarden_core::services::fediverse_trust_service` → `rustwarden_fediverse::services::trust`
163 +- `rustwarden_core::services::fediverse_outbound_service` → `rustwarden_fediverse::services::outbound`
164 +- `rustwarden_core::config::FediverseConfig` → `rustwarden_fediverse::FediverseConfig`
165 +- `rustwarden_core::models::{fediverse_pending_invite, remote_identity, fediverse_auth_challenge}` → `rustwarden_fediverse::models::{pending_invite, remote_identity, auth_challenge}`
166 +- inchangés : `auth::jwt::Claims`, `error::AppError`, `i18n::*`, `middleware::csrf`
167 +
168 +### Tests UI existants à préserver
169 +6 unit tests dans [fediverse_user_auth.rs:987-1052](crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs) :
170 +- `error_reason_parses_known_values`, `error_reason_falls_back_to_unknown`, `error_reason_trims_and_lowercases`, `error_reason_round_trips_through_str`
171 +- `extract_handle_domain_accepts_canonical`, `extract_handle_domain_rejects_malformed`
172 +
173 +`admin_fediverse.rs` : aucun test. Acceptable pour cette PR — gap à combler dans une RFC future, pas dans le scope de l'extraction.
174 +
175 +### Mode dégradé (P9 du Plan UI HTML)
176 +Quand `fediverse_enabled=false` dans `AppSettings`, toutes les routes `/admin/fediverse/*` retournent 404 **sauf** `/admin/fediverse/settings` (l'écran de toggle). Les routes user-facing `/login/fediverse*` retournent aussi 404. Cette logique vit dans les handlers eux-mêmes (early return). → Logique conservée verbatim au déplacement.
177 +
178 +### CSP
179 +Alpine attribute-based + EventSource SSE natif → CSP existante reste compatible. Aucun nouveau `<script>` inline introduit. Vérification automatique : `cargo run -p rustwarden-app --features fediverse`, ouvrir `/login/fediverse`, vérifier 0 violation CSP dans la console.
180 +
181 +## Findings/dette préalable à traiter AVANT l'extraction
182 +
183 +Source : [docs/plans/2026-05-11-fediverse-stable-promotion-iter1-findings.md](docs/plans/2026-05-11-fediverse-stable-promotion-iter1-findings.md) et [iter2-3-findings.md](docs/plans/2026-05-11-fediverse-evaluation-iter2-3-findings.md).
184 +
185 +| ID | Gravité | État | Action requise avant extraction |
186 +|---|---|---|---|
187 +| **F8** | Bloquant | ✅ Corrigé | Garder l'appel `FediverseTrustService::assert_domain_allowed()` AVANT la vérification de signature dans les routes `/inbox`, `auth_routes`, `invite_routes`. À ne PAS perdre au déplacement. |
188 +| **Race `FEDIVERSE_DOMAIN`** | Bloquant tests | ⚠️ À corriger | Tests `e2e_fediverse.rs` mutent `std::env::set_var("FEDIVERSE_DOMAIN", …)` → non-thread-safe. Avant extraction : refactorer pour passer `FediverseConfig` par argument. Sinon les tests deviennent flaky une fois isolés dans un crate dédié. |
189 +| **F4** | UX différé | ⏸️ Documenter | `create_team_invite` ne queue pas le payload AP `Invite`. À tracker dans une RFC future. Ne bloque pas l'extraction. |
190 +| **F5** | UX différé | ⏸️ Documenter | `/inbox` ne dispatch pas le type `Invite`. Idem F4. |
191 +| **F6** | UX différé | ⏸️ Documenter | `record_inbound_invite` manquant. |
192 +| **F7** | UX différé | ⏸️ Documenter | `Accept(Invite)` back-channel absent. |
193 +
194 +Étape 0 du plan d'exécution : appliquer le fix race `FEDIVERSE_DOMAIN` AVANT Phase 1.
195 +
196 +## Q4 — Registry de routes protégées (refactor middleware)
197 +
198 +[auth_service.rs:67-75](crates/rustwarden-core/src/middleware/auth_service.rs#L67-L75) hardcode 7 préfixes (`/home`, `/profile`, `/test-base`, `/settings`, `/admin`, `/fediverse/consent`, `/fediverse/invite`). Non extensible.
199 +
200 +Refactor :
201 +- `AuthService::new(inner, db, protected_prefixes: Arc<Vec<String>>)` au lieu de `AuthService::new(inner, db)`
202 +- `RustwardenBuilder::register_protected_prefix(&mut self, prefix: &str) -> &mut Self` accumule
203 +- `RustwardenBuilder::build()` passe la liste accumulée à `AuthService`
204 +- `rustwarden-ui::default_routes()` enregistre `/home`, `/profile`, `/test-base`, `/settings`, `/admin` (lignes accumulées dans `default_routes`)
205 +- `rustwarden_fediverse::mount()` enregistre `/fediverse/consent`, `/fediverse/invite`
206 +
207 +Effet de bord positif : une appli tierce headless peut maintenant écrire `builder.register_protected_prefix("/api/v1/secure")` au lieu de forker.
208 +
209 +## Q3 — Migrator agrégé
210 +
211 +- `rustwarden_fediverse::migrations::Migrator` (impl `MigratorTrait`) expose ses 4 migrations
212 +- Binaire `migrate` dans `rustwarden-core/bin/migrate.rs` compose :
213 + ```rust
214 + let mut migrators: Vec<Box<dyn MigratorTrait>> = vec![Box::new(CoreMigrator)];
215 + #[cfg(feature = "fediverse")]
216 + migrators.push(Box::new(rustwarden_fediverse::migrations::Migrator));
217 + for m in migrators { m.up(&db, None).await?; }
218 + ```
219 +- Ordre déterministe par numéro chronologique. Les FK fediverse → users/teams marchent car users/teams sont créés en 0000xx (avant 000010).
220 +- `rustwarden-core` doit aussi exposer une feature `fediverse` (ou similaire) côté binaire `migrate` pour conditionnellement linker `rustwarden_fediverse`. Cette feature n'allume PAS de code dans le crate `rustwarden-core` lib lui-même.
221 +
222 +## Q2 — Conséquences du déplacement de `remote_identity`
223 +
224 +Le middleware `AuthService` contient un check gated [auth_service.rs:168-196](crates/rustwarden-core/src/middleware/auth_service.rs#L168-L196) qui révoque les JWT remote dont `remote_identities.disabled_at` est non-null (F10 / RFC 0018). Ce code dépend du modèle qui bouge.
225 +
226 +Solution : exposer dans `core` un trait
227 +
228 +```rust
229 +#[async_trait]
230 +pub trait RemoteIdentityRevocationChecker: Send + Sync {
231 + async fn is_revoked(&self, remote_identity_id: i32) -> bool;
232 +}
233 +```
234 +
235 +et l'injecter via `Extension<Arc<dyn RemoteIdentityRevocationChecker>>`. `rustwarden_fediverse` fournit l'impl par défaut au moment du `mount()`. Si l'extension est absente, le check est sauté (= comportement actuel quand la feature est off).
236 +
237 +## Feature flag : avant / après
238 +
239 +| Crate | Avant | Après |
240 +|---|---|---|
241 +| `rustwarden-core` | `experimental-fediverse = [dep:activitypub_federation, dep:ammonia, dep:url, dep:rsa, dep:reqwest]` | (supprimé) |
242 +| `rustwarden-fediverse` | n/a | (toujours linké quand le crate est dans `Cargo.toml`) |
243 +| `rustwarden-ui` | `experimental-fediverse = ["rustwarden-core/experimental-fediverse"]` | `fediverse = ["dep:rustwarden-fediverse"]` |
244 +| `app` | `experimental-fediverse = ["rustwarden-core/experimental-fediverse", "rustwarden-ui/experimental-fediverse"]` | `fediverse = ["dep:rustwarden-fediverse", "rustwarden-ui/fediverse"]` |
245 +| `template` | absent | (à aligner sur `app`) |
246 +
247 +`app/src/main.rs` : `with_fediverse()` disparaît du builder ; remplacé par
248 +```rust
249 +#[cfg(feature = "fediverse")]
250 +rustwarden_fediverse::mount(&mut builder, FediverseConfig::from_env()?)?;
251 +```
252 +
253 +## Plan d'exécution
254 +
255 +### Phase 0 — Pré-requis (avant tout déplacement)
256 +- [ ] Corriger la race `FEDIVERSE_DOMAIN` dans `e2e_fediverse.rs` et `fediverse_interop.rs` : passer `FediverseConfig` en argument, ne plus muter `std::env`.
257 +- [ ] Vérifier l'état des 5 namespaces i18n fediverse dans les 6 fichiers locales (`fr/en/de/es/it/pt`). Si `de/es/it/pt` n'ont pas les clés, décider : (a) compléter avant extraction, (b) accepter le fallback `en`. Recommandation : (b), documenter dans une note de release.
258 +- [ ] `cargo test --workspace --all-features` green sur HEAD actuel (baseline).
259 +- [ ] Snapshot de la surface publique : `cargo public-api -p rustwarden-core > /tmp/core-before.txt`.
260 +
261 +### Phase 1 — Scaffolding (vide, build OK)
262 +- [ ] `crates/rustwarden-fediverse/Cargo.toml` avec deps : `rustwarden-core` (path), `activitypub_federation`, `rsa`, `reqwest`, `ammonia`, `url`, `sea-orm`, `sea-orm-migration`, `axum`, `tokio`, `tracing`, `serde`, `serde_json`, `chrono`, `uuid`, `thiserror`, `async-trait`.
263 +- [ ] `crates/rustwarden-fediverse/src/lib.rs` minimal (`pub mod models; pub mod services; pub mod config;`)
264 +- [ ] `crates/rustwarden-fediverse/migrations/lib.rs` avec `pub struct Migrator;` impl `MigratorTrait` retournant `vec![]`.
265 +- [ ] Ajouter membre au workspace racine [Cargo.toml](Cargo.toml).
266 +- [ ] ✅ `cargo build -p rustwarden-fediverse`
267 +
268 +### Phase 2 — Modèles + migrations + actor_key_service
269 +- [ ] Déplacer 4 migrations → `crates/rustwarden-fediverse/migrations/`
270 +- [ ] Déplacer 6 modèles (5 fediverse_* + remote_identity) → `src/models/`
271 +- [ ] Déplacer `actor_key_service` → `src/services/actor_key.rs`
272 +- [ ] Adapter imports : `use crate::models::{user, team, …}` → `use rustwarden_core::models::…`
273 +- [ ] Adapter le binaire `migrate` pour composer les Migrators (Q3)
274 +- [ ] Le check F10 dans `auth_service.rs` est temporairement gardé sous `cfg(feature = "experimental-fediverse")` via une feature mirror dans `rustwarden-core` qui ne fait que `dep:rustwarden-fediverse` — sera supprimée Phase 4
275 +- [ ] ✅ `cargo build --workspace --all-features` ET `cargo test --workspace --all-features`
276 +
277 +### Phase 3 — Services + config
278 +- [ ] Déplacer 5 services fediverse → `src/services/{auth, invite, outbound, signal, trust}.rs`
279 +- [ ] Déplacer `config/fediverse.rs` → `src/config.rs`
280 +- [ ] Re-export top-level dans `lib.rs` : `pub use config::FediverseConfig;`
281 +- [ ] **Rebind imports dans [crates/rustwarden-ui/src/handlers/admin_fediverse.rs](crates/rustwarden-ui/src/handlers/admin_fediverse.rs)** (12 occurrences cf. inventaire ci-dessus)
282 +- [ ] **Rebind imports dans [crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs](crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs)** (8 occurrences)
283 +- [ ] [crates/rustwarden-ui/Cargo.toml](crates/rustwarden-ui/Cargo.toml) : ajouter `rustwarden-fediverse = { path = "...", optional = true }`, renommer feature `experimental-fediverse` → `fediverse`
284 +- [ ] [crates/rustwarden-ui/src/routes.rs](crates/rustwarden-ui/src/routes.rs) : renommer toutes les `#[cfg(feature = "experimental-fediverse")]` → `#[cfg(feature = "fediverse")]`
285 +- [ ] ✅ `cargo build --workspace --all-features`
286 +
287 +### Phase 4 — Routes protocole + builder + refactors transverses
288 +- [ ] Déplacer les 14 fichiers `fediverse/*.rs` → `src/*.rs`
289 +- [ ] Vérifier que l'appel `FediverseTrustService::assert_domain_allowed()` est conservé dans `inbox.rs`, `auth_routes.rs`, `invite_routes.rs` (F8 régression-proof)
290 +- [ ] **Refactor Q4** : modifier `AuthService::new` pour accepter `protected_prefixes: Arc<Vec<String>>`. Ajouter `RustwardenBuilder::register_protected_prefix`. `rustwarden-ui::default_routes` s'enregistre via le builder. `rustwarden_fediverse::mount` s'enregistre. Retirer les 7 préfixes hardcodés.
291 +- [ ] **Refactor Q2** : introduire `trait RemoteIdentityRevocationChecker` dans `rustwarden-core::middleware`. Impl par défaut dans `rustwarden-fediverse`. Injection via `Extension` au mount. Supprimer le `#[cfg(feature = "experimental-fediverse")]` du middleware.
292 +- [ ] Supprimer `RustwardenBuilder::with_fediverse()` ; exposer `rustwarden_fediverse::mount(&mut builder, config) -> Result<(), Error>` qui :
293 + 1. injecte `Extension<FediverseConfig>`
294 + 2. injecte `Extension<Arc<dyn RemoteIdentityRevocationChecker>>`
295 + 3. enregistre les préfixes protégés
296 + 4. merge le router fediverse
297 +- [ ] Mettre à jour [app/src/main.rs](app/src/main.rs) et [app/Cargo.toml](app/Cargo.toml)
298 +- [ ] Mettre à jour [template/Cargo.toml](template/Cargo.toml) pour aligner sur app
299 +- [ ] Retirer toutes les `#[cfg(feature = "experimental-fediverse")]` de `rustwarden-core` (29 sites)
300 +- [ ] Retirer la feature `experimental-fediverse` de [rustwarden-core/Cargo.toml](crates/rustwarden-core/Cargo.toml) ainsi que les 5 deps optionnelles
301 +- [ ] ✅ `cargo build --workspace --all-features` ET `cargo build --workspace` (default features, `rustwarden-core` doit construire sans aucune trace fediverse)
302 +
303 +### Phase 5 — Tests, runtime, validation
304 +- [ ] Déplacer `crates/rustwarden-core/tests/{e2e_fediverse,fediverse_interop}.rs` → `crates/rustwarden-fediverse/tests/`
305 +- [ ] Adapter les imports des tests (`use rustwarden_core::fediverse::…` → `use rustwarden_fediverse::…`)
306 +- [ ] Vérifier que les 6 unit tests in-source de `fediverse_user_auth.rs` passent toujours
307 +- [ ] `cargo test --workspace --all-features` → tout green
308 +- [ ] `cargo clippy --workspace --all-features -- -D warnings`
309 +- [ ] **Smoke runtime** : `cargo run -p rustwarden-app --features fediverse`, hit :
310 + - `/.well-known/webfinger?resource=acct:admin@<domain>` → 200
311 + - `/login/fediverse` → 200, formulaire rendu, i18n correct
312 + - `/admin/fediverse/settings` → 200 (auth admin)
313 + - Console DevTools : 0 violation CSP
314 +- [ ] **Smoke testbed Docker** : [tests/fediverse-testbed/scripts/start.sh](tests/fediverse-testbed/scripts/start.sh), puis vérifier qu'un signal lifecycle traverse alpha → beta → gamma
315 +- [ ] **Smoke SSE** : ouvrir `/login/fediverse`, soumettre `@user@example.com`, vérifier que la page waiting reçoit bien les events SSE et fait la transition d'état
316 +- [ ] `cargo run --bin migrate` sur DB vide → 15+ tables créées dans l'ordre, FK respectées
317 +- [ ] Diff de surface : `cargo public-api -p rustwarden-core > /tmp/core-after.txt && diff /tmp/core-before.txt /tmp/core-after.txt` → confirmer disparition de tous les items `fediverse_*` / `FediverseConfig` / `remote_identity` de `rustwarden-core`
318 +
319 +## Fichiers critiques par responsabilité
320 +
321 +### rustwarden-core
322 +- [crates/rustwarden-core/src/lib.rs](crates/rustwarden-core/src/lib.rs) — retirer `pub mod fediverse` + re-exports
323 +- [crates/rustwarden-core/src/app.rs](crates/rustwarden-core/src/app.rs) — retirer `with_fediverse()`, ajouter `register_protected_prefix`, retirer le merge de routes fediverse
324 +- [crates/rustwarden-core/src/middleware/auth_service.rs](crates/rustwarden-core/src/middleware/auth_service.rs) — registry + trait `RemoteIdentityRevocationChecker`
325 +- [crates/rustwarden-core/src/models/mod.rs](crates/rustwarden-core/src/models/mod.rs) — retirer 6 re-exports
326 +- [crates/rustwarden-core/src/services/mod.rs](crates/rustwarden-core/src/services/mod.rs) — retirer 6 re-exports (5 fediverse_* + actor_key)
327 +- [crates/rustwarden-core/src/config/mod.rs](crates/rustwarden-core/src/config/mod.rs) — retirer `FediverseConfig`
328 +- [crates/rustwarden-core/Cargo.toml](crates/rustwarden-core/Cargo.toml) — retirer feature `experimental-fediverse` + 5 deps opt
329 +- `crates/rustwarden-core/src/bin/migrate.rs` — composer Migrators
330 +
331 +### rustwarden-fediverse (nouveau)
332 +- `crates/rustwarden-fediverse/Cargo.toml`
333 +- `crates/rustwarden-fediverse/src/lib.rs` (root, `mount()`, re-exports)
334 +- `crates/rustwarden-fediverse/src/{config,models,services}/*`
335 +- `crates/rustwarden-fediverse/src/{routes,inbox,auth_routes,invite_routes,actor,webfinger,nodeinfo,resolver,fetcher,http_signatures,types,error,state}.rs`
336 +- `crates/rustwarden-fediverse/migrations/*`
337 +- `crates/rustwarden-fediverse/tests/{e2e_fediverse,fediverse_interop}.rs`
338 +
339 +### rustwarden-ui
340 +- [crates/rustwarden-ui/Cargo.toml](crates/rustwarden-ui/Cargo.toml) — feature `fediverse`, dep optionnelle
341 +- [crates/rustwarden-ui/src/handlers/admin_fediverse.rs](crates/rustwarden-ui/src/handlers/admin_fediverse.rs) — 12 imports à rebinder
342 +- [crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs](crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs) — 8 imports à rebinder
343 +- [crates/rustwarden-ui/src/routes.rs](crates/rustwarden-ui/src/routes.rs) — feature gates renommées + appel `register_protected_prefix`
344 +- Templates (12) : pas de déplacement, mais vérifier que les structs Askama compilent après rebinding
345 +- Locales `fr.yml` + `en.yml` : pas de changement de contenu — vérifier au runtime que les clés sont toujours résolues
346 +
347 +### app + template
348 +- [app/src/main.rs](app/src/main.rs) — appel mount explicite
349 +- [app/Cargo.toml](app/Cargo.toml) — feature `fediverse`
350 +- [template/Cargo.toml](template/Cargo.toml) — aligner
351 +
352 +## Risques et pièges identifiés
353 +
354 +1. **Régression F8** : si l'appel `assert_domain_allowed()` saute au déplacement, on réintroduit la faille. Mitigation : test e2e dédié dans `fediverse_interop.rs` (à vérifier qu'il existe — sinon l'ajouter).
355 +2. **SSE downstream** : le client `EventSource` JS dans `login_waiting.html` parse `event: status`. Si le service emit changes au déplacement (impossible si on copie verbatim mais à vérifier), le polling casse silencieusement.
356 +3. **Migrations ordre** : si quelqu'un ajoute une migration core 0000XX avec FK vers une table fediverse, l'ordre se brise. Mitigation : documenter la règle "core ne dépend jamais de fediverse" dans un README de migrations.
357 +4. **i18n partielle** : si `de/es/it/pt` n'ont pas les clés fediverse, le runtime fallback sur `en`. Acceptable mais à documenter.
358 +5. **`rustwarden-fediverse-cli` ?** : le wizard `tools/rustwarden-cli` configure-t-il quoi que ce soit de fediverse ? À vérifier en Phase 0 — sinon documenter qu'aucun changement n'est requis.
359 +6. **Public API breakage** : tout consommateur externe de `rustwarden_core::fediverse::*` doit migrer. Pas de bridge de compat — c'est un breaking change majeur. À annoncer dans CHANGELOG.md + bump major.
360 +
361 +## Vérification finale
362 +
363 +1. `cargo build --workspace` (sans features) → `rustwarden-core` construit sans aucune des 5 deps fediverse, sans aucun `#[cfg]` fediverse
364 +2. `cargo build --workspace --all-features` → OK
365 +3. `cargo test --workspace --all-features` → tous les tests passent dont `e2e_fediverse`, `fediverse_interop` (relocalisés), et les 6 unit tests UI préservés
366 +4. `cargo clippy --workspace --all-features -- -D warnings` → zéro warning
367 +5. Smoke runtime : `/login/fediverse` rendu OK, SSE fonctionne, `/admin/fediverse/settings` OK, 0 violation CSP
368 +6. Smoke testbed Docker tri-instance : signal lifecycle alpha → beta → gamma OK
369 +7. `cargo run --bin migrate` sur base vide → toutes tables créées, FK respectées
370 +8. Diff de surface API : `cargo public-api -p rustwarden-core` → tous les items `fediverse_*`, `FediverseConfig`, `remote_identity` ont disparu de core
371 +9. CHANGELOG.md mis à jour avec note de breaking change + guide de migration pour consommateurs externes
A docs/plans/2026-05-11-fediverse-evaluation-iter2-3-findings.md
+180 -0
@@ -0,0 +1,180 @@
1 +# Itérations 2 + 3 — Findings de la campagne `experimental-fediverse`
2 +
3 +**Statut** : ✅ **iter 2a + iter 3 (F8) + iter 4 (F9 + F10) clos** — F4-F7 différés (UX non-bloquant), 2026-05-11
4 +**Plan parent** : [2026-05-10-fediverse-promotion-stable-evaluation.md](2026-05-10-fediverse-promotion-stable-evaluation.md)
5 +**Plan précédent** : [2026-05-11-fediverse-stable-promotion-iter1-findings.md](2026-05-11-fediverse-stable-promotion-iter1-findings.md)
6 +**Testbed** : [tests/fediverse-testbed/](../../tests/fediverse-testbed/)
7 +**Tests E2E** : [crates/rustwarden-core/tests/e2e_fediverse.rs](../../crates/rustwarden-core/tests/e2e_fediverse.rs)
8 +
9 +---
10 +
11 +## 1. Contexte
12 +
13 +Suite de la campagne après l'itération 1 (S1 SSO bilatéral). Cette itération couvre les scénarios :
14 +
15 +- **S2** — Invitation cross-instance dans une équipe (RFC 0014 + RFC 0009)
16 +- **S3** — Trois instances, non-transitivité du trust (RFC 0004)
17 +
18 +Méthodologie inchangée : audit code → tests E2E sur testbed → identifier les manques → corriger ou différer selon gravité → re-valider.
19 +
20 +## 2. Ce qui a été livré dans cette itération
21 +
22 +### 2.1 Tests E2E nouveaux
23 +
24 +Sur la suite [e2e_fediverse.rs](../../crates/rustwarden-core/tests/e2e_fediverse.rs), 8 tests ajoutés :
25 +
26 +**S2 (4 tests) — flux invite B-only** :
27 +
28 +| Test | Couvre |
29 +|---|---|
30 +| `s2_admin_invite_to_remote_joanie_creates_team_membership` | Happy path : admin crée invite → accept lie `team_member` à `remote_identity` |
31 +| `s2_invite_reject_flips_status_to_rejected_no_member` | Reject ne crée pas de membre |
32 +| `s2_invite_revoke_blocks_subsequent_accept` | Revoke bloque les accept ultérieurs |
33 +| `s2_idp_list_returns_pending_invite_for_handle` | Listing IdP-side fonctionne |
34 +
35 +**S3 (4 tests) — trust model** :
36 +
37 +| Test | Couvre |
38 +|---|---|
39 +| `s3_signed_protocol_flow_never_writes_trust_list` | Non-transitivité : aucune écriture auto dans `fediverse_trusted_domains` |
40 +| `s3_trust_list_entries_persist_independent_of_protocol_activity` | Permanence des entries explicites |
41 +| `s3_untrusted_domain_is_rejected_with_403` | **F8 fix** : un domaine retiré du trust list reçoit 403 |
42 +| `s3_trusted_domain_still_passes_protocol_inbox` | Régression positive : domaine trusté passe toujours |
43 +
44 +**Total suite E2E** : 22 tests (14 iter 1 + 8 iter 2+3) en 7.83s.
45 +
46 +### 2.2 Code livré (F8)
47 +
48 +Trust enforcement au niveau protocole :
49 +
50 +- [crates/rustwarden-core/src/services/fediverse_trust_service.rs](../../crates/rustwarden-core/src/services/fediverse_trust_service.rs) : nouvelle méthode `assert_domain_allowed(domain) -> Result<()>` qui retourne `AppError::Validation` si refusé.
51 +- [crates/rustwarden-core/src/fediverse/error.rs](../../crates/rustwarden-core/src/fediverse/error.rs) : nouvelle variante `FediverseError::Forbidden(String)` → 403.
52 +- [crates/rustwarden-core/src/fediverse/auth_routes.rs](../../crates/rustwarden-core/src/fediverse/auth_routes.rs) : appel `assert_domain_allowed` dans `auth_inbox`, **avant** la vérification de signature.
53 +- [crates/rustwarden-core/src/fediverse/inbox.rs](../../crates/rustwarden-core/src/fediverse/inbox.rs) : idem dans `/inbox`.
54 +
55 +### 2.3 Tests unit fixés
56 +
57 +7 tests unit ont régressé après l'ajout de F8 (le mode allowlist par défaut rejetait tout dans un `MockDatabase` vide). Fix : helper `permissive_trust_mock(MockDatabase)` ajouté dans `fediverse/inbox.rs::tests` et `fediverse/auth_routes.rs::tests`. Pré-pend 3 query results :
58 +
59 +1. `app_settings.fediverse_enabled = true`
60 +2. `app_settings.fediverse_trust_mode = open_with_blocklist`
61 +3. `fediverse_blocked_domains` empty
62 +
63 +Effet de bord trouvé en cours de fix : race env var `FEDIVERSE_DOMAIN` entre `test_fediverse_config` et `auth_inbox_returns_400_when_requester_actor_unknown`. F8 a élargi la fenêtre de mutation. Corrigé en **supprimant** la mutation env du test (l'assertion BadRequest tient quelle que soit la valeur de l'env).
64 +
65 +---
66 +
67 +## 3. Findings
68 +
69 +### F4–F7 — Back-channel AP invite (DIFFÉRÉS, non-bloquant)
70 +
71 +**Sévérité** : 🟡 UX — non-bloquant pour la promotion stable.
72 +
73 +**Constat de l'audit S2** :
74 +
75 +| # | Endroit | Description |
76 +|---|---|---|
77 +| F4 | [crates/rustwarden-ui/src/handlers/admin_fediverse.rs:1617](../../crates/rustwarden-ui/src/handlers/admin_fediverse.rs#L1617) `create_team_invite` | Crée la `fediverse_pending_invite` row côté B mais **n'enqueue pas** le payload AP `Invite{TeamMembership}` vers l'inbox de A |
78 +| F5 | [crates/rustwarden-core/src/fediverse/inbox.rs:86](../../crates/rustwarden-core/src/fediverse/inbox.rs#L86) `match activity.kind` | Dispatch sur `Update/Delete/Revoke` uniquement — **pas de cas `Invite`** |
79 +| F6 | `FediverseAuthService` (par analogie F3) | Pas de `record_inbound_invite(db, body)` service pour persister la notification côté A |
80 +| F7 | [crates/rustwarden-core/src/fediverse/invite_routes.rs:170](../../crates/rustwarden-core/src/fediverse/invite_routes.rs#L170) `idp_accept` | Flippe le status local côté B mais **n'enqueue pas** l'`Accept(Invite)` back vers A pour mise à jour admin |
81 +
82 +**Pourquoi différé** : le flux S2 fonctionne **déjà bout-en-bout** sans ces back-channels (testé par S2a) — l'admin de B et l'invité d'A se coordonnent via le partage manuel du lien d'invitation (similaire à un lien GitHub team invite). Le back-channel AP est une **commodité UX** (notification poussée + status admin), pas une condition fonctionnelle.
83 +
84 +**À implémenter quand** : une priorité produit l'exige, ou lors de la rédaction d'une suite RFC 0014 Phase 3.
85 +
86 +**Effort estimé** : ~400 LoC, similaire à F3 dans sa forme (réutiliser le dispatch type-based de F8, ajouter Invite/Accept(Invite)/Reject(Invite) au match). Pattern bien connu maintenant.
87 +
88 +### F8 — Trust list décorative au niveau protocole (CORRIGÉ)
89 +
90 +**Sévérité** : 🔴 **bloquant pour promotion stable**.
91 +
92 +**Constat** : `FediverseTrustService::is_domain_allowed` existait avec docstring « applied **before** any signature verification or fetch towards a remote domain », mais **n'était appelé que depuis les handlers UI**. Les 3 routes protocole (`auth_inbox`, `/inbox`, `invite_routes`) ne consultaient jamais la trust list.
93 +
94 +**Conséquence** : un domaine `evil.tld` avec une signature valide pouvait POST des activités à n'importe quelle instance Rustwarden, **indépendamment** de la trust list. Le mode `Allowlist` (zéro-trust par défaut) n'avait aucun effet runtime.
95 +
96 +**Reproductibilité** : test `s3_finding_f8_untrusted_domain_payload_accepted` (initial), qui validait empiriquement que beta acceptait un POST signé d'alpha même après retrait d'alpha de la trust list.
97 +
98 +**Fix appliqué** :
99 +
100 +1. Ajout `FediverseError::Forbidden(String)` → 403.
101 +2. Ajout `FediverseTrustService::assert_domain_allowed(domain) -> Result<()>`.
102 +3. Appel `assert_domain_allowed` au début de `auth_inbox` et `/inbox`, **avant** la vérification de signature.
103 +4. Test flipped : `s3_untrusted_domain_is_rejected_with_403` attend désormais 403.
104 +5. Test positif : `s3_trusted_domain_still_passes_protocol_inbox` confirme l'absence de régression.
105 +
106 +**Critère d'acceptation** : ✅ Test `s3_untrusted_domain_is_rejected_with_403` vert, 348 unit tests OK.
107 +
108 +### Effet collatéral de F8 — Race env var dans tests (CORRIGÉ)
109 +
110 +**Sévérité** : 🟡 dette technique de la base de tests.
111 +
112 +**Constat** : `test_fediverse_config` dans `fediverse_auth_service.rs` mute `FEDIVERSE_DOMAIN=test.local` ; `auth_inbox_returns_400_when_requester_actor_unknown` dans `auth_routes.rs` mute `FEDIVERSE_DOMAIN=a.tld` puis `remove_var`. Avec F8 ajoutant 3 queries DB entre set et remove, la fenêtre s'est élargie suffisamment pour qu'un test concurrent `build_*_payload` (sync) lise une valeur incohérente et rate son assertion.
113 +
114 +**Fix appliqué** : suppression complète de la mutation env dans `auth_inbox_returns_400_when_requester_actor_unknown`. L'assertion BadRequest est robuste à toute valeur de `FEDIVERSE_DOMAIN` (target mismatch dans tous les cas).
115 +
116 +**Dette restante** : la mutation env globale dans `test_fediverse_config` reste un anti-pattern. Une RFC dette technique pourrait introduire un mutex `ENV_LOCK` cross-file ou refactorer pour passer `FediverseConfig` en argument plutôt que via env. **Hors-scope cette itération.**
117 +
118 +---
119 +
120 +## 4. État technique final iter 2+3
121 +
122 +```
123 +Tests E2E : 22 verts (iter 1a + 1b + 1c + 1d + 2a + 3)
124 + ├── 7 smoke (WebFinger, NodeInfo, Actor, inbox 401)
125 + ├── 1 AuthRequest signed B→A (iter 1b)
126 + ├── 1 consume B → JWT (iter 1c)
127 + ├── 5 round-trip Accept/Reject A→B (iter 1d / F3)
128 + ├── 4 invite B-only (iter 2a / S2)
129 + └── 4 trust model (iter 3 / S3 + F8)
130 +Tests unit rustwarden-core --lib : 348 verts
131 +Bugs ouverts (bloquants) : 0
132 +Bugs ouverts (UX différés) : F4, F5, F6, F7
133 +RFC nouvelles : (rien dans cette itération — F8 utilise l'API trust existante)
134 +Quality gates : build ✅, clippy -D warnings ✅
135 +Run time E2E suite : 7.83s
136 +```
137 +
138 +---
139 +
140 +## 5. Position promotion stable
141 +
142 +| Scénario plan parent | Statut |
143 +|---|---|
144 +| **S1** SSO bilatéral A ↔ B | ✅ (iter 1) |
145 +| **S2** Invite cross-instance | ✅ B-only ; back-channel A↔B en F4-F7 différés (UX) |
146 +| **S3** 3 instances, non-transitivité trust | ✅ (iter 3 + F8 corrigé) |
147 +| S4 Block / révocation en masse | À auditer (`fediverse_blocked_domains` existe, vérifier que F8 honore le blocklist) |
148 +| S5 Modes de panne (replay, expiry, network) | Partiel (négatifs S1/S2/S3 couvrent une part) |
149 +| S6 Lifecycle Update / Delete Person | À automatiser |
150 +| Non-fonctionnels (perf, observabilité, doc opérateur) | Non commencé |
151 +
152 +**3 scénarios sur 6 validés, 0 bloquant restant pour le protocole identité fédérée.** La promotion `experimental-fediverse` → `fediverse` reste suspendue aux scénarios S4–S6 mais aucun finding restant ne **bloque** : tous sont soit (a) UX différée, soit (b) non encore audités.
153 +
154 +---
155 +
156 +## 6. Patterns récurrents repérés (méta-finding)
157 +
158 +Trois itérations ont révélé un pattern systémique dans l'architecture fédiverse Rustwarden :
159 +
160 +> **Les helpers existent (payloads, services), mais leur appel depuis les routes HTTP est régulièrement absent.**
161 +
162 +| Itération | Helper présent | Route qui devait l'appeler | Statut |
163 +|---|---|---|---|
164 +| F3 | `record_inbound_decision` à ajouter, mais `Accept`/`Reject` types AP standard | `auth_inbox` ne dispatchait pas sur `type` | ✅ Fixé |
165 +| F4 | `build_invite_payload` | `create_team_invite` UI n'enqueuait pas | ⏳ Différé |
166 +| F5 | (Invite dispatch attendu) | `/inbox` ne matchait pas `Invite` | ⏳ Différé |
167 +| F8 | `FediverseTrustService::is_domain_allowed` | Routes protocole n'appelaient jamais | ✅ Fixé |
168 +
169 +**Recommandation pour la stabilité long terme** : avant la promotion stable finale, faire une revue systématique « pour chaque helper public dans `services/fediverse_*`, qui l'appelle en prod ? » et combler les trous. Sans ça, les helpers restent du code mort et la surface fonctionnelle est plus petite que ce que l'API suggère.
170 +
171 +---
172 +
173 +## 7. Prochaine itération suggérée
174 +
175 +**S4 — Block list / révocation en masse**, qui :
176 +- Audite que F8 honore aussi `fediverse_blocked_domains` (mode `OpenWithBlocklist`), pas que `fediverse_trusted_domains` (déjà couvert).
177 +- Teste la révocation en masse : admin ajoute un domaine au blocklist → toutes les sessions JWT remote de ce domaine doivent être invalidées (RFC 0010 lifecycle signals).
178 +- Surface attendue de tests : ~4-5 E2E.
179 +
180 +Effort estimé : ~3-4h, similaire à iter 2.
A docs/plans/2026-05-11-fediverse-stable-promotion-iter1-findings.md
+301 -0
@@ -0,0 +1,301 @@
1 +# Itération 1 — Findings de la campagne d'évaluation `experimental-fediverse`
2 +
3 +**Statut** : ✅ **CLOS — itération 1 complète, 2026-05-11**
4 +**Mise à jour** : 2026-05-11 (F2 + F3 livrés, suite E2E 14 verts)
5 +**Auteur** : EBO + Claude
6 +**Plan parent** : [2026-05-10-fediverse-promotion-stable-evaluation.md](2026-05-10-fediverse-promotion-stable-evaluation.md)
7 +**Testbed** : [tests/fediverse-testbed/](../../tests/fediverse-testbed/)
8 +**Tests E2E** : [crates/rustwarden-core/tests/e2e_fediverse.rs](../../crates/rustwarden-core/tests/e2e_fediverse.rs)
9 +
10 +---
11 +
12 +## 1. Contexte
13 +
14 +La campagne d'évaluation pour la promotion `experimental-fediverse` → `fediverse` (stable) suit un cycle :
15 +
16 +```
17 + ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
18 + │ Itération │ ────► │ Findings │ ────► │ Corrections │
19 + │ E2E testbed │ │ documentés │ │ ciblées │
20 + └──────────────┘ └──────────────┘ └──────────────┘
21 + ▲ │
22 + │ │
23 + └──────────────────────────────────────────────────┘
24 + Re-évaluation après chaque cycle
25 +```
26 +
27 +L'itération 1 (iter 0 setup + iter 1a/1b/1c tests) a livré **9 tests E2E verts** contre une topologie 3-instances Docker. Le présent document :
28 +
29 +1. Synthétise ce qui a été livré.
30 +2. Liste les **manques** que les tests ont révélés dans le code source de Rustwarden.
31 +3. Définit le plan de correction par finding (sévérité, effort, critère d'acceptation).
32 +4. Spécifie la re-évaluation à effectuer après corrections.
33 +
34 +---
35 +
36 +## 2. Ce que la campagne a livré jusqu'ici
37 +
38 +### 2.1 Infrastructure (iter 0)
39 +
40 +- [tests/fediverse-testbed/](../../tests/fediverse-testbed/) — topologie 3 instances locales, 100 % automatisable
41 +- 4 scripts cycle de vie : `setup.sh`, `stop.sh`, `start.sh`, `teardown.sh`
42 +- Image Docker `rustwarden-fediverse-testbed:latest` (rustc 1.91, multi-stage)
43 +- Compose : 12 services (3 PG + 3 migrate + 3 app + 3 seed)
44 +- Réseau Docker avec `network_aliases` (`alpha.local`, `beta.local`, `gamma.local`)
45 +- Pas de TLS, pas de `/etc/hosts` hôte requis (via `FEDIVERSE_DEBUG_LOCAL_FEDERATION=true`)
46 +
47 +### 2.2 Tests E2E (iter 1a + 1b + 1c)
48 +
49 +Total : **9 tests verts** en 1.42 s à chaud.
50 +
51 +| Test | But |
52 +|---|---|
53 +| `webfinger_resolves_local_user_on_each_instance` | WebFinger JRD sur les 3 instances |
54 +| `webfinger_returns_404_for_foreign_domain` | A refuse `acct:user@beta.local` |
55 +| `webfinger_returns_4xx_for_malformed_resource` | Resource mal formée → 4xx |
56 +| `actor_fetch_returns_valid_person_jsonld` | `/users/joanie` retourne Person AP avec publicKey |
57 +| `nodeinfo_discovery_returns_links` | `.well-known/nodeinfo` répond |
58 +| `auth_inbox_rejects_unsigned_post` | Inbox refuse POST sans Signature |
59 +| `auth_inbox_rejects_invalid_signature` | Inbox refuse Signature mal formée |
60 +| `s1_signed_authrequest_yields_pending_challenge_on_alpha` (1b) | **Round-trip signé draft-cavage avec vraie crypto** — B → A AuthRequest signé → A crée challenge Pending |
61 +| `s1_consume_on_beta_mints_jwt_when_challenge_approved` (1c) | Consume B émet JWT remote depuis challenge Approved |
62 +
63 +### 2.3 Bugs corrigés en cours de campagne
64 +
65 +| # | Endroit | Description | Statut |
66 +|---|---|---|---|
67 +| F1 | [crates/rustwarden-core/src/fediverse/auth_routes.rs:196](../../crates/rustwarden-core/src/fediverse/auth_routes.rs#L196) | `FEDIVERSE_DOMAIN` lu brut sans normalisation, échoue quand on passe la forme URL (`http://alpha.local`) | ✅ Fix 2-LoC appliqué |
68 +| F2 | [crates/rustwarden-core/src/services/fediverse_signal_service.rs](../../crates/rustwarden-core/src/services/fediverse_signal_service.rs) | `AlwaysAcceptVerifier` ne populait pas `remote_identities`, brisant le contrat de `persist_inbound_request` | ✅ Fix livré + durci (timeout, body cap, SSRF guard) + 9 tests unit |
69 +| F3 | [crates/rustwarden-core/src/services/fediverse_auth_service.rs](../../crates/rustwarden-core/src/services/fediverse_auth_service.rs) + [auth_routes.rs](../../crates/rustwarden-core/src/fediverse/auth_routes.rs) + [fediverse_user_auth.rs](../../crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs) | Pas de back-channel A→B → SSO bilatéral impossible à compléter sans DB-direct | ✅ RFC 0025 implémentée (dispatch type-based, `record_inbound_decision`, payload builder, enqueue UI) + 5 tests E2E |
70 +
71 +---
72 +
73 +## 3. Findings — analyse des manques (historique, tous corrigés)
74 +
75 +### Finding F2 — `AlwaysAcceptVerifier` ne respecte pas son contrat
76 +
77 +**Sévérité** : 🟠 Sérieux. Non bloquant pour la promotion (le verifier en prod fait le job), mais empoisonne tout test E2E qui dépend de la création implicite de `remote_identities`.
78 +
79 +**Origine** : `crates/rustwarden-core/src/services/fediverse_signal_service.rs` (à vérifier — nom exact à confirmer).
80 +
81 +**Symptôme observé en iter 1b** : POST signé arrive sur `auth_inbox` d'alpha. La signature est acceptée (logs warn `AlwaysAcceptVerifier in use`), mais `persist_inbound_request` plante en 400 avec « Requester actor not in remote_identities cache » parce que le verifier-debug n'a pas peuplé la table comme un vrai verifier le fait.
82 +
83 +**Contrat attendu** (cf. commentaire du `persist_inbound_request`) :
84 +> *"the signature verifier ran upstream and already required this row"*
85 +
86 +Le vrai verifier (`HttpSigVerifier::with_resolver`) :
87 +1. Extrait `keyId` de l'en-tête `Signature`.
88 +2. Fetch l'actor JSON via HTTP GET.
89 +3. Stocke `publicKey + inbox + ...` dans `remote_identities` (UPSERT).
90 +4. Vérifie la signature RSA-SHA256 avec la clé fetchée.
91 +
92 +`AlwaysAcceptVerifier` saute toutes ces étapes et retourne `Ok(())`.
93 +
94 +**Impact concret** :
95 +- Tout test E2E utilisant le mode debug doit pré-seeder `remote_identities` manuellement (workaround actuel dans `e2e_fediverse.rs`).
96 +- Pire : en production, si quelqu'un active accidentellement `AlwaysAcceptVerifier`, la chaîne casse au runtime — pas seulement la sécurité.
97 +
98 +**Plan de correction F2** :
99 +
100 +Deux options, par ordre de préférence :
101 +
102 +**Option F2.A** (recommandée) — Faire honorer le contrat même en mode debug.
103 +
104 +Modifier `AlwaysAcceptVerifier` pour qu'il :
105 +1. Continue d'accepter toutes les signatures (sa raison d'être).
106 +2. **Aussi** : fetch l'actor URL extrait du keyId, peuple `remote_identities`.
107 +
108 +Avantages :
109 +- Tests E2E sans pré-seeding manuel.
110 +- Cohérence : `AlwaysAcceptVerifier` devient « tolérant en signature, strict en data ».
111 +- Aucun impact prod (mode debug uniquement).
112 +
113 +Effort : ~80 LoC + 1 test unitaire. ~1 h.
114 +
115 +**Option F2.B** — Supprimer `AlwaysAcceptVerifier` et toujours utiliser le vrai verifier.
116 +
117 +Avantages : code plus simple, pas de divergence debug/prod.
118 +Inconvénients : impossible de tester sans HTTPS fonctionnel entre instances. Casse le mode `FEDIVERSE_DEBUG_LOCAL_FEDERATION` actuel.
119 +
120 +**Critère d'acceptation F2** : le test `s1_signed_authrequest_yields_pending_challenge_on_alpha` passe **sans** appeler `upsert_remote_identity` dans son setup.
121 +
122 +---
123 +
124 +### Finding F3 — Pas de back-channel A → B (AuthApproval)
125 +
126 +**Sévérité** : 🔴 **Bloquant pour la promotion stable.**
127 +
128 +**Origine** : Conception RFC 0006. Le protocole AP-natif tel qu'implémenté ne définit que la moitié A→B du flux d'authentification fédérée.
129 +
130 +**Symptôme observé en iter 1c** : `grep -rni "AuthApproval"` dans `crates/rustwarden-core/src` retourne **zéro occurrence**. L'`auth_inbox` ne dispatche pas sur le champ `type` du body — il assume `AuthRequest`. La fonction `consent_submit` (UI) flippe le challenge à `Approved` côté A et **rien d'autre** : aucune enqueue outbound, aucun appel HTTP vers B.
131 +
132 +**Mécanique manquante** :
133 +
134 +Quand l'utilisateur clique « Approve » sur l'IdP (A), il faut **propager l'approbation vers le SP (B)**. Trois designs possibles :
135 +
136 +```mermaid
137 +sequenceDiagram
138 + participant A as A (IdP)
139 + participant B as B (SP)
140 +
141 + Note over A,B: Design 1 — Push AP signé (recommandé)
142 + A->>B: POST /fediverse/auth/inbox<br/>{ type: "AuthApproval", inResponseTo: nonce } (signé)
143 + B->>B: dispatch sur type → flip challenge local à Approved
144 +
145 + Note over A,B: Design 2 — Pull / polling
146 + B->>A: POST /fediverse/auth/status/{nonce}
147 + A-->>B: { status: "approved" | "pending" | ... }
148 + B->>B: si approved → flip local challenge
149 +
150 + Note over A,B: Design 3 — Long-polling SSE
151 + B-->>A: GET /fediverse/auth/wait/{nonce} (keep-alive)
152 + A-->>B: stream { status: approved } quand changé
153 +```
154 +
155 +Recommandation : **Design 1** (push AP signé). Avantages :
156 +- Aligné avec la philosophie AP (activities push).
157 +- Permet l'écosystème fédéré (n'importe quelle instance peut être A ou B).
158 +- Pas de polling B → A (charge réseau prévisible).
159 +- L'`auth_inbox` existe déjà, il suffit de dispatch sur `type`.
160 +
161 +**Plan de correction F3** (RFC 0006 Phase 3) :
162 +
163 +Cette correction mérite sa propre RFC. Périmètre minimal viable :
164 +
165 +#### F3.1 — Dispatch type-based dans `auth_inbox`
166 +
167 +[crates/rustwarden-core/src/fediverse/auth_routes.rs:156](../../crates/rustwarden-core/src/fediverse/auth_routes.rs#L156)
168 +
169 +Avant traitement actuel (qui assume AuthRequest), ajouter :
170 +
171 +```rust
172 +let activity_type = body_json
173 + .get("type")
174 + .and_then(|v| v.as_str())
175 + .unwrap_or("");
176 +match activity_type {
177 + "AuthRequest" => { /* code actuel */ },
178 + "AuthApproval" => FediverseAuthService::record_inbound_approval(&db, &body_json).await?,
179 + "AuthDenial" => FediverseAuthService::record_inbound_denial(&db, &body_json).await?,
180 + other => return Err(FediverseError::BadRequest(format!("unknown activity type `{other}`"))),
181 +}
182 +```
183 +
184 +#### F3.2 — Service `record_inbound_approval` / `record_inbound_denial`
185 +
186 +Nouveau dans `services/fediverse_auth_service.rs` :
187 +- Récupère le challenge local de B par `nonce` (champ `inResponseTo` du payload).
188 +- Vérifie expiry et état (doit être Pending).
189 +- Flippe status à Approved/Rejected et lie `requested_remote_id` à l'actor approver.
190 +
191 +#### F3.3 — Enqueue outbound après decision côté A
192 +
193 +[crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs:578](../../crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs#L578) (`consent_submit`) après `record_decision` :
194 +- Construire payload AP `AuthApproval` (id, type, actor=IdP user, inResponseTo=nonce, ...).
195 +- Récupérer l'inbox du requester via la `remote_identity` liée au challenge.
196 +- `FediverseOutboundService::enqueue` un job délivré par `HttpSignedDelivery`.
197 +
198 +#### F3.4 — Payload AP `AuthApproval` (spec interne)
199 +
200 +```json
201 +{
202 + "@context": ["https://www.w3.org/ns/activitystreams",
203 + "https://schema.rustwarden.org/auth-approval/v1"],
204 + "type": "AuthApproval",
205 + "id": "http://alpha.local/auth-approvals/<uuid>",
206 + "actor": "http://alpha.local/users/joanie",
207 + "inResponseTo": "<nonce>",
208 + "decision": "approve" | "reject"
209 +}
210 +```
211 +
212 +#### F3.5 — Tests E2E nouveaux (à ajouter à `e2e_fediverse.rs`)
213 +
214 +| Test | But |
215 +|---|---|
216 +| `s1_full_round_trip_signed_e2e` | B initiate → POST AuthRequest signé vers A → A flip via consent JSON API → A enqueue AuthApproval → polling B's consume jusqu'à 200 avec JWT |
217 +| `s1_reject_propagates_to_b` | Idem, mais decision=reject → B's consume retourne 4xx |
218 +| `s1_unknown_inResponseTo_rejected` | B reçoit AuthApproval pour un nonce inconnu → 400 |
219 +| `s1_expired_challenge_no_longer_consumable` | Approval arrive après TTL → 4xx |
220 +
221 +**Effort estimé F3** : ~400 LoC code prod + 4 tests E2E + RFC formelle. **~2 jours.**
222 +
223 +**Critère d'acceptation F3** : le test `s1_full_round_trip_signed_e2e` passe sans aucun DB-direct sur B.
224 +
225 +---
226 +
227 +## 4. Plan d'exécution
228 +
229 +### 4.1 Phase de correction (≈ 1 jour + 2 jours)
230 +
231 +**Sprint 1 (½ journée)** — Finding F2 corrigé.
232 +
233 +- Implémenter Option F2.A (peupler `remote_identities` même en mode debug).
234 +- Adapter `e2e_fediverse.rs` pour retirer `upsert_remote_identity` du test 1b.
235 +- Re-lancer la suite → 9 tests verts.
236 +
237 +**Sprint 2 (2 jours)** — Finding F3 corrigé (RFC 0006 Phase 3).
238 +
239 +- Rédiger `docs/rfcs/0006-phase3-auth-approval-back-channel.md`.
240 +- Implémenter F3.1 à F3.4.
241 +- Écrire les 4 tests E2E F3.5.
242 +- Re-lancer la suite → 13 tests verts.
243 +
244 +### 4.2 Phase de re-évaluation (½ journée)
245 +
246 +Après chaque sprint :
247 +
248 +1. `tests/fediverse-testbed/scripts/teardown.sh && setup.sh` — état propre.
249 +2. `cargo test -p rustwarden-core --test e2e_fediverse --features experimental-fediverse -- --test-threads=1`.
250 +3. Mettre à jour le tableau « Tests E2E » de ce plan.
251 +4. Si tout est vert, attaquer le scénario suivant du plan parent (S2 invite cross-instance, S3 trois instances no transitive trust, etc.).
252 +
253 +### 4.3 Critères pour clore l'itération 1
254 +
255 +- [x] **F2 corrigé** ; test 1b passe sans pré-seeding (`upsert_remote_identity`
256 + retiré). Helper `fetch_and_cache_remote_actor` durci (timeout / body
257 + cap / SSRF guard) + 9 tests unit verts.
258 +- [x] **F3 corrigé** ; round-trip S1 complet automatisé sans DB-direct via
259 + `Accept(AuthRequest)` standard AP. 5 tests E2E neufs verts.
260 +- [x] **CHANGELOG.md** mis à jour avec entrées « bug F1, F2, F3 résolus ».
261 +- [x] **Plan parent** ([2026-05-10-fediverse-promotion-stable-evaluation.md](2026-05-10-fediverse-promotion-stable-evaluation.md))
262 + : Itération 1 ✅ (à propager dans le tableau §6 du plan parent).
263 +
264 +---
265 +
266 +## 5. Décisions et trade-offs explicites
267 +
268 +| Décision | Pourquoi | Conséquence |
269 +|---|---|---|
270 +| Choisir Design 1 (push AP) pour F3 | Aligné avec écosystème AP, scalable, pas de polling | Demande un endpoint de dispatch type-based côté inbox |
271 +| Faire F2 avant F3 | Sans F2, le test de F3 reste polluable par le bug de cache | Ordre strict de mise en œuvre |
272 +| Pas de RFC séparée pour F1 | Bug local, 2 LoC, déjà fixé en cours d'iter 1b | TODO de refactor noté en commentaire de code |
273 +| Conserver le testbed en HTTP plain (pas de TLS) | TLS dev = friction inutile pour la fédération identité | Le testbed restera dev-only |
274 +
275 +---
276 +
277 +## 6. Annexe — État technique final (itération 1 close)
278 +
279 +```
280 +Tests E2E : 14 verts (iter 1a + 1b + 1c + 1d)
281 + ├── 7 smoke (WebFinger, NodeInfo, Actor, inbox 401)
282 + ├── 1 AuthRequest signed B→A (iter 1b)
283 + ├── 1 consume B → JWT (iter 1c)
284 + └── 5 round-trip Accept/Reject A→B (iter 1d / F3)
285 +Tests unit rustwarden-core --lib : 348 verts (incluant +9 pour F2)
286 +Bugs ouverts : 0 sur le périmètre de l'itération 1
287 +RFC nouvelles : 0025 (Phase 3 RFC 0006)
288 +Quality gates : build ✅, clippy -D warnings ✅, doc ✅
289 +Run time E2E suite : 2.93s
290 +```
291 +
292 +**État final :** RFC `experimental-fediverse` désormais **techniquement
293 +prêt** pour promotion stable côté flux S1 SSO bilatéral. Restent les
294 +scénarios S2 (invite cross-instance), S3 (3 instances, non-transitivité du
295 +trust), S4 (block / révocation), S5 (modes de panne), S6 (lifecycle Update /
296 +Delete Person) du plan parent à automatiser. Aucune dette technique sur le
297 +S1.
298 +
299 +**Suite : voir [2026-05-11-fediverse-evaluation-iter2-3-findings.md](2026-05-11-fediverse-evaluation-iter2-3-findings.md)**
300 +qui couvre S2 (✅ B-only, F4–F7 différés UX), S3 (✅) et F8 — trust
301 +enforcement protocole (✅ corrigé, bloquant qui était caché).
A docs/rfcs/0025-fediverse-auth-back-channel.md
+413 -0
@@ -0,0 +1,413 @@
1 +# RFC 0025 — Fediverse: back-channel `Accept(AuthRequest)` (RFC 0006 Phase 3)
2 +
3 +- **Auteur** : EBO (rustwarden)
4 +- **Date** : 2026-05-11
5 +- **Statut** : **Accepted** — implémentation livrée 2026-05-11 (5 tests E2E + 348 unit verts, suite E2E complète à 14)
6 +- **Apps consommatrices concernées** : toute app utilisant `rustwarden-core` qui souhaite réellement compléter un round-trip d'authentification fédérée Rustwarden ↔ Rustwarden.
7 +- **Plan associé** : [`docs/plans/2026-05-11-fediverse-stable-promotion-iter1-findings.md`](../plans/2026-05-11-fediverse-stable-promotion-iter1-findings.md) (Finding F3).
8 +- **Dépendances** : RFC 0006 (Phase 1 & 2), RFC 0011 (outbound worker), RFC 0015 (HTTP signatures), RFC 0016 (signed delivery).
9 +- **Supersedes** : aucun (complète RFC 0006).
10 +
11 +## Summary
12 +
13 +Implémenter le back-channel manquant du flow d'authentification AP-native : quand l'IdP (instance A) approuve ou rejette une `AuthRequest`, propager la décision vers le SP (instance B) via une activité **AP standard `Accept` / `Reject`** dont le champ `object` pointe l'`AuthRequest` originale. Côté B, l'inbox dispatche sur `type` et bascule le challenge local en `Approved` / `Rejected`, ce qui débloque `consume()` qui peut alors émettre le JWT remote.
14 +
15 +## Motivation
16 +
17 +L'évaluation E2E (testbed `tests/fediverse-testbed/`, itération 1c) a démontré que **deux instances Rustwarden ne peuvent pas effectivement compléter un SSO bilatéral** avec le code actuel :
18 +
19 +- ✅ `B → A` : l'`AuthRequest` signé est reçu et stocké en challenge Pending côté A. — RFC 0006 Phase 1 + 2.
20 +- ✅ `A → A` : l'utilisateur clique « Approve » dans l'UI A, le challenge passe à Approved côté A. — RFC 0023 Slice B.
21 +- ❌ **`A → B` : aucun mécanisme.** L'`auth_inbox` ne dispatche pas sur `type`, `consent_submit` n'enqueue rien dans `fediverse_outbound_queue`. Le challenge sur B reste éternellement Pending jusqu'au TTL 5 min.
22 +
23 +Conséquence : **bloquant pour la promotion `experimental-fediverse` → `fediverse` stable.** Sans round-trip, le protocole n'est pas opérationnel et aucune deuxième instance Rustwarden ne peut être un partenaire fonctionnel.
24 +
25 +La RFC 0006 originale référence explicitement `Accept{AuthRequest}` dans son Summary mais n'a jamais livré l'implémentation côté inbox B. Cette RFC clôt cette dette.
26 +
27 +## Non-goals
28 +
29 +- **Pas de polling / long-polling / SSE** côté `consume()` — le client polling au niveau de l'app consommatrice reste la responsabilité de l'UI (RFC 0023 Slice B garde son SSE).
30 +- **Pas de modification du flow `B → A`** initial — `AuthRequest` reste un type custom dans le namespace Rustwarden, traité par le chemin actuel.
31 +- **Pas de nouveau type d'activité custom** — explicitement, on **n'invente pas** `AuthApproval` / `AuthDenial` ; on utilise les types AP standards `Accept` / `Reject`.
32 +- **Pas d'interop Mastodon** — un Mastodon recevant un `Accept(AuthRequest)` l'ignorera silencieusement (pas de side-effect connu). C'est le comportement souhaité : l'isolation du protocole Rustwarden ↔ Rustwarden reste intacte.
33 +- **Pas de re-signature des activités existantes** dans le code (aucun `AuthApproval` n'a été émis en prod jusqu'à présent).
34 +- **Pas de migration `cavage-draft` → RFC 9421** des HTTP Signatures (sera traité dans une RFC séparée, cf. § Alternatives).
35 +
36 +## API proposée
37 +
38 +### 1. Dispatch type-based dans `auth_inbox`
39 +
40 +[crates/rustwarden-core/src/fediverse/auth_routes.rs:156](../../crates/rustwarden-core/src/fediverse/auth_routes.rs#L156)
41 +
42 +Le handler actuel assume `AuthRequest`. Le nouveau code dispatche :
43 +
44 +```rust
45 +async fn auth_inbox(
46 + Extension(state): Extension<AuthFlowState>,
47 + Extension(db): Extension<DatabaseConnection>,
48 + headers: HeaderMap,
49 + body: axum::body::Bytes,
50 +) -> Result<StatusCode, FediverseError> {
51 + // ... vérification signature (inchangé) ...
52 +
53 + let body_json = serde_json::from_slice::<serde_json::Value>(&body)
54 + .map_err(|e| FediverseError::BadRequest(format!("malformed JSON: {e}")))?;
55 + let activity_type = body_json
56 + .get("type")
57 + .and_then(|v| v.as_str())
58 + .unwrap_or("");
59 +
60 + let idp_domain = normalised_idp_domain();
61 +
62 + match activity_type {
63 + "AuthRequest" => {
64 + FediverseAuthService::persist_inbound_request(&db, &body_json, &idp_domain)
65 + .await
66 + .map_err(|e| FediverseError::BadRequest(e.to_string()))?;
67 + }
68 + "Accept" | "Reject" => {
69 + FediverseAuthService::record_inbound_decision(&db, &body_json)
70 + .await
71 + .map_err(|e| FediverseError::BadRequest(e.to_string()))?;
72 + }
73 + other => {
74 + // AP spec §7 : types non reconnus sont ignorés silencieusement.
75 + tracing::debug!(activity_type = other, "auth_inbox: ignoring unknown type");
76 + }
77 + }
78 + Ok(StatusCode::ACCEPTED)
79 +}
80 +```
81 +
82 +### 2. Nouveau service `record_inbound_decision`
83 +
84 +[crates/rustwarden-core/src/services/fediverse_auth_service.rs](../../crates/rustwarden-core/src/services/fediverse_auth_service.rs) :
85 +
86 +```rust
87 +impl FediverseAuthService {
88 + /// Phase 3 — IdP (A) → SP (B) back-channel.
89 + ///
90 + /// Called by B's `/fediverse/auth/inbox` when A delivers an
91 + /// `Accept(AuthRequest)` or `Reject(AuthRequest)`. The activity's
92 + /// `object` IRI references B's original AuthRequest, and we use the
93 + /// embedded nonce (last URL segment) to look up B's local challenge.
94 + ///
95 + /// Idempotent: replaying the same decision returns Ok without DB write.
96 + pub async fn record_inbound_decision(
97 + db: &DatabaseConnection,
98 + body: &serde_json::Value,
99 + ) -> Result<(), AppError> {
100 + let activity_type = body
101 + .get("type")
102 + .and_then(|v| v.as_str())
103 + .ok_or_else(|| AppError::Validation("activity missing `type`".into()))?;
104 +
105 + let nonce = extract_nonce_from_object(body)?;
106 + let approver_actor_url = body
107 + .get("actor")
108 + .and_then(|v| v.as_str())
109 + .ok_or_else(|| AppError::Validation("activity missing `actor`".into()))?
110 + .to_string();
111 +
112 + let challenge = fediverse_auth_challenge::Entity::find()
113 + .filter(fediverse_auth_challenge::Column::Nonce.eq(&nonce))
114 + .one(db)
115 + .await?
116 + .ok_or_else(|| AppError::NotFound(format!("unknown nonce {nonce}")))?;
117 +
118 + if challenge.status != ChallengeStatus::Pending.as_str() {
119 + // Idempotence : replay sur challenge déjà décidé → ok silencieux.
120 + return Ok(());
121 + }
122 + if challenge.expires_at < Utc::now() {
123 + return Err(AppError::Validation("challenge expired".into()));
124 + }
125 +
126 + // Résoudre l'approver côté B en `remote_identities`. Le verifier
127 + // upstream a déjà créé/mis à jour la ligne (cf. F2).
128 + let approver = remote_identity::Entity::find()
129 + .filter(remote_identity::Column::ApId.eq(&approver_actor_url))
130 + .one(db)
131 + .await?
132 + .ok_or_else(|| AppError::Validation(format!(
133 + "approver {approver_actor_url} not in remote_identities cache"
134 + )))?;
135 +
136 + let new_status = match activity_type {
137 + "Accept" => ChallengeStatus::Approved,
138 + "Reject" => ChallengeStatus::Rejected,
139 + other => return Err(AppError::Validation(format!("unexpected type {other}"))),
140 + };
141 +
142 + let mut active: fediverse_auth_challenge::ActiveModel = challenge.into();
143 + active.status = Set(new_status.as_str().to_string());
144 + active.requested_remote_id = Set(Some(approver.id));
145 + active.update(db).await?;
146 + Ok(())
147 + }
148 +}
149 +
150 +/// Extract the nonce from the `object` field. Supports both string-IRI
151 +/// and inline-object shapes (`object: "https://b/auth/req/<nonce>"` vs
152 +/// `object: { "id": "...", "type": "AuthRequest", "nonce": "..." }`).
153 +fn extract_nonce_from_object(body: &serde_json::Value) -> Result<String, AppError> {
154 + let object = body.get("object").ok_or_else(|| {
155 + AppError::Validation("Accept/Reject activity missing `object`".into())
156 + })?;
157 +
158 + if let Some(s) = object.as_str() {
159 + // IRI form: last segment is the nonce.
160 + return Ok(s.rsplit('/').next().unwrap_or("").to_string());
161 + }
162 + if let Some(inline) = object.as_object()
163 + && let Some(nonce) = inline.get("nonce").and_then(|v| v.as_str())
164 + {
165 + return Ok(nonce.to_string());
166 + }
167 + Err(AppError::Validation(
168 + "cannot extract nonce from Accept/Reject object".into(),
169 + ))
170 +}
171 +```
172 +
173 +### 3. Enqueue outbound dans `consent_submit`
174 +
175 +[crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs:578](../../crates/rustwarden-ui/src/handlers/fediverse_user_auth.rs#L578) — après `record_decision`, enqueue le `Accept` ou `Reject` sortant.
176 +
177 +```rust
178 +// Après FediverseAuthService::record_decision(&db, challenge_id, decision).await?;
179 +
180 +// Charger le challenge pour récupérer le requester remote_identity.
181 +let model = FediverseAuthService::status(&db, challenge_id).await?;
182 +let requester_id = model
183 + .requested_remote_id
184 + .ok_or_else(|| AppError::Internal("challenge has no requester binding".into()))?;
185 +let requester = RemoteIdentityService::find_by_id(&db, requester_id).await?;
186 +
187 +let approver_user = UserService::find_by_id(&db, claims.user_id()).await?;
188 +let approver_actor_url = format!("{}users/{}", config.base_url(), approver_user.username);
189 +let original_auth_request_iri = format!("{}auth-requests/{}",
190 + requester.base_url_or_homepage(), model.nonce);
191 +
192 +let payload = json!({
193 + "@context": [
194 + "https://www.w3.org/ns/activitystreams",
195 + { "AuthRequest": { "@id": "https://schema.rustwarden.org/auth-request/v1#AuthRequest" } }
196 + ],
197 + "type": if matches!(decision, ChallengeDecision::Approve { .. }) { "Accept" } else { "Reject" },
198 + "id": format!("{}auth-decisions/{}", config.base_url(), uuid::Uuid::new_v4()),
199 + "actor": approver_actor_url,
200 + "object": original_auth_request_iri,
201 + "to": [ requester.inbox_url.clone() ],
202 +});
203 +
204 +FediverseOutboundService::enqueue(
205 + &db,
206 + OutboundEnqueueRequest {
207 + target_inbox: requester.inbox_url.clone(),
208 + actor_user_id: Some(approver_user.id),
209 + payload_json: serde_json::to_string(&payload)?,
210 + activity_kind: ActivityKind::AuthDecision,
211 + },
212 +).await?;
213 +```
214 +
215 +### 4. Payload exact
216 +
217 +#### `Accept(AuthRequest)` — approbation
218 +
219 +```json
220 +{
221 + "@context": [
222 + "https://www.w3.org/ns/activitystreams",
223 + { "AuthRequest": { "@id": "https://schema.rustwarden.org/auth-request/v1#AuthRequest" } }
224 + ],
225 + "type": "Accept",
226 + "id": "http://alpha.local/auth-decisions/0d23-...",
227 + "actor": "http://alpha.local/users/joanie",
228 + "object": "http://beta.local/auth-requests/<nonce>",
229 + "to": ["http://beta.local/users/joanie/inbox"]
230 +}
231 +```
232 +
233 +#### `Reject(AuthRequest)` — refus
234 +
235 +```json
236 +{
237 + "type": "Reject",
238 + "id": "http://alpha.local/auth-decisions/...",
239 + "actor": "http://alpha.local/users/joanie",
240 + "object": "http://beta.local/auth-requests/<nonce>",
241 + "to": ["http://beta.local/users/joanie/inbox"]
242 +}
243 +```
244 +
245 +Le champ `object` est une **IRI string** (forme légère), pas un objet inline. Le nonce de B est extrait du dernier segment de l'URL — c'est ce que la fonction `extract_nonce_from_object` fait avec un fallback objet inline pour robustesse.
246 +
247 +### 5. Tests E2E à ajouter
248 +
249 +À ajouter à [crates/rustwarden-core/tests/e2e_fediverse.rs](../../crates/rustwarden-core/tests/e2e_fediverse.rs) :
250 +
251 +| Test | Couvre |
252 +|---|---|
253 +| `s1_full_round_trip_signed_e2e` | Happy path : B initiate → POST signé vers A → consent API JSON → A enqueue Accept → polling B's consume → 200 + JWT remote |
254 +| `s1_reject_propagates_to_b` | Idem mais decision=reject → B's consume retourne 4xx |
255 +| `s1_unknown_nonce_rejected_silently` | A poste Accept avec nonce inconnu → B retourne 202 (silent ignore) ou 4xx (cf. § Décisions) |
256 +| `s1_expired_challenge_rejects_late_accept` | Accept arrive après TTL → erreur Validation |
257 +| `s1_replay_accept_is_idempotent` | Rejouer le même Accept → 202, pas de double-write DB |
258 +
259 +## Analyse de compatibilité
260 +
261 +- [x] API existante **non modifiée** — `auth_inbox` signature publique inchangée, ajouts derrière la couche service.
262 +- [x] Pas de changement de schéma DB — `fediverse_auth_challenges` et `remote_identities` sont déjà en place.
263 +- [x] Pas de breaking change feature-flag — toujours sous `experimental-fediverse`.
264 +- [x] Pas d'activité AP custom émise jusque-là, donc aucune compat ascendante à maintenir.
265 +- [x] Migration sérialisée : aucune.
266 +
267 +→ **Non-breaking. Minor version bump possible.**
268 +
269 +## Alternatives considérées
270 +
271 +### Alternative A (recommandée) : `Accept` / `Reject` AP standards avec `object` = IRI de l'`AuthRequest`
272 +
273 +**Pros** :
274 +- Convention AP universelle (Mastodon utilise `Accept(Follow)` / `Reject(Follow)` exactement comme ça pour les demandes d'amitié, cf. [Mastodon AP spec § Follow](https://docs.joinmastodon.org/spec/activitypub/)).
275 +- Spec W3C explicite (`Accept` / `Reject` polymorphes selon `object.type`, [ActivityPub §7.6-7.7](https://www.w3.org/TR/activitypub/#accept-activity-inbox)).
276 +- Pas de nouveau type custom à documenter ni à protéger contre la sérialisation parasite par AP-strict servers.
277 +- Mastodon ignore silencieusement → isolation propre du protocole Rustwarden.
278 +- Dispatch côté B trivial : un seul `match` sur `type`, déjà familier au reste de l'écosystème.
279 +
280 +**Cons** :
281 +- Lecture du payload nécessite un fallback IRI/inline pour le `object` (légèrement plus de code que `body.nonce` direct).
282 +- Sémantique « Accept » peut sembler bizarre pour de l'auth (vs. Follow) — atténué par le namespace clair de l'`object`.
283 +
284 +### Alternative B : Activités custom `AuthApproval` / `AuthDenial`
285 +
286 +**Pros** :
287 +- Sémantique plus directement lisible côté code.
288 +- Champ `nonce` au niveau racine sans extraction depuis `object`.
289 +
290 +**Cons** :
291 +- Invente un type AP non-standard alors qu'un type standard existe et correspond.
292 +- Toute implémentation tierce stricte (validateurs JSON-LD) requiert le `@context` complet documenté, sinon le terme est silencieusement perdu — coût de maintenance.
293 +- Mastodon le rejette comme inconnu → log noise sur tous les serveurs tiers même si interop n'est pas un goal.
294 +- Pas dans la lignée de ce que la RFC 0006 originale a écrit dans son Summary (`Accept{AuthRequest}` est explicitement mentionné).
295 +
296 +**Verdict** : rejetée.
297 +
298 +### Alternative C : Polling B → A (`GET /fediverse/auth/status/{nonce}`)
299 +
300 +**Pros** :
301 +- Pas de signature à émettre côté A.
302 +- B contrôle son propre rythme de polling.
303 +- Pas de side-effect inbox.
304 +
305 +**Cons** :
306 +- Charge réseau prévisible mais O(N) par challenge ouvert (B doit poller chaque challenge non résolu).
307 +- Latence de notification = intervalle de poll.
308 +- Casse le modèle AP-push qui structure le reste de Rustwarden Fediverse.
309 +- N'aide pas pour l'extension future (`Update Person`, `Delete Person`, RFC 0010) qui nécessite le push de toute façon.
310 +
311 +**Verdict** : rejetée (moins propre que A, et n'aide pas pour la suite).
312 +
313 +### Alternative D : Long-polling / SSE B → A
314 +
315 +**Pros** :
316 +- Latence quasi-nulle.
317 +
318 +**Cons** :
319 +- Connexions persistantes coûteuses (potentiellement N par utilisateur sur l'IdP).
320 +- Ajout d'infrastructure (timeouts, gestion déconnexions) pour une fonctionnalité que push AP gère gratuitement.
321 +- Non-aligned avec le reste du protocole.
322 +
323 +**Verdict** : rejetée.
324 +
325 +### Alternative E : Ne rien faire (statu quo)
326 +
327 +**Pros** : pas de code à écrire.
328 +
329 +**Cons** :
330 +- `experimental-fediverse` ne peut **pas** être promu stable. Le SSO bilatéral est l'objectif #1 du master plan, et il ne fonctionne pas.
331 +- Tout futur consommateur découvrira le trou en testant et le signalera comme bug critique.
332 +
333 +**Verdict** : rejetée.
334 +
335 +## Sécurité
336 +
337 +### Replay protection
338 +
339 +Une activité `Accept` interceptée et rejouée :
340 +- Hors fenêtre Date (>30s, cf. RFC 0015) → rejetée par le verifier de signature.
341 +- Dans la fenêtre, sur un challenge déjà `Approved` → idempotent (cf. § record_inbound_decision), aucun side-effect.
342 +- Dans la fenêtre, sur un challenge déjà `Consumed` → idempotent.
343 +- Sur un nonce qui n'existe pas → 4xx (cf. test `s1_unknown_nonce_rejected_silently`).
344 +
345 +Pour la sécurité accrue (challenge nonce single-use + stockage `seen_accept_ids`), une suite F3-bis pourrait ajouter une table `fediverse_auth_decisions_seen` indexée par `Accept.id`. **Hors scope cette RFC.**
346 +
347 +### Authentification de l'approver
348 +
349 +`record_inbound_decision` se contente de retrouver le `remote_identities` row du `actor` de l'`Accept` — le verifier de signature en amont garantit que la clé qui a signé correspond au domaine de cet actor. Donc :
350 +
351 +- Un attaquant sur `evil.tld` ne peut **pas** émettre un `Accept(http://beta.local/auth-requests/<nonce>)` valide pour B parce que sa signature serait signée par `evil.tld`, mais l'IdP autorisé est `alpha.local` — mismatch détecté.
352 +- Plus précisément : B doit vérifier que l'`actor` du `Accept` est sur le **même domaine** que l'IdP qui a reçu l'`AuthRequest`. Cette vérification est ajoutée à `record_inbound_decision` (extension simple : recharger le challenge, comparer `challenge.domain` au domaine de l'actor).
353 +
354 +### Note sur RFC 9421
355 +
356 +Mastodon est passé de draft-cavage à RFC 9421 (`Signature-Input` + `Signature`) en v4.5.0 ([Mastodon Security spec](https://docs.joinmastodon.org/spec/security/)). Cette RFC continue d'utiliser draft-cavage comme partout dans le projet ; **une RFC séparée traitera la migration vers RFC 9421** pour l'ensemble du fediverse Rustwarden.
357 +
358 +## Décisions explicites à noter
359 +
360 +1. **Le `object` est une IRI string par défaut** (pas un inline object) — réduit le verbosité et matche ce que Mastodon fait. Fallback inline pour robustesse défensive.
361 +2. **Nonce extrait du dernier segment de l'URL** — convention `http://b.tld/auth-requests/<nonce>`. Documenter et exposer cette URL dans `build_auth_request_payload` (RFC 0006 ligne 354, déjà compatible : le champ `id` y est déjà construit comme `{base_url}auth-requests/{challenge.id}`, à modifier pour utiliser le nonce).
362 +3. **Replay sur challenge décidé → 202 silent** (idempotence) plutôt que 4xx. AP-friendly : tout outbound worker qui retry n'a pas à savoir si c'est la 1re ou la 3e tentative.
363 +4. **Replay sur challenge expiré → 4xx** plutôt que 202. Décision claire : l'expiry est une condition métier, pas une erreur transitoire.
364 +5. **Nonce inconnu → 4xx Validation** plutôt que ignore silent. Aide au debug (logs explicites) et ne permet pas d'utiliser l'inbox comme oracle pour des nonces existants (le timing reste constant : query DB unique dans les deux cas).
365 +
366 +## Plan d'implémentation
367 +
368 +### Étape 1 — Service `record_inbound_decision`
369 +- Implémentation + tests unitaires `MockDatabase` (5 tests : happy path, idempotence approved/consumed, unknown nonce, expired, type mismatch).
370 +- ~120 LoC code + 80 LoC tests.
371 +
372 +### Étape 2 — Dispatch type-based dans `auth_inbox`
373 +- Refactor du handler avec `match`.
374 +- Adapter le test existant `auth_inbox_returns_400_when_requester_actor_unknown` qui ne touche que la branche AuthRequest.
375 +- ~50 LoC.
376 +
377 +### Étape 3 — Build payload helper
378 +- `build_auth_decision_payload(config, challenge, approver_actor, decision)` retournant le JSON.
379 +- Tests unitaires : structure JSON-LD, contexte, object IRI bien formé.
380 +- ~80 LoC.
381 +
382 +### Étape 4 — Enqueue dans `consent_submit` UI
383 +- Wire l'enqueue après `record_decision`.
384 +- Test unitaire (UI handler) avec MockDatabase qui vérifie l'enqueue.
385 +- ~60 LoC.
386 +
387 +### Étape 5 — 5 tests E2E nouveaux dans `e2e_fediverse.rs`
388 +- ~300 LoC.
389 +
390 +**Total estimé** : ~700 LoC. ~1.5 jour de développement + tests.
391 +
392 +## Critères d'acceptation
393 +
394 +- [x] Test `s1_full_round_trip_accept_marks_b_challenge_approved` vert sans aucun DB-direct côté B.
395 +- [x] Test `s1_reject_propagates_and_blocks_consume` vert.
396 +- [x] Test `s1_replay_accept_is_idempotent` vert (assertion via `xmin` PostgreSQL).
397 +- [x] Test `s1_expired_challenge_rejects_late_accept` vert.
398 +- [x] Test bonus `s1_unknown_nonce_is_rejected` vert.
399 +- [x] Quality gates standards : build clean, clippy `-D warnings`, doc clean, tests unitaires verts (348/348).
400 +- [x] Mise à jour CHANGELOG.md.
401 +- [x] Plan parent [iter1-findings.md](../plans/2026-05-11-fediverse-stable-promotion-iter1-findings.md) marque F3 = ✅.
402 +- [ ] Doc opérateur (futur RFC) mentionne que `Accept(AuthRequest)` est le format de décision attendu. *(reporté hors-scope cette RFC)*
403 +
404 +## Références
405 +
406 +- [W3C ActivityPub Recommendation, §7.6 Accept](https://www.w3.org/TR/activitypub/#accept-activity-inbox), [§7.7 Reject](https://www.w3.org/TR/activitypub/#reject-activity-inbox)
407 +- [Mastodon ActivityPub specification](https://docs.joinmastodon.org/spec/activitypub/) — Follow approval flow
408 +- [Mastodon Security specification](https://docs.joinmastodon.org/spec/security/) — HTTP Signatures, fenêtre Date
409 +- [SWICG HTTP Signatures guide](https://swicg.github.io/activitypub-http-signature/) — pratiques 2025
410 +- [SocialHub: Single Sign-On for Fediverse thread](https://socialhub.activitypub.rocks/t/single-sign-on-for-fediverse/712) — état du non-consensus
411 +- RFC 0006 — flow d'authentification AP-native (couche service, Phase 1 + 2)
412 +- RFC 0011 — outbound queue
413 +- RFC 0015 — HTTP signatures
M docs/rfcs/README.md
+1 -0
@@ -83,6 +83,7 @@ Score minimum **25/35** pour passer en `Accepted`. Voir grille détaillée dans
83 83 | [0022](0022-smoke-tests-regression.md) | Smoke tests anti-régression (middleware AuthConfig + pagination admin) | **Accepted** (26/35) | EBO | 2026-05-10 |
84 84 | [0023](0023-fediverse-ui-user-auth.md) | Fediverse UI — Parcours utilisateur d'authentification fédérée (Slice B) | **Accepted** (26/35) | EBO | 2026-05-10 |
85 85 | [0024](0024-fediverse-ui-ops-slice-c.md) | Fediverse UI — Invitations & ops admin (Slice C, 6 écrans) | **Accepted** (26/35) | EBO | 2026-05-10 |
86 +| [0025](0025-fediverse-auth-back-channel.md) | Fediverse — Back-channel `Accept(AuthRequest)` (RFC 0006 Phase 3) | **Accepted** (livré + 5 tests E2E verts) | EBO | 2026-05-11 |
86 87
87 88 À mesure que les RFCs sont créées, elles doivent apparaître dans cette table avec leur statut courant.
88 89
A tests/fediverse-testbed/.gitignore
+3 -0
@@ -0,0 +1,3 @@
1 +data/
2 +*.log
3 +.env.local
A tests/fediverse-testbed/Dockerfile
+43 -0
@@ -0,0 +1,43 @@
1 +# Build context must be the workspace root: `docker build -f tests/fediverse-testbed/Dockerfile .`
2 +# The compose file already passes context: ../.. accordingly.
3 +
4 +FROM rust:1.91-slim-bookworm AS builder
5 +WORKDIR /build
6 +
7 +RUN apt-get update \
8 + && apt-get install -y --no-install-recommends pkg-config libssl-dev ca-certificates \
9 + && rm -rf /var/lib/apt/lists/*
10 +
11 +COPY Cargo.toml Cargo.lock ./
12 +COPY app ./app
13 +COPY crates ./crates
14 +COPY tools ./tools
15 +
16 +# rustwarden-app + migrate (SeaORM CLI) + seed helper.
17 +RUN cargo build --release \
18 + -p rustwarden-app --features experimental-fediverse \
19 + && cargo build --release \
20 + -p rustwarden-core --bin seed --bin migrate
21 +
22 +FROM debian:bookworm-slim AS runtime
23 +RUN apt-get update \
24 + && apt-get install -y --no-install-recommends ca-certificates libssl3 curl postgresql-client \
25 + && rm -rf /var/lib/apt/lists/*
26 +
27 +WORKDIR /app
28 +
29 +# Locales are read at runtime from these *relative* paths (see init_i18n in
30 +# crates/rustwarden-core/src/app.rs). The directory layout must mirror the
31 +# workspace tree for the i18n loader to find them.
32 +COPY --from=builder /build/crates/rustwarden-core/locales ./crates/rustwarden-core/locales
33 +COPY --from=builder /build/crates/rustwarden-ui/locales ./crates/rustwarden-ui/locales
34 +
35 +# Static assets served by ServeDir (path overridable via RUSTWARDEN_STATIC_DIR).
36 +COPY static ./static
37 +
38 +COPY --from=builder /build/target/release/rustwarden /usr/local/bin/rustwarden
39 +COPY --from=builder /build/target/release/seed /usr/local/bin/seed
40 +COPY --from=builder /build/target/release/migrate /usr/local/bin/migrate
41 +
42 +EXPOSE 80
43 +CMD ["rustwarden"]
A tests/fediverse-testbed/README.md
+123 -0
@@ -0,0 +1,123 @@
1 +# Fediverse testbed — three-instance Rustwarden topology
2 +
3 +Banc d'essai isolé pour valider la fédération identitaire Rustwarden ↔ Rustwarden en local. Trois instances complètes (`alpha`, `beta`, `gamma`) tournent dans Docker, chacune avec sa propre PostgreSQL et son `FEDIVERSE_DOMAIN`, communiquant via le réseau Docker sans TLS ni reverse proxy.
4 +
5 +Cet environnement sert d'**itération 0** de la campagne décrite dans [docs/plans/2026-05-10-iter0-fediverse-docker-compose.md](../../docs/plans/2026-05-10-iter0-fediverse-docker-compose.md) et [docs/plans/2026-05-10-fediverse-promotion-stable-evaluation.md](../../docs/plans/2026-05-10-fediverse-promotion-stable-evaluation.md).
6 +
7 +## Prérequis
8 +
9 +- Docker Engine + Compose plugin v2 (`docker compose version` doit fonctionner).
10 +- ~3 Go de RAM libre.
11 +- Ports `3001`, `3002`, `3003` libres sur l'hôte.
12 +
13 +Aucun ajout dans `/etc/hosts` n'est nécessaire pour les tests automatisés. Pour piloter le testbed depuis un navigateur sur l'hôte, voir « Accès humain » plus bas.
14 +
15 +## Démarrage
16 +
17 +```bash
18 +cd tests/fediverse-testbed
19 +./scripts/setup.sh
20 +```
21 +
22 +Le script :
23 +
24 +1. Build l'image multi-stage (long la première fois, ~5–10 min ; secondes ensuite grâce au cache).
25 +2. Démarre les 3 PostgreSQL et attend qu'elles soient `healthy`.
26 +3. Lance les 3 jobs `migrate-*` one-shot (SeaORM CLI : `migrate up`). L'app **ne fait pas** les migrations au démarrage, ce service le fait à sa place.
27 +4. Démarre les 3 `app-*` une fois leur migrate-* sorti en code 0.
28 +5. Lance les 3 jobs `seed-*` qui chargent les fixtures Happy Days.
29 +6. Insère en SQL la trust list croisée (`alpha ↔ beta ↔ gamma`) et active `fediverse_enabled`.
30 +7. Smoke check final depuis l'hôte (`curl /health` × 3).
31 +
32 +## Topologie
33 +
34 +```
35 + ┌──────────────┐
36 + │ fediverse- │
37 + │ net │
38 + │ (bridge) │
39 + └──────┬───────┘
40 + ┌────────────────────┼─────────────────────┐
41 + │ │ │
42 + ┌─────────▼────────┐ ┌─────────▼────────┐ ┌─────────▼────────┐
43 + │ app-alpha │ │ app-beta │ │ app-gamma │
44 + │ alias alpha.local│ │ alias beta.local │ │ alias gamma.local│
45 + │ host: :3001 │ │ host: :3002 │ │ host: :3003 │
46 + └─────────┬────────┘ └─────────┬────────┘ └─────────┬────────┘
47 + │ │ │
48 + ┌─────────▼────────┐ ┌─────────▼────────┐ ┌─────────▼────────┐
49 + │ postgres-alpha │ │ postgres-beta │ │ postgres-gamma │
50 + └──────────────────┘ └──────────────────┘ └──────────────────┘
51 +```
52 +
53 +À l'intérieur du réseau Docker, chaque app résout `alpha.local`, `beta.local`, `gamma.local` vers les containers des autres apps grâce aux `network_aliases`. Aucune dépendance DNS hôte.
54 +
55 +## Identifiants seed
56 +
57 +Chaque instance a les mêmes 5 utilisateurs Happy Days (cf. `crates/rustwarden-core/src/bin/seed.rs`) :
58 +
59 +| Login | Email | Rôle | Mot de passe |
60 +|---|---|---|---|
61 +| joanie | joanie@rustwarden.local | admin | `happydays123` |
62 +| richie | richie@rustwarden.local | user | `happydays123` |
63 +| fonzie | fonzie@rustwarden.local | user | `happydays123` |
64 +| potsie | potsie@rustwarden.local | user | `happydays123` |
65 +| ralph | ralph@rustwarden.local | user | `happydays123` |
66 +
67 +## Vérifications manuelles utiles
68 +
69 +```bash
70 +# Santé
71 +curl http://localhost:3001/health
72 +curl http://localhost:3002/health
73 +curl http://localhost:3003/health
74 +
75 +# WebFinger (un user existant sur alpha)
76 +curl 'http://localhost:3001/.well-known/webfinger?resource=acct:joanie@alpha.local'
77 +
78 +# Trust list configurée
79 +docker compose exec -T postgres-alpha psql -U rustwarden -d rustwarden_alpha \
80 + -c 'SELECT domain FROM fediverse_trusted_domains;'
81 +
82 +# Logs en direct d'une instance
83 +docker compose logs -f app-alpha
84 +```
85 +
86 +## Accès humain (optionnel)
87 +
88 +Les URLs ActivityPub renvoyées par chaque instance contiennent `alpha.local` / `beta.local` / `gamma.local`. Pour suivre ces liens depuis un navigateur sur l'hôte, ajouter une fois pour toutes :
89 +
90 +```
91 +# /etc/hosts
92 +127.0.0.1 alpha.local beta.local gamma.local
93 +```
94 +
95 +Puis pour accéder à chaque instance, utiliser les ports : `http://alpha.local:3001`, `http://beta.local:3002`, `http://gamma.local:3003`. Les tests automatisés (`cargo test`) n'en ont pas besoin — ils s'adressent à `localhost:3001/3002/3003`.
96 +
97 +## Cycle de vie
98 +
99 +| Commande | Effet | Quand l'utiliser |
100 +|---|---|---|
101 +| `./scripts/setup.sh` | Build + démarre + migre + seed + trust + smoke | Premier boot, ou après un teardown |
102 +| `./scripts/stop.sh` | `docker compose stop` — pause sans rien perdre | Tu veux libérer CPU/RAM mais reprendre vite avec l'état actuel (DB préservée) |
103 +| `./scripts/start.sh` | Reprend les containers stoppés, attend `/health` | Après un `stop.sh`. Pas de re-seed, pas de re-trust |
104 +| `./scripts/teardown.sh` | `docker compose down -v` — purge containers + volumes | Tu veux repartir d'un état totalement vierge |
105 +
106 +Cycle typique pour itérer sur des tests sans tout reconstruire :
107 +```bash
108 +./scripts/setup.sh # une fois
109 +cargo test --features ... # itère
110 +./scripts/stop.sh # pause quand tu fermes ta session
111 +./scripts/start.sh # reprend plus tard
112 +```
113 +
114 +## Limites connues
115 +
116 +- HTTP plain only. Le flag `FEDIVERSE_DEBUG_LOCAL_FEDERATION=true` autorise le bypass HTTPS en local — ne **jamais** activer en production.
117 +- Build à froid long. Mitigation v2 : intégrer [`cargo-chef`](https://github.com/LukeMathWalker/cargo-chef) au Dockerfile pour cacher les couches de dépendances.
118 +- Les `seed-*` se déclenchent après `app-*` healthy, donc après que l'app a déjà démarré. Conséquence : les rôles `admin`/`user` et les premiers users sont insérés **après** que le serveur HTTP écoute déjà — bref creux de quelques secondes où l'app tourne sans user. Pas bloquant pour les tests E2E qui attendent `setup.sh` complet.
119 +- La trust list est écrite via SQL direct (`configure-trust.sh`) et non via l'UI admin (CSRF). Si la table `fediverse_trusted_domains` change de schéma, mettre à jour le script.
120 +
121 +## Plan d'évolution
122 +
123 +Itération 1+ : tests E2E Rust (`cargo test --test e2e_fediverse`) qui pilotent le testbed via les ports `localhost:300X` exposés. Code dans `crates/rustwarden-core/tests/e2e_fediverse/` (à créer, hors périmètre de cette itération).
A tests/fediverse-testbed/docker-compose.yml
+182 -0
@@ -0,0 +1,182 @@
1 +# Three-instance Rustwarden topology for fediverse end-to-end tests.
2 +# Each app talks to the others via the docker network using FEDIVERSE_DOMAIN
3 +# as a network alias, so signed HTTP requests can resolve the peer hostnames
4 +# without any /etc/hosts trickery on the host machine.
5 +#
6 +# Usage:
7 +# cd tests/fediverse-testbed
8 +# ./scripts/setup.sh
9 +# ./scripts/teardown.sh
10 +
11 +name: rustwarden-fediverse-testbed
12 +
13 +x-app-common: &app-common
14 + build:
15 + context: ../..
16 + dockerfile: tests/fediverse-testbed/Dockerfile
17 + image: rustwarden-fediverse-testbed:latest
18 + restart: unless-stopped
19 + healthcheck:
20 + test: ["CMD", "curl", "-fsS", "http://localhost/health"]
21 + interval: 5s
22 + timeout: 3s
23 + retries: 30
24 + start_period: 10s
25 +
26 +services:
27 + postgres-alpha:
28 + image: postgres:18-trixie
29 + environment:
30 + POSTGRES_DB: rustwarden_alpha
31 + POSTGRES_USER: rustwarden
32 + POSTGRES_PASSWORD: rustwarden
33 + healthcheck:
34 + test: ["CMD-SHELL", "pg_isready -U rustwarden -d rustwarden_alpha"]
35 + interval: 3s
36 + timeout: 2s
37 + retries: 20
38 + networks: [fediverse-net]
39 +
40 + postgres-beta:
41 + image: postgres:18-trixie
42 + environment:
43 + POSTGRES_DB: rustwarden_beta
44 + POSTGRES_USER: rustwarden
45 + POSTGRES_PASSWORD: rustwarden
46 + healthcheck:
47 + test: ["CMD-SHELL", "pg_isready -U rustwarden -d rustwarden_beta"]
48 + interval: 3s
49 + timeout: 2s
50 + retries: 20
51 + networks: [fediverse-net]
52 +
53 + postgres-gamma:
54 + image: postgres:18-trixie
55 + environment:
56 + POSTGRES_DB: rustwarden_gamma
57 + POSTGRES_USER: rustwarden
58 + POSTGRES_PASSWORD: rustwarden
59 + healthcheck:
60 + test: ["CMD-SHELL", "pg_isready -U rustwarden -d rustwarden_gamma"]
61 + interval: 3s
62 + timeout: 2s
63 + retries: 20
64 + networks: [fediverse-net]
65 +
66 + # One-shot migration jobs. The rustwarden-app binary does NOT auto-run
67 + # migrations at startup — the dedicated `migrate` CLI (SeaORM) must run
68 + # first and exit 0 before the apps boot.
69 + migrate-alpha:
70 + <<: *app-common
71 + restart: "no"
72 + healthcheck:
73 + disable: true
74 + env_file: env/alpha.env
75 + command: ["migrate", "up"]
76 + depends_on:
77 + postgres-alpha:
78 + condition: service_healthy
79 + networks: [fediverse-net]
80 +
81 + migrate-beta:
82 + <<: *app-common
83 + restart: "no"
84 + healthcheck:
85 + disable: true
86 + env_file: env/beta.env
87 + command: ["migrate", "up"]
88 + depends_on:
89 + postgres-beta:
90 + condition: service_healthy
91 + networks: [fediverse-net]
92 +
93 + migrate-gamma:
94 + <<: *app-common
95 + restart: "no"
96 + healthcheck:
97 + disable: true
98 + env_file: env/gamma.env
99 + command: ["migrate", "up"]
100 + depends_on:
101 + postgres-gamma:
102 + condition: service_healthy
103 + networks: [fediverse-net]
104 +
105 + app-alpha:
106 + <<: *app-common
107 + env_file: env/alpha.env
108 + depends_on:
109 + migrate-alpha:
110 + condition: service_completed_successfully
111 + ports:
112 + - "3001:80"
113 + networks:
114 + fediverse-net:
115 + aliases: [alpha.local]
116 +
117 + app-beta:
118 + <<: *app-common
119 + env_file: env/beta.env
120 + depends_on:
121 + migrate-beta:
122 + condition: service_completed_successfully
123 + ports:
124 + - "3002:80"
125 + networks:
126 + fediverse-net:
127 + aliases: [beta.local]
128 +
129 + app-gamma:
130 + <<: *app-common
131 + env_file: env/gamma.env
132 + depends_on:
133 + migrate-gamma:
134 + condition: service_completed_successfully
135 + ports:
136 + - "3003:80"
137 + networks:
138 + fediverse-net:
139 + aliases: [gamma.local]
140 +
141 + # One-shot seed jobs: each waits for its app to be healthy (which means
142 + # migrations have run) then loads the Happy Days fixtures with
143 + # joanie/happydays123 as the admin user.
144 + seed-alpha:
145 + <<: *app-common
146 + restart: "no"
147 + healthcheck:
148 + disable: true
149 + env_file: env/alpha.env
150 + command: ["seed", "--force"]
151 + depends_on:
152 + app-alpha:
153 + condition: service_healthy
154 + networks: [fediverse-net]
155 +
156 + seed-beta:
157 + <<: *app-common
158 + restart: "no"
159 + healthcheck:
160 + disable: true
161 + env_file: env/beta.env
162 + command: ["seed", "--force"]
163 + depends_on:
164 + app-beta:
165 + condition: service_healthy
166 + networks: [fediverse-net]
167 +
168 + seed-gamma:
169 + <<: *app-common
170 + restart: "no"
171 + healthcheck:
172 + disable: true
173 + env_file: env/gamma.env
174 + command: ["seed", "--force"]
175 + depends_on:
176 + app-gamma:
177 + condition: service_healthy
178 + networks: [fediverse-net]
179 +
180 +networks:
181 + fediverse-net:
182 + driver: bridge
A tests/fediverse-testbed/env/alpha.env
+14 -0
@@ -0,0 +1,14 @@
1 +# Instance ALPHA — testbed only, no real secrets.
2 +DATABASE_URL=postgres://rustwarden:rustwarden@postgres-alpha:5432/rustwarden_alpha
3 +JWT_SECRET=testbed-alpha-jwt-secret-32-bytes-minimum-padding-1234567890
4 +JWT_ISSUER=rustwarden-alpha
5 +ENCRYPTION_KEY=a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1
6 +SERVER_HOST=0.0.0.0
7 +SERVER_PORT=80
8 +FEDIVERSE_DOMAIN=http://alpha.local
9 +FEDIVERSE_DEBUG_LOCAL_FEDERATION=true
10 +APP_DEBUG=true
11 +COOKIE_SECURE=false
12 +SESSION_BACKEND=seaorm
13 +ENVIRONMENT=development
14 +RUST_LOG=info,rustwarden=debug
A tests/fediverse-testbed/env/beta.env
+14 -0
@@ -0,0 +1,14 @@
1 +# Instance BETA — testbed only, no real secrets.
2 +DATABASE_URL=postgres://rustwarden:rustwarden@postgres-beta:5432/rustwarden_beta
3 +JWT_SECRET=testbed-beta-jwt-secret-32-bytes-minimum-padding-1234567890aa
4 +JWT_ISSUER=rustwarden-beta
5 +ENCRYPTION_KEY=b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2
6 +SERVER_HOST=0.0.0.0
7 +SERVER_PORT=80
8 +FEDIVERSE_DOMAIN=http://beta.local
9 +FEDIVERSE_DEBUG_LOCAL_FEDERATION=true
10 +APP_DEBUG=true
11 +COOKIE_SECURE=false
12 +SESSION_BACKEND=seaorm
13 +ENVIRONMENT=development
14 +RUST_LOG=info,rustwarden=debug
A tests/fediverse-testbed/env/gamma.env
+14 -0
@@ -0,0 +1,14 @@
1 +# Instance GAMMA — testbed only, no real secrets.
2 +DATABASE_URL=postgres://rustwarden:rustwarden@postgres-gamma:5432/rustwarden_gamma
3 +JWT_SECRET=testbed-gamma-jwt-secret-32-bytes-minimum-padding-12345678901
4 +JWT_ISSUER=rustwarden-gamma
5 +ENCRYPTION_KEY=c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3
6 +SERVER_HOST=0.0.0.0
7 +SERVER_PORT=80
8 +FEDIVERSE_DOMAIN=http://gamma.local
9 +FEDIVERSE_DEBUG_LOCAL_FEDERATION=true
10 +APP_DEBUG=true
11 +COOKIE_SECURE=false
12 +SESSION_BACKEND=seaorm
13 +ENVIRONMENT=development
14 +RUST_LOG=info,rustwarden=debug
A tests/fediverse-testbed/scripts/configure-trust.sh
+45 -0
@@ -0,0 +1,45 @@
1 +#!/usr/bin/env bash
2 +# Configure cross-instance trust by writing directly into each instance's
3 +# fediverse_trusted_domains table via psql.
4 +#
5 +# Rationale: the admin trust list UI is a CSRF-protected form POST that
6 +# would require a full login + token-extraction dance for automation.
7 +# Direct SQL insertion is simpler, safer (no auth bypass), and only
8 +# reasonable in this testbed context where we own the database.
9 +
10 +set -euo pipefail
11 +
12 +cd "$(dirname "$0")/.."
13 +
14 +# Bidirectional trust: each instance trusts the two others.
15 +declare -A TRUSTS=(
16 + [alpha]="beta.local gamma.local"
17 + [beta]="alpha.local gamma.local"
18 + [gamma]="alpha.local beta.local"
19 +)
20 +
21 +# Flip the fediverse_enabled app setting to true, so admin UI shows the
22 +# fediverse pages. Protocol endpoints (inbox/webfinger) do not depend on
23 +# this flag, but the admin UX does.
24 +ENABLE_SQL="
25 +UPDATE app_settings SET value = 'true' WHERE key = 'fediverse_enabled';
26 +"
27 +
28 +for instance in alpha beta gamma; do
29 + echo "==> Configuring trust list on $instance"
30 + for peer in ${TRUSTS[$instance]}; do
31 + SQL="
32 + INSERT INTO fediverse_trusted_domains (id, domain, note, added_by_user_id, created_at)
33 + VALUES (gen_random_uuid(), '$peer', 'testbed bootstrap', NULL, NOW())
34 + ON CONFLICT (domain) DO NOTHING;
35 + "
36 + docker compose exec -T "postgres-$instance" psql -U rustwarden -d "rustwarden_$instance" \
37 + -c "$SQL" >/dev/null
38 + echo " trusts $peer"
39 + done
40 + docker compose exec -T "postgres-$instance" psql -U rustwarden -d "rustwarden_$instance" \
41 + -c "$ENABLE_SQL" >/dev/null
42 + echo " fediverse_enabled=true"
43 +done
44 +
45 +echo "==> Trust list configured for all three instances."
A tests/fediverse-testbed/scripts/setup.sh
+62 -0
@@ -0,0 +1,62 @@
1 +#!/usr/bin/env bash
2 +# Start the three-instance Rustwarden fediverse testbed.
3 +#
4 +# Steps:
5 +# 1. docker compose up -d --wait -> postgres + apps healthy
6 +# 2. wait for seed-* jobs to exit 0 -> Happy Days users loaded
7 +# 3. configure-trust.sh -> cross-instance allowlist
8 +# 4. smoke-check /health on the 3 apps -> visible from the host
9 +
10 +set -euo pipefail
11 +
12 +cd "$(dirname "$0")/.."
13 +
14 +echo "==> docker compose up (build + start, wait for health)"
15 +# `--wait` waits for: services with healthcheck to become healthy, AND
16 +# one-shot services (restart:no, no healthcheck) to exit 0. So when this
17 +# returns, migrate-* and seed-* are guaranteed to have completed
18 +# successfully — no separate wait loop needed.
19 +docker compose up -d --wait --wait-timeout 300
20 +
21 +echo "==> Verifying one-shot jobs exited 0"
22 +for job in migrate-alpha migrate-beta migrate-gamma seed-alpha seed-beta seed-gamma; do
23 + code=$(docker inspect -f '{{.State.ExitCode}}' "rustwarden-fediverse-testbed-${job}-1")
24 + if [ "$code" != "0" ]; then
25 + echo " $job FAILED (exit $code)"
26 + docker compose logs "$job" | tail -40
27 + exit 1
28 + fi
29 + echo " $job ok"
30 +done
31 +
32 +echo "==> Configuring cross-instance trust"
33 +./scripts/configure-trust.sh
34 +
35 +echo "==> Host-side smoke checks"
36 +for port in 3001 3002 3003; do
37 + echo -n " http://localhost:$port/health ... "
38 + if curl -fsS "http://localhost:$port/health" >/dev/null; then
39 + echo "ok"
40 + else
41 + echo "FAILED"
42 + exit 1
43 + fi
44 +done
45 +
46 +cat <<'EOF'
47 +
48 +==> Testbed ready.
49 +
50 + Instances:
51 + alpha.local -> http://localhost:3001 (DB rustwarden_alpha)
52 + beta.local -> http://localhost:3002 (DB rustwarden_beta)
53 + gamma.local -> http://localhost:3003 (DB rustwarden_gamma)
54 +
55 + Seeded admin (per instance):
56 + email joanie@rustwarden.local
57 + password happydays123
58 +
59 + To stop and purge everything:
60 + ./scripts/teardown.sh
61 +
62 +EOF
A tests/fediverse-testbed/scripts/start.sh
+28 -0
@@ -0,0 +1,28 @@
1 +#!/usr/bin/env bash
2 +# Resume a previously-stopped testbed. Skips build, seed, and trust config —
3 +# state from the last setup is preserved.
4 +#
5 +# Use ./scripts/setup.sh for the first boot or after a teardown.
6 +
7 +set -euo pipefail
8 +
9 +cd "$(dirname "$0")/.."
10 +
11 +echo "==> docker compose start (resume paused containers)"
12 +docker compose start
13 +
14 +echo "==> Waiting for /health on the three apps"
15 +for port in 3001 3002 3003; do
16 + echo -n " http://localhost:$port/health ... "
17 + # Up to 30 s of polling — typical resume is < 5 s but cold disk caches
18 + # on slow hosts may need longer.
19 + for _ in $(seq 1 30); do
20 + if curl -fsS "http://localhost:$port/health" >/dev/null 2>&1; then
21 + echo "ok"
22 + break
23 + fi
24 + sleep 1
25 + done
26 +done
27 +
28 +echo "==> Testbed resumed."
A tests/fediverse-testbed/scripts/stop.sh
+14 -0
@@ -0,0 +1,14 @@
1 +#!/usr/bin/env bash
2 +# Pause the testbed without losing state. Containers and volumes stay,
3 +# DB data preserved. Resume later with ./scripts/start.sh.
4 +#
5 +# For a full purge (fresh state on next setup), use ./scripts/teardown.sh.
6 +
7 +set -euo pipefail
8 +
9 +cd "$(dirname "$0")/.."
10 +
11 +echo "==> docker compose stop (containers paused, volumes preserved)"
12 +docker compose stop
13 +
14 +echo "==> Stopped. Resume with ./scripts/start.sh, or purge with ./scripts/teardown.sh."
A tests/fediverse-testbed/scripts/teardown.sh
+12 -0
@@ -0,0 +1,12 @@
1 +#!/usr/bin/env bash
2 +# Stop the testbed and purge all volumes so the next setup starts from a
3 +# clean slate.
4 +
5 +set -euo pipefail
6 +
7 +cd "$(dirname "$0")/.."
8 +
9 +echo "==> docker compose down -v"
10 +docker compose down -v --remove-orphans
11 +
12 +echo "==> Done. No containers, no volumes left."