phase 1 à 5 fediverse

EBO <eric.bouhana@softalys.com> committé le 2026-05-09 23:14

22e095a46e616c1d11948d7df7ec0e8e6b3204a2

1 parent(s)

74 fichiers modifiés +14849 -2
A CHANGELOG.md
+328 -0
@@ -0,0 +1,328 @@
1 +# Changelog
2 +
3 +All notable changes to this project are documented in this file.
4 +
5 +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6 +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 +
8 +## [Unreleased]
9 +
10 +### Added
11 +
12 +- **Workspace** : edition Rust **2024** + `rust-version = "1.85"` au niveau
13 + `[workspace.package]`. Toutes les crates héritent (`edition.workspace = true`).
14 +- **RFC 0015** — Fediverse: signatures HTTP draft-cavage (RSA-SHA256).
15 + Premier déblocage cryptographique réel pour interop Mastodon.
16 + - Nouveau module public gated `experimental-fediverse` :
17 + `rustwarden_core::fediverse::http_signatures::{ParsedSignature, parse_signature_header, build_signature_input, rsa_sha256_sign, rsa_sha256_verify, parse_and_verify_cavage}`.
18 + - Aucune nouvelle dépendance externe : réutilise `rsa` (RFC 0005) +
19 + `sha2` + `base64` déjà tirés.
20 + - Helpers de bas niveau (parse + canonical input + sign/verify
21 + primitives) + helper haut niveau `parse_and_verify_cavage` pour
22 + les inbox handlers qui ont accès aux full headers.
23 + - 11 tests unit (parsing + canonical + crypto round-trip + tampered
24 + rejection + algorithm/base64 validation).
25 + - Limitations : couverture **draft-cavage-12** uniquement (Mastodon-
26 + compat) ; pas de RFC 9421 final ; pas de support `hs2019` ;
27 + intégration dans le `SignatureVerifier` trait (qui reçoit
28 + seulement signature_header + body) reste à étendre dans un follow-up
29 + qui veut accepter une struct `InboundRequest` complète. La
30 + fonction `parse_and_verify_cavage` est déjà la cible.
31 +- **RFC 0014** — Fediverse: 6 routes HTTP des invitations cross-instance
32 + (slice 3/N des routes). Couverture HTTP complète de RFC 0009 :
33 + - Côté `b.tld` (admin) :
34 + - `POST /api/v1/teams/{team_id}/members/invite-fediverse` (201).
35 + - `GET /api/v1/teams/{team_id}/invites` (200).
36 + - `POST /api/v1/teams/{team_id}/invites/{invite_id}/revoke` (200).
37 + - Côté `a.tld` (IdP) :
38 + - `GET /api/v1/fediverse/invites?handle=…` (200).
39 + - `POST /api/v1/fediverse/invites/{invite_id}/accept` (200).
40 + - `POST /api/v1/fediverse/invites/{invite_id}/reject` (200).
41 + - Nouveau module public gated `experimental-fediverse` :
42 + `rustwarden_core::fediverse::invite_routes::{router, InviteRoutesState, InviteBody, InviteResponse, InviteRow, IdpListQuery}`.
43 + - Nouvelle méthode `FediverseInviteService::list_pending_for_handle`
44 + (utilisée par le handler IdP-side ; retourne les invitations
45 + pending pour un `@user@domain` donné).
46 + - 12 tests unit `axum::Router` + `tower::ServiceExt::oneshot`
47 + (5 inviter-side + 5 IdP-side + 2 default-TTL/empty-list).
48 + - Limitations : pas de UI HTML, pas de pagination, et le mode IdP
49 + suppose actuellement une DB partagée (`debug_local_federation`) —
50 + un follow-up RFC ajoutera le mirror IdP-side via inbox dispatch.
51 +- **RFC 0013** — Fediverse: 5 routes HTTP du flow d'auth AP-native
52 + (slice 2/N des routes). Couverture HTTP complète de RFC 0006 :
53 + - `POST /api/v1/auth/login/fediverse/initiate` (201, body `InitiateBody`,
54 + réponse `InitiateResponse`).
55 + - `GET /api/v1/auth/login/fediverse/status/{challenge_id}` (200,
56 + réponse `StatusResponse`).
57 + - `POST /api/v1/auth/login/fediverse/consume/{challenge_id}` (200,
58 + réponse `ConsumeResponse` — JWT `IdentityKind::Remote`).
59 + - `POST /fediverse/auth/inbox` (202, vérifié par `SignatureVerifier`).
60 + - `POST /api/v1/fediverse/auth/consent` (200, body `ConsentBody`).
61 + - Nouveau module public gated `experimental-fediverse` :
62 + `rustwarden_core::fediverse::auth_routes::{router, AuthFlowState, InitiateBody, InitiateResponse, StatusResponse, ConsumeResponse, ConsentBody}`.
63 + - Nouvelle fonction `routes::router_with_auth(...)` permettant de
64 + plugger un `AuthConfig` explicite quand les env vars ne sont pas
65 + disponibles au moment du build.
66 + - 10 tests unit `axum::Router` + `tower::ServiceExt::oneshot`.
67 + - Limitations : pas de UI HTML / SSE polling, pas de rate-limit
68 + spécifique, pas de CSRF middleware (les apps consommatrices le
69 + branchent à leur niveau).
70 +- **RFC 0012** — Fediverse: route HTTP `POST /inbox` (slice 1/N des
71 + routes). Première route HTTP qui rend la couche service livrée par
72 + RFC 0010 réellement utilisable end-to-end.
73 + - Nouveau module public gated par `experimental-fediverse` :
74 + `rustwarden_core::fediverse::inbox` exposant `handle` et
75 + `InboxState`.
76 + - Dispatch table : `Update` → `apply_update_actor`, `Delete` →
77 + `apply_delete_actor`, `Revoke` → `apply_revoke_membership`.
78 + - Réponses : `202 Accepted`, `400 Bad Request`, `401 Unauthorized`
79 + (signature manquante / verifier rejette), `501 Not Implemented`
80 + (activité inconnue).
81 + - Nouvelle fonction `routes::router_with(config, resolver, verifier)`
82 + permettant de plugger une `Arc<dyn SignatureVerifier>` custom.
83 + L'ancienne `routes::router` continue de monter
84 + `AlwaysAcceptVerifier` par défaut ;
85 + `routes::router_with_resolver` est marquée `#[deprecated]`.
86 + - Nouvelles variantes `FediverseError::{Unauthorized, BadRequest, Unsupported}`.
87 + - 8 tests unit `axum::Router` + `tower::ServiceExt::oneshot`
88 + (3 success-paths + 5 error-paths).
89 + - Limitations : impl HTTP-Sig RFC 9421 réelle restera reportée à
90 + une RFC crypto dédiée ; les routes auth-flow et team-invite
91 + arrivent dans `0013` / `0014`.
92 +- **RFC 0010** — Fediverse: signaux lifecycle + retry/backoff outbound.
93 + Pièce maîtresse de la Phase 4.
94 + - Nouveau service public gated par `experimental-fediverse` :
95 + `rustwarden_core::services::fediverse_signal_service::{FediverseSignalService, UpdatedActor, DeleteOutcome, SignatureVerifier, AlwaysAcceptVerifier}`.
96 + - `apply_update_actor` upsert idempotent dans `remote_identities`.
97 + - `apply_delete_actor` flip `disabled_at = NOW()` (compteur
98 + `jwts_blacklisted = 0` en attendant une méthode dédiée
99 + `JwtBlacklistService::blacklist_by_remote_identity_id`).
100 + - `apply_revoke_membership` drop ciblé du `team_members` row distant.
101 + - Trait pluggable `SignatureVerifier` + impl test-only
102 + `AlwaysAcceptVerifier`. L'implémentation HTTP-Sig RFC 9421 réelle
103 + sera livrée par un follow-up RFC crypto dédié.
104 + - Retry/backoff ladder pour `FediverseOutboundService::drain_one` :
105 + sur échec, la row reste `pending` avec `next_retry_at` calculé
106 + selon le ladder (5min → 30min → 2h → 6h → 24h → 48h). Transition
107 + terminale `pending → dead` quand `attempts >= max_attempts`.
108 + Nouvelle méthode `OutboundStatus::next_retry_offset(attempts)`.
109 + - 10 tests unit signal (1 pure + 9 DB-mocked) + 2 nouveaux tests
110 + outbound (`drain_one_marks_failed_pending_with_next_retry_at_when_below_max_attempts`,
111 + `drain_one_marks_dead_when_attempts_reach_max`,
112 + `next_retry_offset_grows_then_caps`) en remplacement de l'ancien
113 + `drain_one_marks_failed_on_error` (la sémantique de Failed a
114 + évolué vers transitoire).
115 + - Limitations : routes HTTP `/inbox` + UI HTML "apps connectées" /
116 + sessions remote admin reportées à un follow-up RFC routes ;
117 + `JwtBlacklistService` ne ferme pas encore les sessions associées
118 + à une identité remote supprimée.
119 +- **RFC 0009** — Fediverse: équipes et partages cross-instance + invitations.
120 + - Nouvelle migration SeaORM `m20260510_000015_fediverse_pending_invites`
121 + (table + 2 indexes dont le UNIQUE partiel `team_members_remote_unique`
122 + qui interdit les doublons remote pour la même paire `(team, identity)`).
123 + - Nouveaux items publics gated par `experimental-fediverse` :
124 + `rustwarden_core::services::fediverse_invite_service::{FediverseInviteService, InviteStatus, build_invite_payload, build_accept_invite_payload, build_reject_invite_payload, build_revoke_membership_payload}`,
125 + `rustwarden_core::models::fediverse_pending_invite::Model`,
126 + `rustwarden_core::services::team_service::TeamService::{add_member_identity, teams_of_identity}`.
127 + - 11 tests unitaires (3 pure + 8 DB-mocked) + 2 tests d'intégration
128 + `team_service` (gated `experimental-fediverse`, vrai DB).
129 + - Limitation : routes HTTP admin/IdP (5 endpoints) reportées à un
130 + follow-up RFC ; `resource_service::share_with_remote_identity`
131 + également différé (la table support existe).
132 +- **RFC 0008** — Fediverse: abstraction `Identity` + colonnes sibling FK.
133 + Fondation de la Phase 3 : enum public `Identity { Local(Uuid),
134 + Remote(Uuid) }` + trait `IdentityResolver`, plus colonnes nullables
135 + `remote_identity_id` ajoutées à `team_members`,
136 + `resource_shares.shared_with_remote_identity_id`, et
137 + `audit_logs.{actor,target}_remote_identity_id`.
138 + - Nouvelle migration SeaORM `m20260510_000014_identity_sibling_fks`
139 + (4 colonnes nullable + 2 indexes partiels).
140 + - Nouveau module public gated par `experimental-fediverse` :
141 + `rustwarden_core::identity::{Identity, IdentityResolver}`.
142 + - Helpers `Identity::is_local()`, `is_remote()`, `id()`,
143 + `as_local()`, `as_remote()`, `from_claims(&Claims)` (préserve
144 + le guard confused-deputy de RFC 0007).
145 + - Sérialisation JSON snake_case `{"kind":"local","id":"<uuid>"}`.
146 + - 8 tests unitaires (helpers + serde round-trip + conversion claims).
147 + - Limitations : aucune modification des services existants
148 + (`team_service`, `resource_service`, `audit_service`) ni de la
149 + PK composite `team_members(team_id, user_id)` — RFC 0009 consomme
150 + ces colonnes via un index unique partiel sans relax de la PK.
151 +- **RFC 0006** — Fediverse: flow d'authentification AP-native (couche
152 + service). State machine `Pending → Approved | Rejected → Consumed`
153 + avec one-shot guard et nonce cryptographique URL-safe base64 256 bits.
154 + Les routes HTTP `/api/v1/auth/login/fediverse/*` + UI consent sont
155 + volontairement reportées à un follow-up — la couche service livrée
156 + ici est suffisante pour brancher des handlers consommateurs.
157 + - Nouvelle migration SeaORM `m20260510_000013_fediverse_auth_challenges`
158 + (table + index partiel `pending`).
159 + - Nouveaux items publics gated par `experimental-fediverse` :
160 + `rustwarden_core::services::fediverse_auth_service::{FediverseAuthService, ChallengeStatus, InitiateRequest, InitiatedChallenge, ChallengeDecision, ConsumedSession, build_auth_request_payload}`,
161 + `rustwarden_core::models::fediverse_auth_challenge::Model`.
162 + - 12 tests unitaires (3 pure + 9 DB-mocked).
163 +- **RFC 0007** — Fediverse: extension du JWT `Claims` pour distinguer
164 + les sujets locaux (`users.id`) des sujets distants
165 + (`remote_identities.id`).
166 + - Nouvel enum public `IdentityKind` (`Local` / `Remote`),
167 + sérialisé snake_case sous la clé `kind`. `#[serde(default)]`
168 + garantit la rétrocompatibilité avec les JWT pré-RFC 0007.
169 + - Nouveau champ `Claims.remote_id: Option<Uuid>` (omis du JSON
170 + quand `None`).
171 + - Helpers `Claims::is_remote()`, `is_local()`, `remote_identity_id()`
172 + (guard confused-deputy : retourne `None` si `kind = Local` même
173 + si un `remote_id` parasite est présent).
174 + - Nouvelle méthode `JwtService::generate_remote_token(config, remote_id, handle)`
175 + (sub = remote_id, kind = Remote, roles/permissions vides).
176 + - 8 tests unitaires (helpers, serde round-trip, legacy compat).
177 +- **RFC 0011** — Fediverse: file outbound + worker (slice Phase 2
178 + minimal). Persistance des activités sortantes + worker polling +
179 + trait pluggable `OutboundDelivery` (impl `NoopDelivery` ; la
180 + signature HTTP réelle vient avec RFC 0010).
181 + - Nouvelle migration SeaORM `m20260510_000012_fediverse_outbound_queue`.
182 + - Nouveaux items publics gated par `experimental-fediverse` :
183 + `rustwarden_core::services::fediverse_outbound_service::{FediverseOutboundService, OutboundStatus, EnqueueRequest, OutboundDelivery, NoopDelivery, OutboundWorker}`,
184 + `rustwarden_core::models::fediverse_outbound_queue::Model`.
185 + - 10 tests unitaires (2 pure + 8 DB-mocked + worker smoke test).
186 + - Limitation : transitions retry/backoff + transport HTTP-Sig réel
187 + déléguées à RFC 0010.
188 +- **RFC 0005** — Fediverse: stockage des clés d'acteur RSA chiffrées.
189 + Génération paresseuse d'une paire RSA-2048 (PKCS#8 / SPKI) lors de la
190 + première résolution AP du user, persistance avec clé privée chiffrée
191 + via `CryptoService` (AES-256-GCM), et exposition automatique de la
192 + clé publique dans `PersonJson::public_key`.
193 + - Nouvelle migration SeaORM `m20260510_000011_extend_users_with_actor_keys` :
194 + ajoute `users.ap_public_key_pem TEXT NULL`,
195 + `users.ap_private_key_pem TEXT NULL` (chiffré at-rest),
196 + `users.ap_discoverable BOOLEAN NOT NULL DEFAULT TRUE`.
197 + - Nouveaux items publics gated par `experimental-fediverse` :
198 + `rustwarden_core::services::actor_key_service::{ActorKeyService, ActorKeyMaterial}`.
199 + - Wiring : `SeaOrmLocalUserResolver::find_local_user` appelle
200 + `ActorKeyService::ensure_keys` lazy quand `CryptoService::is_available()`,
201 + sinon retourne un acteur sans clé (mieux qu'une 500). Une erreur de
202 + génération est tracée mais ne casse pas la résolution.
203 + - 13 tests unitaires (4 pure RSA + 8 DB-mocked + 1 DB-mocked NotFound).
204 + - Limitations : pas de rotation automatique de clé, pas de backfill
205 + de masse au démarrage (commande CLI dédiée prévue dans RFC 0011),
206 + pas de support Ed25519. Les routes signature HTTP sortantes/entrantes
207 + qui consomment ces clés viendront avec RFC 0010 / 0011.
208 + - Nouvelle dépendance : `rsa = { version = "0.9", features = ["pem", "getrandom"], optional = true }`,
209 + tirée uniquement avec `experimental-fediverse`.
210 +- **RFC 0004** — Fediverse: modèle de confiance (allowlist / blocklist + scope
211 + d'interop). Permet à l'admin de choisir entre `allowlist` (zero-trust par
212 + défaut) et `open_with_blocklist` (modèle Mastodon), et expose le check
213 + central `is_domain_allowed(domain)` que les RFCs suivantes appliqueront
214 + avant toute opération avec un domaine distant.
215 + - Nouvelle migration SeaORM `m20260510_000010_fediverse_trust_and_blocks` :
216 + tables `fediverse_trusted_domains` et `fediverse_blocked_domains`
217 + (PK uuid, `domain` UNIQUE, `severity` pour blocklist, audit trail
218 + `added_by_user_id`), plus 6 entrées `app_settings` :
219 + `fediverse_enabled` (`false`), `fediverse_trust_mode` (`allowlist`),
220 + `fediverse_interop_scope` (`rustwarden_only`),
221 + `fediverse_authorized_fetch` (`false`), `fediverse_node_name`
222 + (`Rustwarden`), `fediverse_node_description` (vide).
223 + - Nouveaux items publics gated par `experimental-fediverse` :
224 + `rustwarden_core::models::fediverse_trusted_domain::Model`,
225 + `rustwarden_core::models::fediverse_blocked_domain::Model`,
226 + `rustwarden_core::services::fediverse_trust_service::{FediverseTrustService, TrustMode, InteropScope, BlockSeverity}`.
227 + - Normalisation domaine systématique (lowercase + strip `www.`) avant
228 + comparaison.
229 + - 12 tests unitaires (6 pure logic + 6 DB-mocked via `sea-orm/mock`).
230 +- **RFC 0003** — Fediverse: stockage des identités distantes + résolution
231 + `@user@domain`. Cache local persistant des acteurs ActivityPub résolus,
232 + avec TTL configurable.
233 + - Nouvelle migration SeaORM `m20260510_000009_remote_identities` :
234 + table `remote_identities` (PK uuid, `ap_id` UNIQUE, contrainte UNIQUE
235 + sur `(username, domain)`, `disabled_at` pour soft-delete, `raw_json`
236 + pour audit / re-processing).
237 + - Nouveaux items publics gated par `experimental-fediverse` :
238 + `rustwarden_core::models::remote_identity::Model`,
239 + `rustwarden_core::services::remote_identity_service::RemoteIdentityService`,
240 + `rustwarden_core::fediverse::{RemoteFetcher, UnconfiguredFetcher}`.
241 + - 11 tests unitaires (5 pure logic + 6 DB-mocked via `sea-orm/mock`).
242 + - Limitations : la livraison `RemoteFetcher` réelle (HTTP signé) arrive
243 + avec RFC 0010. Pour l'instant `UnconfiguredFetcher` sert de placeholder
244 + et retourne `AppError::Config` à toute tentative d'appel.
245 +
246 +### Tooling
247 +
248 +- Ajout de la feature `mock` de SeaORM en `[dev-dependencies]` pour
249 + permettre les tests unitaires sans connexion DB réelle.
250 +
251 +- **RFC 0002** — Fediverse: WebFinger + Acteur + NodeInfo (couche découverte).
252 + Câbles automatiquement par `with_fediverse()` quand la feature
253 + `experimental-fediverse` est activée et que la méthode est appelée.
254 + - Nouvelles routes HTTP :
255 + - `GET /.well-known/webfinger?resource=acct:user@domain` (CORS `*`,
256 + content-type `application/jrd+json`)
257 + - `GET /.well-known/nodeinfo`
258 + - `GET /nodeinfo/2.1`
259 + - `GET /users/{username}` (négociation `Accept` : AP JSON ou 406 si
260 + seulement `text/html` et UI absente)
261 + - Nouveaux items publics gated par `experimental-fediverse` :
262 + - Module `rustwarden_core::fediverse` (sous-modules `webfinger`,
263 + `actor`, `nodeinfo`, `routes`, `state`, `types`, `resolver`, `error`).
264 + - Trait `LocalUserResolver` avec impl par défaut `SeaOrmLocalUserResolver`.
265 + - Types JSON sérialisés : `PersonJson`, `PublicKeyJson`, `EndpointsJson`,
266 + `WebFingerResponse`, `WebFingerLink`, `NodeInfoDiscovery`, `NodeInfo`,
267 + `NodeInfoSoftware`, `NodeInfoServices`, `NodeInfoUsage`,
268 + `NodeInfoUsageUsers`, `NodeInfoLink`.
269 + - Erreur `FediverseError` (404 / 400 / 406, conversion `From<AppError>`).
270 + - Helper `actor_context()` pour `@context` Mastodon-compat.
271 + - 15 tests unitaires (5 webfinger, 6 actor, 3 nodeinfo, 1 helper).
272 + - Limitation connue : `PersonJson::public_key` est `None` tant que la
273 + RFC 0005 (`actor-key-storage`) n'est pas livrée — l'acteur est
274 + discoverable mais Mastodon refusera de fédérer (signatures HTTP requises).
275 +- **RFC 0001** — Fediverse: `FediverseConfig` + `with_fediverse()` builder.
276 + Nouvelle Cargo feature optionnelle `experimental-fediverse` sur `rustwarden-core`
277 + et `rustwarden-ui`, off par défaut. Charge la configuration de fédération
278 + ActivityPub depuis l'environnement et la stocke sur le builder. Les routes
279 + HTTP, le middleware de fédération et le worker outbound arrivent dans les
280 + RFCs 0002+.
281 + - Nouvelles dépendances optionnelles (gated `experimental-fediverse`) :
282 + `activitypub_federation = "=0.7.0-beta.11"` (features `["axum"]`, no default),
283 + `ammonia = "4"`, `url = "2"` (avec `serde`).
284 + - Nouveaux items publics gated par `experimental-fediverse` :
285 + `rustwarden_core::config::FediverseConfig`,
286 + `rustwarden_core::config::FediverseConfigError`,
287 + `RustwardenBuilder::with_fediverse()`.
288 +
289 +### Changed
290 +
291 +- 4 occurrences de `std::env::set_var` enveloppées dans `unsafe { ... }` avec
292 + commentaire `// SAFETY:` (1 prod dans `app.rs`, 3 tests dans `crypto_service.rs`
293 + et `totp_service.rs`). Requis par edition 2024 — comportement runtime
294 + inchangé.
295 +- 9 lints clippy résolus (7× `explicit_auto_deref` sur `&*auth_config` dans
296 + les middlewares, 2× `let_and_return` dans `error_handler.rs` et
297 + `services/oauth/service.rs`).
298 +- `oauth_callback` reçoit `#[allow(clippy::too_many_arguments)]` local avec
299 + commentaire — handler Axum à 8 extractors, refacto sans valeur.
300 +
301 +### Migration steps
302 +
303 +Pour les apps consommatrices qui **ne veulent pas** la fédération : aucune
304 +action requise. La feature est off par défaut et n'a aucun coût (compile-time
305 +ou runtime).
306 +
307 +Pour activer la fédération :
308 +
309 +1. Dans `Cargo.toml` :
310 + ```toml
311 + rustwarden-core = { version = "X.Y.Z", features = ["experimental-fediverse"] }
312 + rustwarden-ui = { version = "X.Y.Z", features = ["experimental-fediverse"] }
313 + ```
314 +2. Dans `.env`, au minimum :
315 + ```
316 + FEDIVERSE_DOMAIN=your.tld
317 + ```
318 +3. Dans `main.rs` :
319 + ```rust
320 + RustwardenApp::builder()
321 + .from_env()
322 + .with_fediverse()?
323 + .build().await?
324 + .run().await
325 + ```
326 +
327 +À ce stade aucune route HTTP n'est exposée — ces dernières arrivent avec la
328 +RFC 0002 (`webfinger-actor-nodeinfo`).
M Cargo.lock
+499 -2
@@ -3,6 +3,56 @@
3 3 version = 4
4 4
5 5 [[package]]
6 +name = "activitypub_federation"
7 +version = "0.7.0-beta.11"
8 +source = "registry+https://github.com/rust-lang/crates.io-index"
9 +checksum = "20222f29f14358d3baeb0ffdec08f99bd0f56b6b59504f33556d97db720de748"
10 +dependencies = [
11 + "activitystreams-kinds",
12 + "async-trait",
13 + "axum",
14 + "base64 0.22.1",
15 + "bytes",
16 + "chrono",
17 + "derive_builder",
18 + "dyn-clone",
19 + "either",
20 + "enum_delegate",
21 + "futures",
22 + "futures-core",
23 + "http",
24 + "http-signature-normalization",
25 + "http-signature-normalization-reqwest",
26 + "httpdate",
27 + "itertools 0.14.0",
28 + "moka",
29 + "pin-project-lite",
30 + "rand 0.8.5",
31 + "regex",
32 + "reqwest 0.13.3",
33 + "reqwest-middleware",
34 + "rsa",
35 + "serde",
36 + "serde_json",
37 + "sha2",
38 + "thiserror 2.0.18",
39 + "tokio",
40 + "tower",
41 + "tracing",
42 + "url",
43 +]
44 +
45 +[[package]]
46 +name = "activitystreams-kinds"
47 +version = "0.3.0"
48 +source = "registry+https://github.com/rust-lang/crates.io-index"
49 +checksum = "e97dfe76efd8c0b113cc3580a6b5f4acba47662e3cfbbfcce081c9ac89798990"
50 +dependencies = [
51 + "serde",
52 + "url",
53 +]
54 +
55 +[[package]]
6 56 name = "adler2"
7 57 version = "2.0.1"
8 58 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -90,6 +140,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
90 140 checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
91 141
92 142 [[package]]
143 +name = "ammonia"
144 +version = "4.1.2"
145 +source = "registry+https://github.com/rust-lang/crates.io-index"
146 +checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
147 +dependencies = [
148 + "cssparser",
149 + "html5ever",
150 + "maplit",
151 + "tendril",
152 + "url",
153 +]
154 +
155 +[[package]]
93 156 name = "android_system_properties"
94 157 version = "0.1.5"
95 158 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -402,6 +465,17 @@ dependencies = [
402 465 ]
403 466
404 467 [[package]]
468 +name = "async-lock"
469 +version = "3.4.2"
470 +source = "registry+https://github.com/rust-lang/crates.io-index"
471 +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
472 +dependencies = [
473 + "event-listener",
474 + "event-listener-strategy",
475 + "pin-project-lite",
476 +]
477 +
478 +[[package]]
405 479 name = "async-stream"
406 480 version = "0.3.6"
407 481 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -989,6 +1063,24 @@ dependencies = [
989 1063 ]
990 1064
991 1065 [[package]]
1066 +name = "crossbeam-channel"
1067 +version = "0.5.15"
1068 +source = "registry+https://github.com/rust-lang/crates.io-index"
1069 +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
1070 +dependencies = [
1071 + "crossbeam-utils",
1072 +]
1073 +
1074 +[[package]]
1075 +name = "crossbeam-epoch"
1076 +version = "0.9.18"
1077 +source = "registry+https://github.com/rust-lang/crates.io-index"
1078 +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
1079 +dependencies = [
1080 + "crossbeam-utils",
1081 +]
1082 +
1083 +[[package]]
992 1084 name = "crossbeam-queue"
993 1085 version = "0.3.12"
994 1086 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1033,6 +1125,29 @@ dependencies = [
1033 1125 ]
1034 1126
1035 1127 [[package]]
1128 +name = "cssparser"
1129 +version = "0.35.0"
1130 +source = "registry+https://github.com/rust-lang/crates.io-index"
1131 +checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
1132 +dependencies = [
1133 + "cssparser-macros",
1134 + "dtoa-short",
1135 + "itoa",
1136 + "phf",
1137 + "smallvec",
1138 +]
1139 +
1140 +[[package]]
1141 +name = "cssparser-macros"
1142 +version = "0.6.1"
1143 +source = "registry+https://github.com/rust-lang/crates.io-index"
1144 +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
1145 +dependencies = [
1146 + "quote",
1147 + "syn 2.0.117",
1148 +]
1149 +
1150 +[[package]]
1036 1151 name = "ctr"
1037 1152 version = "0.9.2"
1038 1153 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1160,6 +1275,37 @@ dependencies = [
1160 1275 ]
1161 1276
1162 1277 [[package]]
1278 +name = "derive_builder"
1279 +version = "0.20.2"
1280 +source = "registry+https://github.com/rust-lang/crates.io-index"
1281 +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
1282 +dependencies = [
1283 + "derive_builder_macro",
1284 +]
1285 +
1286 +[[package]]
1287 +name = "derive_builder_core"
1288 +version = "0.20.2"
1289 +source = "registry+https://github.com/rust-lang/crates.io-index"
1290 +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
1291 +dependencies = [
1292 + "darling 0.20.11",
1293 + "proc-macro2",
1294 + "quote",
1295 + "syn 2.0.117",
1296 +]
1297 +
1298 +[[package]]
1299 +name = "derive_builder_macro"
1300 +version = "0.20.2"
1301 +source = "registry+https://github.com/rust-lang/crates.io-index"
1302 +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
1303 +dependencies = [
1304 + "derive_builder_core",
1305 + "syn 2.0.117",
1306 +]
1307 +
1308 +[[package]]
1163 1309 name = "derive_more"
1164 1310 version = "2.1.1"
1165 1311 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1225,6 +1371,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1225 1371 checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
1226 1372
1227 1373 [[package]]
1374 +name = "dtoa"
1375 +version = "1.0.11"
1376 +source = "registry+https://github.com/rust-lang/crates.io-index"
1377 +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
1378 +
1379 +[[package]]
1380 +name = "dtoa-short"
1381 +version = "0.3.5"
1382 +source = "registry+https://github.com/rust-lang/crates.io-index"
1383 +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
1384 +dependencies = [
1385 + "dtoa",
1386 +]
1387 +
1388 +[[package]]
1228 1389 name = "dyn-clone"
1229 1390 version = "1.0.20"
1230 1391 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1330,6 +1491,30 @@ dependencies = [
1330 1491 ]
1331 1492
1332 1493 [[package]]
1494 +name = "enum_delegate"
1495 +version = "0.2.0"
1496 +source = "registry+https://github.com/rust-lang/crates.io-index"
1497 +checksum = "a8ea75f31022cba043afe037940d73684327e915f88f62478e778c3de914cd0a"
1498 +dependencies = [
1499 + "enum_delegate_lib",
1500 + "proc-macro2",
1501 + "quote",
1502 + "syn 1.0.109",
1503 +]
1504 +
1505 +[[package]]
1506 +name = "enum_delegate_lib"
1507 +version = "0.2.0"
1508 +source = "registry+https://github.com/rust-lang/crates.io-index"
1509 +checksum = "2e1f6c3800b304a6be0012039e2a45a322a093539c45ab818d9e6895a39c90fe"
1510 +dependencies = [
1511 + "proc-macro2",
1512 + "quote",
1513 + "rand 0.8.5",
1514 + "syn 1.0.109",
1515 +]
1516 +
1517 +[[package]]
1333 1518 name = "equivalent"
1334 1519 version = "1.0.2"
1335 1520 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1368,6 +1553,16 @@ dependencies = [
1368 1553 ]
1369 1554
1370 1555 [[package]]
1556 +name = "event-listener-strategy"
1557 +version = "0.5.4"
1558 +source = "registry+https://github.com/rust-lang/crates.io-index"
1559 +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
1560 +dependencies = [
1561 + "event-listener",
1562 + "pin-project-lite",
1563 +]
1564 +
1565 +[[package]]
1371 1566 name = "fastrand"
1372 1567 version = "2.3.0"
1373 1568 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1521,6 +1716,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1521 1716 checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
1522 1717
1523 1718 [[package]]
1719 +name = "futf"
1720 +version = "0.1.5"
1721 +source = "registry+https://github.com/rust-lang/crates.io-index"
1722 +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
1723 +dependencies = [
1724 + "mac",
1725 + "new_debug_unreachable",
1726 +]
1727 +
1728 +[[package]]
1524 1729 name = "futures"
1525 1730 version = "0.3.32"
1526 1731 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1851,6 +2056,17 @@ dependencies = [
1851 2056 ]
1852 2057
1853 2058 [[package]]
2059 +name = "html5ever"
2060 +version = "0.35.0"
2061 +source = "registry+https://github.com/rust-lang/crates.io-index"
2062 +checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
2063 +dependencies = [
2064 + "log",
2065 + "markup5ever",
2066 + "match_token",
2067 +]
2068 +
2069 +[[package]]
1854 2070 name = "http"
1855 2071 version = "1.4.0"
1856 2072 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1890,6 +2106,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1890 2106 checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
1891 2107
1892 2108 [[package]]
2109 +name = "http-signature-normalization"
2110 +version = "0.7.0"
2111 +source = "registry+https://github.com/rust-lang/crates.io-index"
2112 +checksum = "b95e3149194de5f3f9d5225bcc6a8677979f8ff8ce39c85654730ad4824f101e"
2113 +dependencies = [
2114 + "httpdate",
2115 +]
2116 +
2117 +[[package]]
2118 +name = "http-signature-normalization-reqwest"
2119 +version = "0.14.1"
2120 +source = "registry+https://github.com/rust-lang/crates.io-index"
2121 +checksum = "7b2441a67ea8984d46c95099b4a9be83dc5bed2c254b8443dc2554edaeaa7d0b"
2122 +dependencies = [
2123 + "async-trait",
2124 + "base64 0.22.1",
2125 + "http-signature-normalization",
2126 + "httpdate",
2127 + "reqwest 0.13.3",
2128 + "reqwest-middleware",
2129 + "sha2",
2130 + "tokio",
2131 +]
2132 +
2133 +[[package]]
1893 2134 name = "httparse"
1894 2135 version = "1.10.1"
1895 2136 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2472,6 +2713,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
2472 2713 checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
2473 2714
2474 2715 [[package]]
2716 +name = "mac"
2717 +version = "0.1.1"
2718 +source = "registry+https://github.com/rust-lang/crates.io-index"
2719 +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
2720 +
2721 +[[package]]
2475 2722 name = "mac_address"
2476 2723 version = "1.1.8"
2477 2724 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2483,6 +2730,34 @@ dependencies = [
2483 2730 ]
2484 2731
2485 2732 [[package]]
2733 +name = "maplit"
2734 +version = "1.0.2"
2735 +source = "registry+https://github.com/rust-lang/crates.io-index"
2736 +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
2737 +
2738 +[[package]]
2739 +name = "markup5ever"
2740 +version = "0.35.0"
2741 +source = "registry+https://github.com/rust-lang/crates.io-index"
2742 +checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
2743 +dependencies = [
2744 + "log",
2745 + "tendril",
2746 + "web_atoms",
2747 +]
2748 +
2749 +[[package]]
2750 +name = "match_token"
2751 +version = "0.35.0"
2752 +source = "registry+https://github.com/rust-lang/crates.io-index"
2753 +checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
2754 +dependencies = [
2755 + "proc-macro2",
2756 + "quote",
2757 + "syn 2.0.117",
2758 +]
2759 +
2760 +[[package]]
2486 2761 name = "matchers"
2487 2762 version = "0.2.0"
2488 2763 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2566,6 +2841,26 @@ dependencies = [
2566 2841 ]
2567 2842
2568 2843 [[package]]
2844 +name = "moka"
2845 +version = "0.12.15"
2846 +source = "registry+https://github.com/rust-lang/crates.io-index"
2847 +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046"
2848 +dependencies = [
2849 + "async-lock",
2850 + "crossbeam-channel",
2851 + "crossbeam-epoch",
2852 + "crossbeam-utils",
2853 + "equivalent",
2854 + "event-listener",
2855 + "futures-util",
2856 + "parking_lot",
2857 + "portable-atomic",
2858 + "smallvec",
2859 + "tagptr",
2860 + "uuid",
2861 +]
2862 +
2863 +[[package]]
2569 2864 name = "moxcms"
2570 2865 version = "0.8.1"
2571 2866 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2593,6 +2888,12 @@ dependencies = [
2593 2888 ]
2594 2889
2595 2890 [[package]]
2891 +name = "new_debug_unreachable"
2892 +version = "1.0.6"
2893 +source = "registry+https://github.com/rust-lang/crates.io-index"
2894 +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
2895 +
2896 +[[package]]
2596 2897 name = "nix"
2597 2898 version = "0.29.0"
2598 2899 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2736,7 +3037,7 @@ dependencies = [
2736 3037 "getrandom 0.2.17",
2737 3038 "http",
2738 3039 "rand 0.8.5",
2739 - "reqwest",
3040 + "reqwest 0.12.28",
2740 3041 "serde",
2741 3042 "serde_json",
2742 3043 "serde_path_to_error",
@@ -2977,6 +3278,58 @@ dependencies = [
2977 3278 ]
2978 3279
2979 3280 [[package]]
3281 +name = "phf"
3282 +version = "0.11.3"
3283 +source = "registry+https://github.com/rust-lang/crates.io-index"
3284 +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
3285 +dependencies = [
3286 + "phf_macros",
3287 + "phf_shared",
3288 +]
3289 +
3290 +[[package]]
3291 +name = "phf_codegen"
3292 +version = "0.11.3"
3293 +source = "registry+https://github.com/rust-lang/crates.io-index"
3294 +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
3295 +dependencies = [
3296 + "phf_generator",
3297 + "phf_shared",
3298 +]
3299 +
3300 +[[package]]
3301 +name = "phf_generator"
3302 +version = "0.11.3"
3303 +source = "registry+https://github.com/rust-lang/crates.io-index"
3304 +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
3305 +dependencies = [
3306 + "phf_shared",
3307 + "rand 0.8.5",
3308 +]
3309 +
3310 +[[package]]
3311 +name = "phf_macros"
3312 +version = "0.11.3"
3313 +source = "registry+https://github.com/rust-lang/crates.io-index"
3314 +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
3315 +dependencies = [
3316 + "phf_generator",
3317 + "phf_shared",
3318 + "proc-macro2",
3319 + "quote",
3320 + "syn 2.0.117",
3321 +]
3322 +
3323 +[[package]]
3324 +name = "phf_shared"
3325 +version = "0.11.3"
3326 +source = "registry+https://github.com/rust-lang/crates.io-index"
3327 +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
3328 +dependencies = [
3329 + "siphasher",
3330 +]
3331 +
3332 +[[package]]
2980 3333 name = "pin-project-lite"
2981 3334 version = "0.2.17"
2982 3335 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3057,6 +3410,12 @@ dependencies = [
3057 3410 ]
3058 3411
3059 3412 [[package]]
3413 +name = "portable-atomic"
3414 +version = "1.13.1"
3415 +source = "registry+https://github.com/rust-lang/crates.io-index"
3416 +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
3417 +
3418 +[[package]]
3060 3419 name = "potential_utf"
3061 3420 version = "0.1.4"
3062 3421 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3081,6 +3440,12 @@ dependencies = [
3081 3440 ]
3082 3441
3083 3442 [[package]]
3443 +name = "precomputed-hash"
3444 +version = "0.1.1"
3445 +source = "registry+https://github.com/rust-lang/crates.io-index"
3446 +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
3447 +
3448 +[[package]]
3084 3449 name = "prettyplease"
3085 3450 version = "0.2.37"
3086 3451 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3487,6 +3852,54 @@ dependencies = [
3487 3852 ]
3488 3853
3489 3854 [[package]]
3855 +name = "reqwest"
3856 +version = "0.13.3"
3857 +source = "registry+https://github.com/rust-lang/crates.io-index"
3858 +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
3859 +dependencies = [
3860 + "base64 0.22.1",
3861 + "bytes",
3862 + "futures-core",
3863 + "futures-util",
3864 + "http",
3865 + "http-body",
3866 + "http-body-util",
3867 + "hyper",
3868 + "hyper-util",
3869 + "js-sys",
3870 + "log",
3871 + "percent-encoding",
3872 + "pin-project-lite",
3873 + "serde",
3874 + "serde_json",
3875 + "sync_wrapper",
3876 + "tokio",
3877 + "tokio-util",
3878 + "tower",
3879 + "tower-http",
3880 + "tower-service",
3881 + "url",
3882 + "wasm-bindgen",
3883 + "wasm-bindgen-futures",
3884 + "wasm-streams",
3885 + "web-sys",
3886 +]
3887 +
3888 +[[package]]
3889 +name = "reqwest-middleware"
3890 +version = "0.5.1"
3891 +source = "registry+https://github.com/rust-lang/crates.io-index"
3892 +checksum = "199dda04a536b532d0cc04d7979e39b1c763ea749bf91507017069c00b96056f"
3893 +dependencies = [
3894 + "anyhow",
3895 + "async-trait",
3896 + "http",
3897 + "reqwest 0.13.3",
3898 + "thiserror 2.0.18",
3899 + "tower-service",
3900 +]
3901 +
3902 +[[package]]
3490 3903 name = "rfc6979"
3491 3904 version = "0.4.0"
3492 3905 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3697,7 +4110,9 @@ dependencies = [
3697 4110 name = "rustwarden-core"
3698 4111 version = "0.1.0"
3699 4112 dependencies = [
4113 + "activitypub_federation",
3700 4114 "aes-gcm",
4115 + "ammonia",
3701 4116 "anyhow",
3702 4117 "askama",
3703 4118 "async-trait",
@@ -3718,7 +4133,8 @@ dependencies = [
3718 4133 "num-format",
3719 4134 "openidconnect",
3720 4135 "rand 0.9.2",
3721 - "reqwest",
4136 + "reqwest 0.12.28",
4137 + "rsa",
3722 4138 "sea-orm",
3723 4139 "sea-orm-migration",
3724 4140 "serde",
@@ -3736,6 +4152,7 @@ dependencies = [
3736 4152 "tower-sessions-redis-store",
3737 4153 "tracing",
3738 4154 "tracing-subscriber",
4155 + "url",
3739 4156 "urlencoding",
3740 4157 "uuid",
3741 4158 "validator",
@@ -4081,6 +4498,7 @@ version = "1.0.149"
4081 4498 source = "registry+https://github.com/rust-lang/crates.io-index"
4082 4499 checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
4083 4500 dependencies = [
4501 + "indexmap 2.13.0",
4084 4502 "itoa",
4085 4503 "memchr",
4086 4504 "serde",
@@ -4239,6 +4657,12 @@ dependencies = [
4239 4657 ]
4240 4658
4241 4659 [[package]]
4660 +name = "siphasher"
4661 +version = "1.0.3"
4662 +source = "registry+https://github.com/rust-lang/crates.io-index"
4663 +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
4664 +
4665 +[[package]]
4242 4666 name = "slab"
4243 4667 version = "0.4.12"
4244 4668 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4522,6 +4946,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
4522 4946 checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
4523 4947
4524 4948 [[package]]
4949 +name = "string_cache"
4950 +version = "0.8.9"
4951 +source = "registry+https://github.com/rust-lang/crates.io-index"
4952 +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
4953 +dependencies = [
4954 + "new_debug_unreachable",
4955 + "parking_lot",
4956 + "phf_shared",
4957 + "precomputed-hash",
4958 + "serde",
4959 +]
4960 +
4961 +[[package]]
4962 +name = "string_cache_codegen"
4963 +version = "0.5.4"
4964 +source = "registry+https://github.com/rust-lang/crates.io-index"
4965 +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
4966 +dependencies = [
4967 + "phf_generator",
4968 + "phf_shared",
4969 + "proc-macro2",
4970 + "quote",
4971 +]
4972 +
4973 +[[package]]
4525 4974 name = "stringprep"
4526 4975 version = "0.1.5"
4527 4976 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4614,6 +5063,12 @@ dependencies = [
4614 5063 ]
4615 5064
4616 5065 [[package]]
5066 +name = "tagptr"
5067 +version = "0.2.0"
5068 +source = "registry+https://github.com/rust-lang/crates.io-index"
5069 +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
5070 +
5071 +[[package]]
4617 5072 name = "tap"
4618 5073 version = "1.0.1"
4619 5074 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4633,6 +5088,17 @@ dependencies = [
4633 5088 ]
4634 5089
4635 5090 [[package]]
5091 +name = "tendril"
5092 +version = "0.4.3"
5093 +source = "registry+https://github.com/rust-lang/crates.io-index"
5094 +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
5095 +dependencies = [
5096 + "futf",
5097 + "mac",
5098 + "utf-8",
5099 +]
5100 +
5101 +[[package]]
4636 5102 name = "thiserror"
4637 5103 version = "1.0.69"
4638 5104 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5179,6 +5645,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
5179 5645 checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
5180 5646
5181 5647 [[package]]
5648 +name = "utf-8"
5649 +version = "0.7.6"
5650 +source = "registry+https://github.com/rust-lang/crates.io-index"
5651 +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
5652 +
5653 +[[package]]
5182 5654 name = "utf8_iter"
5183 5655 version = "1.0.4"
5184 5656 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5371,6 +5843,19 @@ dependencies = [
5371 5843 ]
5372 5844
5373 5845 [[package]]
5846 +name = "wasm-streams"
5847 +version = "0.5.0"
5848 +source = "registry+https://github.com/rust-lang/crates.io-index"
5849 +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
5850 +dependencies = [
5851 + "futures-util",
5852 + "js-sys",
5853 + "wasm-bindgen",
5854 + "wasm-bindgen-futures",
5855 + "web-sys",
5856 +]
5857 +
5858 +[[package]]
5374 5859 name = "wasmparser"
5375 5860 version = "0.244.0"
5376 5861 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5403,6 +5888,18 @@ dependencies = [
5403 5888 ]
5404 5889
5405 5890 [[package]]
5891 +name = "web_atoms"
5892 +version = "0.1.3"
5893 +source = "registry+https://github.com/rust-lang/crates.io-index"
5894 +checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
5895 +dependencies = [
5896 + "phf",
5897 + "phf_codegen",
5898 + "string_cache",
5899 + "string_cache_codegen",
5900 +]
5901 +
5902 +[[package]]
5406 5903 name = "webpki-roots"
5407 5904 version = "1.0.6"
5408 5905 source = "registry+https://github.com/rust-lang/crates.io-index"
M crates/rustwarden-core/Cargo.toml
+14 -0
@@ -69,11 +69,23 @@ urlencoding = "2"
69 69 # OAuth2 / OpenID Connect (optional)
70 70 openidconnect = { version = "4", optional = true }
71 71 reqwest = { version = "0.12", features = ["json", "native-tls"], optional = true }
72 +# Fediverse / ActivityPub identity federation (optional, RFC 0001+)
73 +activitypub_federation = { version = "=0.7.0-beta.11", features = ["axum"], default-features = false, optional = true }
74 +ammonia = { version = "4", optional = true }
75 +url = { version = "2", features = ["serde"], optional = true }
76 +# RFC 0005: actor-key storage (RSA-2048 PKCS#8 / SPKI). Pulled only with
77 +# `experimental-fediverse`; private keys are encrypted at rest via
78 +# `CryptoService` (AES-256-GCM) before being persisted.
79 +rsa = { version = "0.9", features = ["pem", "getrandom"], optional = true }
72 80
73 81 [features]
74 82 default = []
75 83 redis-session = ["tower-sessions-redis-store"]
76 84 oauth = ["openidconnect", "reqwest"]
85 +# RFC 0001: experimental ActivityPub identity federation. Promoted to
86 +# `fediverse` when stabilised (>= 2 adopting apps or 30 days without
87 +# critical bugs, cf. RUSTWARDEN_EVOLUTION_PROCESS.md §5.1).
88 +experimental-fediverse = ["dep:activitypub_federation", "dep:ammonia", "dep:url", "dep:rsa"]
77 89
78 90 [lints]
79 91 workspace = true
@@ -98,3 +110,5 @@ path = "src/bin/change_password.rs"
98 110 hyper = { version = "1", features = ["full"] }
99 111 hyper-util = { version = "0.1", features = ["full"] }
100 112 tower = "0.5"
113 +# Enable SeaORM mock backend for unit tests (RFC 0003+).
114 +sea-orm = { version = "2.0.0-rc.20", features = ["mock"] }
M crates/rustwarden-core/migrations/src/lib.rs
+14 -0
@@ -8,6 +8,13 @@ pub mod m20260310_000005_create_oauth_accounts_table;
8 8 pub mod m20260409_000006_create_totp_tables;
9 9 pub mod m20260422_000007_create_personal_access_tokens;
10 10 pub mod m20260423_000008_create_teams;
11 +pub mod m20260510_000009_remote_identities;
12 +pub mod m20260510_000010_fediverse_trust_and_blocks;
13 +pub mod m20260510_000011_extend_users_with_actor_keys;
14 +pub mod m20260510_000012_fediverse_outbound_queue;
15 +pub mod m20260510_000013_fediverse_auth_challenges;
16 +pub mod m20260510_000014_identity_sibling_fks;
17 +pub mod m20260510_000015_fediverse_pending_invites;
11 18
12 19 pub struct Migrator;
13 20
@@ -23,6 +30,13 @@ impl MigratorTrait for Migrator {
23 30 Box::new(m20260409_000006_create_totp_tables::Migration),
24 31 Box::new(m20260422_000007_create_personal_access_tokens::Migration),
25 32 Box::new(m20260423_000008_create_teams::Migration),
33 + Box::new(m20260510_000009_remote_identities::Migration),
34 + Box::new(m20260510_000010_fediverse_trust_and_blocks::Migration),
35 + Box::new(m20260510_000011_extend_users_with_actor_keys::Migration),
36 + Box::new(m20260510_000012_fediverse_outbound_queue::Migration),
37 + Box::new(m20260510_000013_fediverse_auth_challenges::Migration),
38 + Box::new(m20260510_000014_identity_sibling_fks::Migration),
39 + Box::new(m20260510_000015_fediverse_pending_invites::Migration),
26 40 ]
27 41 }
28 42 }
A crates/rustwarden-core/migrations/src/m20260510_000009_remote_identities.rs
+114 -0
@@ -0,0 +1,114 @@
1 +//! Migration `m20260510_000009_remote_identities` (RFC 0003).
2 +//!
3 +//! Creates the `remote_identities` table — local cache of ActivityPub actors
4 +//! resolved on remote instances. Backs `RemoteIdentityService::resolve_handle`.
5 +
6 +use sea_orm_migration::prelude::*;
7 +
8 +#[derive(DeriveMigrationName)]
9 +pub struct Migration;
10 +
11 +#[async_trait::async_trait]
12 +impl MigrationTrait for Migration {
13 + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
14 + manager
15 + .create_table(
16 + Table::create()
17 + .table(RemoteIdentities::Table)
18 + .col(
19 + ColumnDef::new(RemoteIdentities::Id)
20 + .uuid()
21 + .not_null()
22 + .primary_key(),
23 + )
24 + .col(
25 + ColumnDef::new(RemoteIdentities::ApId)
26 + .text()
27 + .not_null()
28 + .unique_key(),
29 + )
30 + .col(ColumnDef::new(RemoteIdentities::Username).text().not_null())
31 + .col(ColumnDef::new(RemoteIdentities::Domain).text().not_null())
32 + .col(ColumnDef::new(RemoteIdentities::DisplayName).text().null())
33 + .col(ColumnDef::new(RemoteIdentities::SummaryHtml).text().null())
34 + .col(ColumnDef::new(RemoteIdentities::InboxUrl).text().not_null())
35 + .col(ColumnDef::new(RemoteIdentities::SharedInboxUrl).text().null())
36 + .col(
37 + ColumnDef::new(RemoteIdentities::PublicKeyPem)
38 + .text()
39 + .not_null(),
40 + )
41 + .col(ColumnDef::new(RemoteIdentities::IconUrl).text().null())
42 + .col(
43 + ColumnDef::new(RemoteIdentities::ActorType)
44 + .text()
45 + .not_null()
46 + .default("Person"),
47 + )
48 + .col(ColumnDef::new(RemoteIdentities::RawJson).json_binary().null())
49 + .col(
50 + ColumnDef::new(RemoteIdentities::LastFetchedAt)
51 + .timestamp_with_time_zone()
52 + .not_null(),
53 + )
54 + .col(
55 + ColumnDef::new(RemoteIdentities::DisabledAt)
56 + .timestamp_with_time_zone()
57 + .null(),
58 + )
59 + .col(
60 + ColumnDef::new(RemoteIdentities::CreatedAt)
61 + .timestamp_with_time_zone()
62 + .not_null(),
63 + )
64 + .col(
65 + ColumnDef::new(RemoteIdentities::UpdatedAt)
66 + .timestamp_with_time_zone()
67 + .not_null(),
68 + )
69 + .to_owned(),
70 + )
71 + .await?;
72 +
73 + manager
74 + .create_index(
75 + Index::create()
76 + .table(RemoteIdentities::Table)
77 + .name("uniq_remote_identities_handle")
78 + .col(RemoteIdentities::Username)
79 + .col(RemoteIdentities::Domain)
80 + .unique()
81 + .to_owned(),
82 + )
83 + .await?;
84 +
85 + Ok(())
86 + }
87 +
88 + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
89 + manager
90 + .drop_table(Table::drop().table(RemoteIdentities::Table).to_owned())
91 + .await
92 + }
93 +}
94 +
95 +#[derive(DeriveIden)]
96 +enum RemoteIdentities {
97 + Table,
98 + Id,
99 + ApId,
100 + Username,
101 + Domain,
102 + DisplayName,
103 + SummaryHtml,
104 + InboxUrl,
105 + SharedInboxUrl,
106 + PublicKeyPem,
107 + IconUrl,
108 + ActorType,
109 + RawJson,
110 + LastFetchedAt,
111 + DisabledAt,
112 + CreatedAt,
113 + UpdatedAt,
114 +}
A crates/rustwarden-core/migrations/src/m20260510_000010_fediverse_trust_and_blocks.rs
+153 -0
@@ -0,0 +1,153 @@
1 +//! Migration `m20260510_000010_fediverse_trust_and_blocks` (RFC 0004).
2 +//!
3 +//! Creates the trust-model tables (`fediverse_trusted_domains`,
4 +//! `fediverse_blocked_domains`) and seeds default `app_settings` keys.
5 +
6 +use sea_orm_migration::prelude::*;
7 +use sea_orm_migration::sea_orm::ConnectionTrait;
8 +use uuid::Uuid;
9 +
10 +#[derive(DeriveMigrationName)]
11 +pub struct Migration;
12 +
13 +#[async_trait::async_trait]
14 +impl MigrationTrait for Migration {
15 + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
16 + manager
17 + .create_table(
18 + Table::create()
19 + .table(FediverseTrustedDomains::Table)
20 + .col(
21 + ColumnDef::new(FediverseTrustedDomains::Id)
22 + .uuid()
23 + .not_null()
24 + .primary_key(),
25 + )
26 + .col(
27 + ColumnDef::new(FediverseTrustedDomains::Domain)
28 + .text()
29 + .not_null()
30 + .unique_key(),
31 + )
32 + .col(ColumnDef::new(FediverseTrustedDomains::Note).text().null())
33 + .col(
34 + ColumnDef::new(FediverseTrustedDomains::AddedByUserId)
35 + .uuid()
36 + .null(),
37 + )
38 + .col(
39 + ColumnDef::new(FediverseTrustedDomains::CreatedAt)
40 + .timestamp_with_time_zone()
41 + .not_null(),
42 + )
43 + .to_owned(),
44 + )
45 + .await?;
46 +
47 + manager
48 + .create_table(
49 + Table::create()
50 + .table(FediverseBlockedDomains::Table)
51 + .col(
52 + ColumnDef::new(FediverseBlockedDomains::Id)
53 + .uuid()
54 + .not_null()
55 + .primary_key(),
56 + )
57 + .col(
58 + ColumnDef::new(FediverseBlockedDomains::Domain)
59 + .text()
60 + .not_null()
61 + .unique_key(),
62 + )
63 + .col(
64 + ColumnDef::new(FediverseBlockedDomains::Severity)
65 + .text()
66 + .not_null()
67 + .default("suspend"),
68 + )
69 + .col(
70 + ColumnDef::new(FediverseBlockedDomains::Reason)
71 + .text()
72 + .null(),
73 + )
74 + .col(
75 + ColumnDef::new(FediverseBlockedDomains::AddedByUserId)
76 + .uuid()
77 + .null(),
78 + )
79 + .col(
80 + ColumnDef::new(FediverseBlockedDomains::CreatedAt)
81 + .timestamp_with_time_zone()
82 + .not_null(),
83 + )
84 + .to_owned(),
85 + )
86 + .await?;
87 +
88 + // Seed default app_settings keys. Idempotent via ON CONFLICT —
89 + // the `app_settings` table is guaranteed to exist (created by
90 + // migration 0002) at this point of the migration chain. Values are
91 + // hard-coded constants under our control, so embedding them in the
92 + // SQL via `execute_unprepared` is safe (no user input).
93 + for (key, value) in [
94 + ("fediverse_enabled", "false"),
95 + ("fediverse_trust_mode", "allowlist"),
96 + ("fediverse_interop_scope", "rustwarden_only"),
97 + ("fediverse_authorized_fetch", "false"),
98 + ("fediverse_node_name", "Rustwarden"),
99 + ("fediverse_node_description", ""),
100 + ] {
101 + let sql = format!(
102 + "INSERT INTO app_settings (id, key, value, updated_at) \
103 + VALUES ('{}', '{}', '{}', NOW()) \
104 + ON CONFLICT (key) DO NOTHING",
105 + Uuid::new_v4(),
106 + key,
107 + value
108 + );
109 + manager.get_connection().execute_unprepared(&sql).await?;
110 + }
111 +
112 + Ok(())
113 + }
114 +
115 + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
116 + manager
117 + .drop_table(
118 + Table::drop()
119 + .table(FediverseTrustedDomains::Table)
120 + .to_owned(),
121 + )
122 + .await?;
123 + manager
124 + .drop_table(
125 + Table::drop()
126 + .table(FediverseBlockedDomains::Table)
127 + .to_owned(),
128 + )
129 + .await?;
130 + Ok(())
131 + }
132 +}
133 +
134 +#[derive(DeriveIden)]
135 +enum FediverseTrustedDomains {
136 + Table,
137 + Id,
138 + Domain,
139 + Note,
140 + AddedByUserId,
141 + CreatedAt,
142 +}
143 +
144 +#[derive(DeriveIden)]
145 +enum FediverseBlockedDomains {
146 + Table,
147 + Id,
148 + Domain,
149 + Severity,
150 + Reason,
151 + AddedByUserId,
152 + CreatedAt,
153 +}
A crates/rustwarden-core/migrations/src/m20260510_000011_extend_users_with_actor_keys.rs
+91 -0
@@ -0,0 +1,91 @@
1 +//! Migration `m20260510_000011_extend_users_with_actor_keys` (RFC 0005).
2 +//!
3 +//! Adds three columns to `users` for ActivityPub actor-key storage:
4 +//!
5 +//! - `ap_public_key_pem TEXT NULL` — public RSA key (SPKI / PEM).
6 +//! - `ap_private_key_pem TEXT NULL` — private RSA key (PKCS#8 / PEM),
7 +//! encrypted at rest via `CryptoService`.
8 +//! - `ap_discoverable BOOLEAN NOT NULL DEFAULT TRUE`.
9 +
10 +use sea_orm_migration::prelude::*;
11 +
12 +#[derive(DeriveMigrationName)]
13 +pub struct Migration;
14 +
15 +#[async_trait::async_trait]
16 +impl MigrationTrait for Migration {
17 + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
18 + manager
19 + .alter_table(
20 + Table::alter()
21 + .table(Users::Table)
22 + .add_column(ColumnDef::new(Users::ApPublicKeyPem).text().null())
23 + .to_owned(),
24 + )
25 + .await?;
26 +
27 + manager
28 + .alter_table(
29 + Table::alter()
30 + .table(Users::Table)
31 + .add_column(ColumnDef::new(Users::ApPrivateKeyPem).text().null())
32 + .to_owned(),
33 + )
34 + .await?;
35 +
36 + manager
37 + .alter_table(
38 + Table::alter()
39 + .table(Users::Table)
40 + .add_column(
41 + ColumnDef::new(Users::ApDiscoverable)
42 + .boolean()
43 + .not_null()
44 + .default(true),
45 + )
46 + .to_owned(),
47 + )
48 + .await?;
49 +
50 + Ok(())
51 + }
52 +
53 + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
54 + manager
55 + .alter_table(
56 + Table::alter()
57 + .table(Users::Table)
58 + .drop_column(Users::ApDiscoverable)
59 + .to_owned(),
60 + )
61 + .await?;
62 +
63 + manager
64 + .alter_table(
65 + Table::alter()
66 + .table(Users::Table)
67 + .drop_column(Users::ApPrivateKeyPem)
68 + .to_owned(),
69 + )
70 + .await?;
71 +
72 + manager
73 + .alter_table(
74 + Table::alter()
75 + .table(Users::Table)
76 + .drop_column(Users::ApPublicKeyPem)
77 + .to_owned(),
78 + )
79 + .await?;
80 +
81 + Ok(())
82 + }
83 +}
84 +
85 +#[derive(DeriveIden)]
86 +enum Users {
87 + Table,
88 + ApPublicKeyPem,
89 + ApPrivateKeyPem,
90 + ApDiscoverable,
91 +}
A crates/rustwarden-core/migrations/src/m20260510_000012_fediverse_outbound_queue.rs
+139 -0
@@ -0,0 +1,139 @@
1 +//! Migration `m20260510_000012_fediverse_outbound_queue` (RFC 0011).
2 +//!
3 +//! Creates the persistent outbound delivery queue used by the
4 +//! ActivityPub outbound worker. The slice livered by RFC 0011 is the
5 +//! Phase-2 minimum: status transitions `pending → delivered|failed`.
6 +//! Full retry/backoff logic lands with RFC 0010.
7 +
8 +use sea_orm_migration::prelude::*;
9 +
10 +#[derive(DeriveMigrationName)]
11 +pub struct Migration;
12 +
13 +#[async_trait::async_trait]
14 +impl MigrationTrait for Migration {
15 + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
16 + manager
17 + .create_table(
18 + Table::create()
19 + .table(FediverseOutboundQueue::Table)
20 + .col(
21 + ColumnDef::new(FediverseOutboundQueue::Id)
22 + .uuid()
23 + .not_null()
24 + .primary_key(),
25 + )
26 + .col(
27 + ColumnDef::new(FediverseOutboundQueue::ActivityId)
28 + .text()
29 + .not_null(),
30 + )
31 + .col(
32 + ColumnDef::new(FediverseOutboundQueue::PayloadJson)
33 + .text()
34 + .not_null(),
35 + )
36 + .col(
37 + ColumnDef::new(FediverseOutboundQueue::TargetInbox)
38 + .text()
39 + .not_null(),
40 + )
41 + .col(
42 + ColumnDef::new(FediverseOutboundQueue::TargetDomain)
43 + .text()
44 + .not_null(),
45 + )
46 + .col(
47 + ColumnDef::new(FediverseOutboundQueue::Status)
48 + .text()
49 + .not_null()
50 + .default("pending"),
51 + )
52 + .col(
53 + ColumnDef::new(FediverseOutboundQueue::Attempts)
54 + .integer()
55 + .not_null()
56 + .default(0),
57 + )
58 + .col(
59 + ColumnDef::new(FediverseOutboundQueue::MaxAttempts)
60 + .integer()
61 + .not_null()
62 + .default(8),
63 + )
64 + .col(ColumnDef::new(FediverseOutboundQueue::LastError).text().null())
65 + .col(
66 + ColumnDef::new(FediverseOutboundQueue::NextRetryAt)
67 + .timestamp_with_time_zone()
68 + .null(),
69 + )
70 + .col(
71 + ColumnDef::new(FediverseOutboundQueue::CreatedAt)
72 + .timestamp_with_time_zone()
73 + .not_null(),
74 + )
75 + .col(
76 + ColumnDef::new(FediverseOutboundQueue::DeliveredAt)
77 + .timestamp_with_time_zone()
78 + .null(),
79 + )
80 + .col(
81 + ColumnDef::new(FediverseOutboundQueue::ActorUserId)
82 + .uuid()
83 + .null(),
84 + )
85 + .to_owned(),
86 + )
87 + .await?;
88 +
89 + manager
90 + .create_index(
91 + Index::create()
92 + .name("fediverse_outbound_pending_idx")
93 + .table(FediverseOutboundQueue::Table)
94 + .col(FediverseOutboundQueue::Status)
95 + .col(FediverseOutboundQueue::NextRetryAt)
96 + .to_owned(),
97 + )
98 + .await?;
99 +
100 + Ok(())
101 + }
102 +
103 + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
104 + manager
105 + .drop_index(
106 + Index::drop()
107 + .name("fediverse_outbound_pending_idx")
108 + .table(FediverseOutboundQueue::Table)
109 + .to_owned(),
110 + )
111 + .await?;
112 + manager
113 + .drop_table(
114 + Table::drop()
115 + .table(FediverseOutboundQueue::Table)
116 + .to_owned(),
117 + )
118 + .await?;
119 + Ok(())
120 + }
121 +}
122 +
123 +#[derive(DeriveIden)]
124 +enum FediverseOutboundQueue {
125 + Table,
126 + Id,
127 + ActivityId,
128 + PayloadJson,
129 + TargetInbox,
130 + TargetDomain,
131 + Status,
132 + Attempts,
133 + MaxAttempts,
134 + LastError,
135 + NextRetryAt,
136 + CreatedAt,
137 + DeliveredAt,
138 + ActorUserId,
139 +}
A crates/rustwarden-core/migrations/src/m20260510_000013_fediverse_auth_challenges.rs
+122 -0
@@ -0,0 +1,122 @@
1 +//! Migration `m20260510_000013_fediverse_auth_challenges` (RFC 0006).
2 +//!
3 +//! Persists ActivityPub authentication challenges issued by the
4 +//! client-side `FediverseAuthService::initiate` step.
5 +
6 +use sea_orm_migration::prelude::*;
7 +
8 +#[derive(DeriveMigrationName)]
9 +pub struct Migration;
10 +
11 +#[async_trait::async_trait]
12 +impl MigrationTrait for Migration {
13 + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
14 + manager
15 + .create_table(
16 + Table::create()
17 + .table(FediverseAuthChallenges::Table)
18 + .col(
19 + ColumnDef::new(FediverseAuthChallenges::Id)
20 + .uuid()
21 + .not_null()
22 + .primary_key(),
23 + )
24 + .col(
25 + ColumnDef::new(FediverseAuthChallenges::Nonce)
26 + .text()
27 + .not_null()
28 + .unique_key(),
29 + )
30 + .col(
31 + ColumnDef::new(FediverseAuthChallenges::Handle)
32 + .text()
33 + .not_null(),
34 + )
35 + .col(
36 + ColumnDef::new(FediverseAuthChallenges::Domain)
37 + .text()
38 + .not_null(),
39 + )
40 + .col(
41 + ColumnDef::new(FediverseAuthChallenges::RequestedRemoteId)
42 + .uuid()
43 + .null(),
44 + )
45 + .col(
46 + ColumnDef::new(FediverseAuthChallenges::Status)
47 + .text()
48 + .not_null()
49 + .default("pending"),
50 + )
51 + .col(
52 + ColumnDef::new(FediverseAuthChallenges::RedirectUrl)
53 + .text()
54 + .null(),
55 + )
56 + .col(
57 + ColumnDef::new(FediverseAuthChallenges::CreatedAt)
58 + .timestamp_with_time_zone()
59 + .not_null(),
60 + )
61 + .col(
62 + ColumnDef::new(FediverseAuthChallenges::ExpiresAt)
63 + .timestamp_with_time_zone()
64 + .not_null(),
65 + )
66 + .col(
67 + ColumnDef::new(FediverseAuthChallenges::ConsumedAt)
68 + .timestamp_with_time_zone()
69 + .null(),
70 + )
71 + .to_owned(),
72 + )
73 + .await?;
74 +
75 + manager
76 + .create_index(
77 + Index::create()
78 + .name("fediverse_auth_challenges_pending_idx")
79 + .table(FediverseAuthChallenges::Table)
80 + .col(FediverseAuthChallenges::Status)
81 + .col(FediverseAuthChallenges::ExpiresAt)
82 + .to_owned(),
83 + )
84 + .await?;
85 +
86 + Ok(())
87 + }
88 +
89 + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
90 + manager
91 + .drop_index(
92 + Index::drop()
93 + .name("fediverse_auth_challenges_pending_idx")
94 + .table(FediverseAuthChallenges::Table)
95 + .to_owned(),
96 + )
97 + .await?;
98 + manager
99 + .drop_table(
100 + Table::drop()
101 + .table(FediverseAuthChallenges::Table)
102 + .to_owned(),
103 + )
104 + .await?;
105 + Ok(())
106 + }
107 +}
108 +
109 +#[derive(DeriveIden)]
110 +enum FediverseAuthChallenges {
111 + Table,
112 + Id,
113 + Nonce,
114 + Handle,
115 + Domain,
116 + RequestedRemoteId,
117 + Status,
118 + RedirectUrl,
119 + CreatedAt,
120 + ExpiresAt,
121 + ConsumedAt,
122 +}
A crates/rustwarden-core/migrations/src/m20260510_000014_identity_sibling_fks.rs
+150 -0
@@ -0,0 +1,150 @@
1 +//! Migration `m20260510_000014_identity_sibling_fks` (RFC 0008).
2 +//!
3 +//! Adds **nullable sibling FK columns** so that team membership,
4 +//! resource sharing, and audit logs can reference a
5 +//! `remote_identities.id` once RFC 0009 lands.
6 +//!
7 +//! No CHECK constraints are added in this migration: the existing
8 +//! `team_members.user_id` is part of the PK (NOT NULL by structure)
9 +//! and `resource_shares` already has a XOR check between
10 +//! `shared_with_user_id` / `shared_with_team_id`. RFC 0009 will
11 +//! relax the PK and extend the XOR to include the remote sibling
12 +//! when the service actually starts emitting remote rows.
13 +
14 +use sea_orm_migration::prelude::*;
15 +use sea_orm_migration::sea_orm::ConnectionTrait;
16 +
17 +#[derive(DeriveMigrationName)]
18 +pub struct Migration;
19 +
20 +#[async_trait::async_trait]
21 +impl MigrationTrait for Migration {
22 + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
23 + manager
24 + .alter_table(
25 + Table::alter()
26 + .table(TeamMembers::Table)
27 + .add_column(ColumnDef::new(TeamMembers::RemoteIdentityId).uuid().null())
28 + .to_owned(),
29 + )
30 + .await?;
31 +
32 + manager
33 + .alter_table(
34 + Table::alter()
35 + .table(ResourceShares::Table)
36 + .add_column(
37 + ColumnDef::new(ResourceShares::SharedWithRemoteIdentityId)
38 + .uuid()
39 + .null(),
40 + )
41 + .to_owned(),
42 + )
43 + .await?;
44 +
45 + manager
46 + .alter_table(
47 + Table::alter()
48 + .table(AuditLogs::Table)
49 + .add_column(
50 + ColumnDef::new(AuditLogs::ActorRemoteIdentityId)
51 + .uuid()
52 + .null(),
53 + )
54 + .to_owned(),
55 + )
56 + .await?;
57 + manager
58 + .alter_table(
59 + Table::alter()
60 + .table(AuditLogs::Table)
61 + .add_column(
62 + ColumnDef::new(AuditLogs::TargetRemoteIdentityId)
63 + .uuid()
64 + .null(),
65 + )
66 + .to_owned(),
67 + )
68 + .await?;
69 +
70 + // Partial indexes for the lookup paths RFC 0009 will exercise.
71 + let conn = manager.get_connection();
72 + conn.execute_unprepared(
73 + "CREATE INDEX team_members_remote_identity_idx \
74 + ON team_members (remote_identity_id) \
75 + WHERE remote_identity_id IS NOT NULL",
76 + )
77 + .await?;
78 + conn.execute_unprepared(
79 + "CREATE INDEX resource_shares_remote_identity_idx \
80 + ON resource_shares (shared_with_remote_identity_id) \
81 + WHERE shared_with_remote_identity_id IS NOT NULL",
82 + )
83 + .await?;
84 +
85 + Ok(())
86 + }
87 +
88 + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
89 + let conn = manager.get_connection();
90 + for sql in [
91 + "DROP INDEX IF EXISTS resource_shares_remote_identity_idx",
92 + "DROP INDEX IF EXISTS team_members_remote_identity_idx",
93 + ] {
94 + conn.execute_unprepared(sql).await?;
95 + }
96 +
97 + manager
98 + .alter_table(
99 + Table::alter()
100 + .table(AuditLogs::Table)
101 + .drop_column(AuditLogs::TargetRemoteIdentityId)
102 + .to_owned(),
103 + )
104 + .await?;
105 + manager
106 + .alter_table(
107 + Table::alter()
108 + .table(AuditLogs::Table)
109 + .drop_column(AuditLogs::ActorRemoteIdentityId)
110 + .to_owned(),
111 + )
112 + .await?;
113 + manager
114 + .alter_table(
115 + Table::alter()
116 + .table(ResourceShares::Table)
117 + .drop_column(ResourceShares::SharedWithRemoteIdentityId)
118 + .to_owned(),
119 + )
120 + .await?;
121 + manager
122 + .alter_table(
123 + Table::alter()
124 + .table(TeamMembers::Table)
125 + .drop_column(TeamMembers::RemoteIdentityId)
126 + .to_owned(),
127 + )
128 + .await?;
129 + Ok(())
130 + }
131 +}
132 +
133 +#[derive(DeriveIden)]
134 +enum TeamMembers {
135 + Table,
136 + RemoteIdentityId,
137 +}
138 +
139 +#[derive(DeriveIden)]
140 +enum ResourceShares {
141 + Table,
142 + SharedWithRemoteIdentityId,
143 +}
144 +
145 +#[derive(DeriveIden)]
146 +enum AuditLogs {
147 + Table,
148 + ActorRemoteIdentityId,
149 + TargetRemoteIdentityId,
150 +}
A crates/rustwarden-core/migrations/src/m20260510_000015_fediverse_pending_invites.rs
+129 -0
@@ -0,0 +1,129 @@
1 +//! Migration `m20260510_000015_fediverse_pending_invites` (RFC 0009).
2 +//!
3 +//! Persists cross-instance team-membership invitations and adds the
4 +//! partial unique index needed to prevent duplicate remote
5 +//! memberships within the same team.
6 +
7 +use sea_orm_migration::prelude::*;
8 +use sea_orm_migration::sea_orm::ConnectionTrait;
9 +
10 +#[derive(DeriveMigrationName)]
11 +pub struct Migration;
12 +
13 +#[async_trait::async_trait]
14 +impl MigrationTrait for Migration {
15 + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
16 + manager
17 + .create_table(
18 + Table::create()
19 + .table(FediversePendingInvites::Table)
20 + .col(
21 + ColumnDef::new(FediversePendingInvites::Id)
22 + .uuid()
23 + .not_null()
24 + .primary_key(),
25 + )
26 + .col(
27 + ColumnDef::new(FediversePendingInvites::ActivityId)
28 + .text()
29 + .not_null()
30 + .unique_key(),
31 + )
32 + .col(
33 + ColumnDef::new(FediversePendingInvites::TeamId)
34 + .uuid()
35 + .not_null(),
36 + )
37 + .col(
38 + ColumnDef::new(FediversePendingInvites::InvitedHandle)
39 + .text()
40 + .not_null(),
41 + )
42 + .col(
43 + ColumnDef::new(FediversePendingInvites::InvitedRemoteIdentityId)
44 + .uuid()
45 + .null(),
46 + )
47 + .col(
48 + ColumnDef::new(FediversePendingInvites::Role)
49 + .text()
50 + .not_null(),
51 + )
52 + .col(
53 + ColumnDef::new(FediversePendingInvites::InvitedByUserId)
54 + .uuid()
55 + .not_null(),
56 + )
57 + .col(
58 + ColumnDef::new(FediversePendingInvites::Status)
59 + .text()
60 + .not_null()
61 + .default("pending"),
62 + )
63 + .col(
64 + ColumnDef::new(FediversePendingInvites::CreatedAt)
65 + .timestamp_with_time_zone()
66 + .not_null(),
67 + )
68 + .col(
69 + ColumnDef::new(FediversePendingInvites::ExpiresAt)
70 + .timestamp_with_time_zone()
71 + .not_null(),
72 + )
73 + .col(
74 + ColumnDef::new(FediversePendingInvites::DecidedAt)
75 + .timestamp_with_time_zone()
76 + .null(),
77 + )
78 + .to_owned(),
79 + )
80 + .await?;
81 +
82 + let conn = manager.get_connection();
83 + conn.execute_unprepared(
84 + "CREATE INDEX fediverse_pending_invites_status_idx \
85 + ON fediverse_pending_invites (status, expires_at)",
86 + )
87 + .await?;
88 + conn.execute_unprepared(
89 + "CREATE UNIQUE INDEX team_members_remote_unique \
90 + ON team_members (team_id, remote_identity_id) \
91 + WHERE remote_identity_id IS NOT NULL",
92 + )
93 + .await?;
94 +
95 + Ok(())
96 + }
97 +
98 + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
99 + let conn = manager.get_connection();
100 + conn.execute_unprepared("DROP INDEX IF EXISTS team_members_remote_unique")
101 + .await?;
102 + conn.execute_unprepared("DROP INDEX IF EXISTS fediverse_pending_invites_status_idx")
103 + .await?;
104 + manager
105 + .drop_table(
106 + Table::drop()
107 + .table(FediversePendingInvites::Table)
108 + .to_owned(),
109 + )
110 + .await?;
111 + Ok(())
112 + }
113 +}
114 +
115 +#[derive(DeriveIden)]
116 +enum FediversePendingInvites {
117 + Table,
118 + Id,
119 + ActivityId,
120 + TeamId,
121 + InvitedHandle,
122 + InvitedRemoteIdentityId,
123 + Role,
124 + InvitedByUserId,
125 + Status,
126 + CreatedAt,
127 + ExpiresAt,
128 + DecidedAt,
129 +}
M crates/rustwarden-core/src/app.rs
+69 -0
@@ -132,6 +132,11 @@ pub struct RustwardenBuilder {
132 132 locale_dirs: Vec<PathBuf>,
133 133 supported_locales: Vec<String>,
134 134 default_locale: String,
135 + /// Federation configuration loaded by [`RustwardenBuilder::with_fediverse`].
136 + /// Subsequent fediverse RFCs (0002+) consume this field to mount routes,
137 + /// the federation middleware, and the outbound delivery worker.
138 + #[cfg(feature = "experimental-fediverse")]
139 + pub(crate) fediverse_config: Option<crate::config::FediverseConfig>,
135 140 }
136 141
137 142 impl Default for RustwardenBuilder {
@@ -149,6 +154,8 @@ impl Default for RustwardenBuilder {
149 154 locale_dirs: Vec::new(),
150 155 supported_locales: vec!["fr".into(), "en".into(), "de".into(), "es".into()],
151 156 default_locale: "fr".into(),
157 + #[cfg(feature = "experimental-fediverse")]
158 + fediverse_config: None,
152 159 }
153 160 }
154 161 }
@@ -215,6 +222,54 @@ impl RustwardenBuilder {
215 222 self
216 223 }
217 224
225 + /// Enable ActivityPub identity federation on the application (RFC 0001).
226 + ///
227 + /// Loads [`FediverseConfig`](crate::config::FediverseConfig) from
228 + /// environment variables and stores it on the builder. Subsequent
229 + /// fediverse RFCs (0002+) consume this configuration to mount the
230 + /// federation router, register the federation middleware, and start the
231 + /// outbound delivery worker.
232 + ///
233 + /// At runtime, the federation router additionally honours the
234 + /// `app_settings.fediverse_enabled` flag (covered by RFC 0004): when
235 + /// `false`, federation routes return `404 Not Found` even though they
236 + /// are wired into the Axum app.
237 + ///
238 + /// **Compile-time opt-in**: this method is only available when the
239 + /// `experimental-fediverse` Cargo feature is enabled. Consumers that do
240 + /// not enable the feature cannot call it, which is intentional.
241 + ///
242 + /// # Errors
243 + ///
244 + /// Returns [`anyhow::Error`] when
245 + /// [`FediverseConfig::from_env`](crate::config::FediverseConfig::from_env)
246 + /// fails (missing/placeholder domain, insecure base URL outside debug,
247 + /// invalid numeric env var, …).
248 + ///
249 + /// # Example
250 + ///
251 + /// ```rust,ignore
252 + /// use rustwarden_core::app::RustwardenApp;
253 + ///
254 + /// #[tokio::main]
255 + /// async fn main() -> anyhow::Result<()> {
256 + /// RustwardenApp::builder()
257 + /// .from_env()
258 + /// .with_fediverse()?
259 + /// .build()
260 + /// .await?
261 + /// .run()
262 + /// .await
263 + /// }
264 + /// ```
265 + #[cfg(feature = "experimental-fediverse")]
266 + pub fn with_fediverse(mut self) -> anyhow::Result<Self> {
267 + let config = crate::config::FediverseConfig::from_env()
268 + .map_err(|e| anyhow::anyhow!("invalid FediverseConfig: {e}"))?;
269 + self.fediverse_config = Some(config);
270 + Ok(self)
271 + }
272 +
218 273 /// Enable automatic database migration at startup.
219 274 pub fn auto_migrate(mut self, enabled: bool) -> Self {
220 275 self.auto_migrate = enabled;
@@ -419,6 +474,20 @@ impl RustwardenBuilder {
419 474 router = router.merge(oauth_routes);
420 475 }
421 476
477 + // 7c. Federation routes (RFC 0002 — discovery layer).
478 + // Mounted only if the consumer called `with_fediverse()` and the
479 + // feature is compiled in.
480 + #[cfg(feature = "experimental-fediverse")]
481 + if let Some(fediverse_config) = self.fediverse_config.as_ref() {
482 + let fediverse_routes =
483 + crate::fediverse::routes::router(fediverse_config.clone(), db.clone());
484 + router = router.merge(fediverse_routes);
485 + tracing::info!(
486 + "ActivityPub federation routes mounted on domain={}",
487 + fediverse_config.domain()
488 + );
489 + }
490 +
422 491 // 8. Apply middleware stack
423 492 let supported = self.supported_locales.clone();
424 493 let default = self.default_locale.clone();
M crates/rustwarden-core/src/auth/jwt.rs
+211 -0
@@ -13,6 +13,28 @@ use uuid::Uuid;
13 13 use crate::config::auth::AuthConfig;
14 14 use crate::error::AppError;
15 15
16 +/// Origin of a JWT subject (RFC 0007).
17 +///
18 +/// Lets middlewares distinguish a local user (`users.id` in `sub`) from
19 +/// a federated remote identity (`remote_identities.id` in `sub`) without
20 +/// touching the database. Persisted on the wire as the snake_case key
21 +/// `kind`. Tokens minted before RFC 0007 deserialise as `Local`.
22 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23 +#[serde(rename_all = "snake_case")]
24 +pub enum IdentityKind {
25 + /// Subject = `users.id` (local account).
26 + Local,
27 + /// Subject = `remote_identities.id` (federated user authenticated via
28 + /// the AP-native flow of RFC 0006).
29 + Remote,
30 +}
31 +
32 +impl Default for IdentityKind {
33 + fn default() -> Self {
34 + Self::Local
35 + }
36 +}
37 +
16 38 /// JWT Claims structure
17 39 ///
18 40 /// Contains all information embedded in the JWT token.
@@ -51,6 +73,8 @@ use crate::error::AppError;
51 73 /// exp: Utc::now().timestamp() + 900,
52 74 /// iat: Utc::now().timestamp(),
53 75 /// iss: "rustwarden".to_string(),
76 +/// identity_kind: rustwarden_core::auth::jwt::IdentityKind::Local,
77 +/// remote_id: None,
54 78 /// };
55 79 /// ```
56 80 #[derive(Debug, Serialize, Deserialize, Clone)]
@@ -84,6 +108,42 @@ pub struct Claims {
84 108
85 109 /// Issuer (e.g., "rustwarden")
86 110 pub iss: String,
111 +
112 + /// RFC 0007: identity kind discriminating local users from remote
113 + /// (federated) ones. Defaults to [`IdentityKind::Local`] for legacy
114 + /// tokens that pre-date the field.
115 + #[serde(default, rename = "kind")]
116 + pub identity_kind: IdentityKind,
117 +
118 + /// RFC 0007: foreign key to `remote_identities.id` when
119 + /// `identity_kind = Remote`. Always `None` for local tokens.
120 + #[serde(default, skip_serializing_if = "Option::is_none")]
121 + pub remote_id: Option<Uuid>,
122 +}
123 +
124 +impl Claims {
125 + /// Returns true when this token was issued for a federated remote
126 + /// identity (RFC 0006 flow).
127 + pub fn is_remote(&self) -> bool {
128 + matches!(self.identity_kind, IdentityKind::Remote)
129 + }
130 +
131 + /// Returns true when this token was issued for a local user.
132 + pub fn is_local(&self) -> bool {
133 + matches!(self.identity_kind, IdentityKind::Local)
134 + }
135 +
136 + /// Returns the `remote_identities.id` carried by this token, but
137 + /// only when [`Self::is_remote`] is true. A spurious `remote_id`
138 + /// alongside `kind = Local` is treated as `None` to avoid the
139 + /// confused-deputy hazard.
140 + pub fn remote_identity_id(&self) -> Option<Uuid> {
141 + if self.is_remote() {
142 + self.remote_id
143 + } else {
144 + None
145 + }
146 + }
87 147 }
88 148
89 149 /// JWT service for token operations
@@ -159,6 +219,8 @@ impl JwtService {
159 219 exp: expiration.timestamp(),
160 220 iat: now.timestamp(),
161 221 iss: config.jwt_issuer.clone(),
222 + identity_kind: IdentityKind::Local,
223 + remote_id: None,
162 224 };
163 225
164 226 let token = encode(
@@ -180,6 +242,54 @@ impl JwtService {
180 242 Ok(token)
181 243 }
182 244
245 + /// Generate a JWT token explicitly typed as a remote (federated)
246 + /// identity (RFC 0007).
247 + ///
248 + /// `remote_identity_id` becomes both `sub` and `remote_id` in the
249 + /// payload, and `identity_kind` is set to [`IdentityKind::Remote`].
250 + /// `handle` is the canonical `@user@domain` string used as
251 + /// `username` for log/audit consistency. Remote tokens never carry
252 + /// roles or permissions — federated subjects authenticate against
253 + /// team-scoped grants only (RFC 0009).
254 + ///
255 + /// # Errors
256 + ///
257 + /// Returns `AppError::Config` if the underlying signing fails.
258 + pub fn generate_remote_token(
259 + config: &AuthConfig,
260 + remote_identity_id: Uuid,
261 + handle: &str,
262 + ) -> Result<String, AppError> {
263 + let now = Utc::now();
264 + let expiration = now + config.jwt_expiration_duration();
265 +
266 + let claims = Claims {
267 + sub: remote_identity_id,
268 + jti: Uuid::new_v4().to_string(),
269 + aud: config.jwt_issuer.clone(),
270 + username: handle.to_string(),
271 + roles: Vec::new(),
272 + permissions: Vec::new(),
273 + exp: expiration.timestamp(),
274 + iat: now.timestamp(),
275 + iss: config.jwt_issuer.clone(),
276 + identity_kind: IdentityKind::Remote,
277 + remote_id: Some(remote_identity_id),
278 + };
279 +
280 + let token = encode(
281 + &Header::default(),
282 + &claims,
283 + &EncodingKey::from_secret(config.jwt_secret.as_bytes()),
284 + )
285 + .map_err(|e| {
286 + tracing::error!("Failed to generate remote JWT token: {}", e);
287 + AppError::Config(format!("JWT generation failed: {}", e))
288 + })?;
289 +
290 + Ok(token)
291 + }
292 +
183 293 /// Validate a JWT token and extract claims
184 294 ///
185 295 /// Verifies the token signature, checks expiration, and extracts the claims.
@@ -353,6 +463,8 @@ mod tests {
353 463 exp: Utc::now().timestamp() + 900,
354 464 iat: Utc::now().timestamp(),
355 465 iss: "rustwarden".to_string(),
466 + identity_kind: IdentityKind::Local,
467 + remote_id: None,
356 468 };
357 469
358 470 let json = serde_json::to_string(&claims);
@@ -371,6 +483,8 @@ mod tests {
371 483 exp: (Utc::now() + chrono::Duration::hours(1)).timestamp(),
372 484 iat: Utc::now().timestamp(),
373 485 iss: "rustwarden".to_string(),
486 + identity_kind: IdentityKind::Local,
487 + remote_id: None,
374 488 };
375 489 assert!(!JwtService::is_expired(&claims));
376 490
@@ -384,10 +498,107 @@ mod tests {
384 498 exp: (Utc::now() - chrono::Duration::hours(1)).timestamp(),
385 499 iat: Utc::now().timestamp(),
386 500 iss: "rustwarden".to_string(),
501 + identity_kind: IdentityKind::Local,
502 + remote_id: None,
387 503 };
388 504 assert!(JwtService::is_expired(&expired_claims));
389 505 }
390 506
391 507 // Note: Integration tests with real token generation/validation
392 508 // require AuthConfig and are in tests/integration/
509 +
510 + // ─── RFC 0007 — IdentityKind & remote_id ─────────────────────────
511 +
512 + fn make_local_claims() -> Claims {
513 + Claims {
514 + sub: Uuid::new_v4(),
515 + jti: Uuid::new_v4().to_string(),
516 + aud: "rustwarden".to_string(),
517 + username: "alice".to_string(),
518 + roles: vec![],
519 + permissions: vec![],
520 + exp: Utc::now().timestamp() + 900,
521 + iat: Utc::now().timestamp(),
522 + iss: "rustwarden".to_string(),
523 + identity_kind: IdentityKind::Local,
524 + remote_id: None,
525 + }
526 + }
527 +
528 + #[test]
529 + fn rfc0007_default_identity_kind_is_local() {
530 + assert_eq!(IdentityKind::default(), IdentityKind::Local);
531 + }
532 +
533 + #[test]
534 + fn rfc0007_claims_serialise_kind_as_snake_case() {
535 + let mut claims = make_local_claims();
536 + claims.identity_kind = IdentityKind::Remote;
537 + claims.remote_id = Some(Uuid::new_v4());
538 + let json = serde_json::to_value(&claims).unwrap();
539 + assert_eq!(json["kind"], serde_json::json!("remote"));
540 + assert!(json["remote_id"].is_string());
541 + }
542 +
543 + #[test]
544 + fn rfc0007_claims_deserialise_legacy_token_as_local() {
545 + let legacy = serde_json::json!({
546 + "sub": Uuid::new_v4(),
547 + "jti": Uuid::new_v4().to_string(),
548 + "aud": "rustwarden",
549 + "username": "bob",
550 + "roles": [],
551 + "permissions": [],
552 + "exp": 4_102_444_800_i64,
553 + "iat": 1_704_067_200_i64,
554 + "iss": "rustwarden"
555 + });
556 + let claims: Claims = serde_json::from_value(legacy).unwrap();
557 + assert_eq!(claims.identity_kind, IdentityKind::Local);
558 + assert!(claims.remote_id.is_none());
559 + }
560 +
561 + #[test]
562 + fn rfc0007_claims_remote_id_skips_when_none() {
563 + let claims = make_local_claims();
564 + let json = serde_json::to_value(&claims).unwrap();
565 + assert!(
566 + json.get("remote_id").is_none(),
567 + "remote_id must be omitted when None, payload was: {json}"
568 + );
569 + }
570 +
571 + #[test]
572 + fn rfc0007_is_remote_returns_true_for_remote_token() {
573 + let mut claims = make_local_claims();
574 + claims.identity_kind = IdentityKind::Remote;
575 + claims.remote_id = Some(Uuid::new_v4());
576 + assert!(claims.is_remote());
577 + assert!(!claims.is_local());
578 + }
579 +
580 + #[test]
581 + fn rfc0007_is_local_returns_true_when_default() {
582 + let claims = make_local_claims();
583 + assert!(claims.is_local());
584 + assert!(!claims.is_remote());
585 + }
586 +
587 + #[test]
588 + fn rfc0007_remote_identity_id_returns_none_for_local() {
589 + let mut claims = make_local_claims();
590 + // A leaked `remote_id` on a local token must NOT be exposed:
591 + // confused-deputy guard.
592 + claims.remote_id = Some(Uuid::new_v4());
593 + assert!(claims.remote_identity_id().is_none());
594 + }
595 +
596 + #[test]
597 + fn rfc0007_remote_identity_id_returns_some_for_remote() {
598 + let id = Uuid::new_v4();
599 + let mut claims = make_local_claims();
600 + claims.identity_kind = IdentityKind::Remote;
601 + claims.remote_id = Some(id);
602 + assert_eq!(claims.remote_identity_id(), Some(id));
603 + }
393 604 }
A crates/rustwarden-core/src/config/fediverse.rs
+490 -0
@@ -0,0 +1,490 @@
1 +//! Runtime configuration for ActivityPub identity federation (RFC 0001).
2 +//!
3 +//! This module is gated behind the `experimental-fediverse` Cargo feature.
4 +//! It loads federation parameters from environment variables at startup and
5 +//! is consumed by [`crate::app::RustwardenBuilder::with_fediverse`] to wire
6 +//! the federation router and outbound worker into the Axum application.
7 +//!
8 +//! See [`docs/rfcs/0001-fediverse-config-and-builder.md`](https://github.com/EBO/rustwarden/blob/main/docs/rfcs/0001-fediverse-config-and-builder.md)
9 +//! for the full specification.
10 +
11 +use std::time::Duration;
12 +use url::Url;
13 +
14 +/// Runtime configuration for ActivityPub identity federation.
15 +///
16 +/// Built from environment variables via [`FediverseConfig::from_env`]. Validated
17 +/// at construction: missing or placeholder domains, invalid URLs, and HTTP base
18 +/// URLs in production are rejected eagerly.
19 +///
20 +/// # Example
21 +///
22 +/// ```rust,ignore
23 +/// use rustwarden_core::config::FediverseConfig;
24 +///
25 +/// // SAFETY: doctest fixture — values are stable test constants and the
26 +/// // doctest binary serialises env access through cargo's test harness.
27 +/// unsafe {
28 +/// std::env::set_var("FEDIVERSE_DOMAIN", "example.org");
29 +/// }
30 +///
31 +/// let config = FediverseConfig::from_env().expect("valid config");
32 +/// assert_eq!(config.domain(), "example.org");
33 +/// assert_eq!(config.base_url().as_str(), "https://example.org/");
34 +/// ```
35 +#[derive(Debug, Clone)]
36 +pub struct FediverseConfig {
37 + domain: String,
38 + base_url: Url,
39 + user_agent: String,
40 + authorized_fetch: bool,
41 + outbound_workers: usize,
42 + outbound_max_attempts: u32,
43 + outbound_request_timeout: Duration,
44 + fetch_actor_cache_ttl: Duration,
45 + auth_challenge_ttl: Duration,
46 + debug_local_federation: bool,
47 +}
48 +
49 +/// Errors that can occur while loading or validating [`FediverseConfig`].
50 +#[derive(Debug, thiserror::Error)]
51 +pub enum FediverseConfigError {
52 + /// `FEDIVERSE_DOMAIN` environment variable is missing or empty.
53 + #[error("FEDIVERSE_DOMAIN environment variable is missing or empty")]
54 + MissingDomain,
55 +
56 + /// `FEDIVERSE_DOMAIN` value is a known placeholder
57 + /// (e.g. `example.com`, `localhost.localdomain`, `your.tld`).
58 + /// Refused regardless of debug mode to prevent accidental production use.
59 + #[error("FEDIVERSE_DOMAIN value `{0}` is a placeholder; set a real domain")]
60 + PlaceholderDomain(String),
61 +
62 + /// Computed base URL is not HTTPS while `debug_local_federation` is off.
63 + #[error("base URL `{0}` must use HTTPS in production (set FEDIVERSE_DEBUG_LOCAL_FEDERATION=true for local tests)")]
64 + InsecureBaseUrl(String),
65 +
66 + /// `FEDIVERSE_DOMAIN` cannot be parsed as a valid URL host.
67 + #[error("FEDIVERSE_DOMAIN value `{0}` is not a valid host: {1}")]
68 + InvalidDomain(String, String),
69 +
70 + /// A numeric environment variable could not be parsed.
71 + #[error("environment variable `{name}` has an invalid value `{value}`: {reason}")]
72 + InvalidNumeric {
73 + /// Name of the offending environment variable.
74 + name: &'static str,
75 + /// Raw value that failed to parse.
76 + value: String,
77 + /// Underlying parse error description.
78 + reason: String,
79 + },
80 +}
81 +
82 +impl FediverseConfig {
83 + /// Load and validate a [`FediverseConfig`] from environment variables.
84 + ///
85 + /// # Required variables
86 + ///
87 + /// - `FEDIVERSE_DOMAIN` — federated domain, no scheme, no path
88 + /// (e.g. `example.org`).
89 + ///
90 + /// # Optional variables (defaults shown)
91 + ///
92 + /// | Variable | Default |
93 + /// |---|---|
94 + /// | `FEDIVERSE_AUTHORIZED_FETCH` | `false` |
95 + /// | `FEDIVERSE_OUTBOUND_WORKERS` | `4` |
96 + /// | `FEDIVERSE_OUTBOUND_MAX_ATTEMPTS` | `8` |
97 + /// | `FEDIVERSE_OUTBOUND_REQUEST_TIMEOUT_SECS` | `30` |
98 + /// | `FEDIVERSE_FETCH_ACTOR_CACHE_TTL_SECS` | `86400` (24 h) |
99 + /// | `FEDIVERSE_AUTH_CHALLENGE_TTL_SECS` | `300` (5 min) |
100 + /// | `FEDIVERSE_DEBUG_LOCAL_FEDERATION` | `false` |
101 + ///
102 + /// When `FEDIVERSE_DEBUG_LOCAL_FEDERATION=true`, HTTP base URLs are
103 + /// accepted (for local 2-instance integration tests). In production this
104 + /// flag must remain `false` and the resulting base URL must be HTTPS.
105 + ///
106 + /// # Errors
107 + ///
108 + /// See [`FediverseConfigError`] for the full failure taxonomy.
109 + pub fn from_env() -> Result<Self, FediverseConfigError> {
110 + let raw_domain = std::env::var("FEDIVERSE_DOMAIN")
111 + .ok()
112 + .map(|s| s.trim().to_string())
113 + .filter(|s| !s.is_empty())
114 + .ok_or(FediverseConfigError::MissingDomain)?;
115 +
116 + if is_placeholder_domain(&raw_domain) {
117 + return Err(FediverseConfigError::PlaceholderDomain(raw_domain));
118 + }
119 +
120 + let debug_local_federation = read_bool_env("FEDIVERSE_DEBUG_LOCAL_FEDERATION", false)?;
121 +
122 + // Accept either a bare domain ("example.org") or a fully-qualified URL
123 + // ("http://localhost:3000"). The bare form always becomes HTTPS; the
124 + // explicit form is only honoured under debug mode.
125 + let (scheme, host_part) = if let Some(rest) = raw_domain.strip_prefix("https://") {
126 + ("https", rest.trim_end_matches('/').to_string())
127 + } else if let Some(rest) = raw_domain.strip_prefix("http://") {
128 + ("http", rest.trim_end_matches('/').to_string())
129 + } else {
130 + ("https", raw_domain.trim_end_matches('/').to_string())
131 + };
132 +
133 + if scheme == "http" && !debug_local_federation {
134 + return Err(FediverseConfigError::InsecureBaseUrl(format!(
135 + "http://{host_part}"
136 + )));
137 + }
138 +
139 + let base_url_str = format!("{scheme}://{host_part}/");
140 + let base_url = Url::parse(&base_url_str)
141 + .map_err(|e| FediverseConfigError::InvalidDomain(host_part.clone(), e.to_string()))?;
142 +
143 + let authorized_fetch = read_bool_env("FEDIVERSE_AUTHORIZED_FETCH", false)?;
144 + let outbound_workers = read_numeric_env("FEDIVERSE_OUTBOUND_WORKERS", 4)?;
145 + let outbound_max_attempts = read_numeric_env("FEDIVERSE_OUTBOUND_MAX_ATTEMPTS", 8)?;
146 + let outbound_request_timeout =
147 + Duration::from_secs(read_numeric_env("FEDIVERSE_OUTBOUND_REQUEST_TIMEOUT_SECS", 30)?);
148 + let fetch_actor_cache_ttl = Duration::from_secs(read_numeric_env(
149 + "FEDIVERSE_FETCH_ACTOR_CACHE_TTL_SECS",
150 + 86_400,
151 + )?);
152 + let auth_challenge_ttl =
153 + Duration::from_secs(read_numeric_env("FEDIVERSE_AUTH_CHALLENGE_TTL_SECS", 300)?);
154 +
155 + let user_agent = format!(
156 + "rustwarden/{} (+{})",
157 + env!("CARGO_PKG_VERSION"),
158 + base_url
159 + );
160 +
161 + Ok(Self {
162 + domain: host_part,
163 + base_url,
164 + user_agent,
165 + authorized_fetch,
166 + outbound_workers,
167 + outbound_max_attempts,
168 + outbound_request_timeout,
169 + fetch_actor_cache_ttl,
170 + auth_challenge_ttl,
171 + debug_local_federation,
172 + })
173 + }
174 +
175 + /// Federated domain (no scheme, no trailing slash). Example: `example.org`.
176 + pub fn domain(&self) -> &str {
177 + &self.domain
178 + }
179 +
180 + /// Canonical base URL with trailing slash. Example: `https://example.org/`.
181 + pub fn base_url(&self) -> &Url {
182 + &self.base_url
183 + }
184 +
185 + /// HTTP `User-Agent` to send on outbound federation requests.
186 + pub fn user_agent(&self) -> &str {
187 + &self.user_agent
188 + }
189 +
190 + /// Whether incoming `GET` requests on actor/object endpoints must be
191 + /// signed (Mastodon "secure mode" / Authorized Fetch).
192 + pub fn authorized_fetch(&self) -> bool {
193 + self.authorized_fetch
194 + }
195 +
196 + /// Number of concurrent worker tasks delivering outbound activities.
197 + pub fn outbound_workers(&self) -> usize {
198 + self.outbound_workers
199 + }
200 +
201 + /// Maximum delivery attempts before a job is marked `dead`.
202 + pub fn outbound_max_attempts(&self) -> u32 {
203 + self.outbound_max_attempts
204 + }
205 +
206 + /// Per-request HTTP timeout for outbound deliveries.
207 + pub fn outbound_request_timeout(&self) -> Duration {
208 + self.outbound_request_timeout
209 + }
210 +
211 + /// TTL for cached remote actor documents.
212 + pub fn fetch_actor_cache_ttl(&self) -> Duration {
213 + self.fetch_actor_cache_ttl
214 + }
215 +
216 + /// TTL for federated authentication challenges (cf. Phase 2).
217 + pub fn auth_challenge_ttl(&self) -> Duration {
218 + self.auth_challenge_ttl
219 + }
220 +
221 + /// When `true`, HTTP base URLs are allowed (local 2-instance tests).
222 + pub fn debug_local_federation(&self) -> bool {
223 + self.debug_local_federation
224 + }
225 +}
226 +
227 +/// Hostnames that almost certainly indicate the operator forgot to override
228 +/// the placeholder (CLI wizard templates, canonical RFC examples). Refused
229 +/// regardless of debug mode.
230 +///
231 +/// `example.org` is intentionally **not** included: it is a real IANA-owned
232 +/// domain commonly used in tests, doctests, and demos for this framework.
233 +const PLACEHOLDER_DOMAINS: &[&str] = &[
234 + "example.com",
235 + "example.net",
236 + "localhost.localdomain",
237 + "your.tld",
238 + "your-domain.tld",
239 + "domain.tld",
240 + "change-me.tld",
241 +];
242 +
243 +fn is_placeholder_domain(raw: &str) -> bool {
244 + let lowered = raw.to_ascii_lowercase();
245 + let stripped = lowered
246 + .trim_start_matches("http://")
247 + .trim_start_matches("https://")
248 + .trim_end_matches('/');
249 + PLACEHOLDER_DOMAINS.contains(&stripped)
250 +}
251 +
252 +fn read_bool_env(key: &'static str, default: bool) -> Result<bool, FediverseConfigError> {
253 + match std::env::var(key) {
254 + Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
255 + "" => Ok(default),
256 + "true" | "1" | "yes" | "on" => Ok(true),
257 + "false" | "0" | "no" | "off" => Ok(false),
258 + other => Err(FediverseConfigError::InvalidNumeric {
259 + name: key,
260 + value: other.to_string(),
261 + reason: "expected boolean (true/false/1/0/yes/no/on/off)".into(),
262 + }),
263 + },
264 + Err(_) => Ok(default),
265 + }
266 +}
267 +
268 +fn read_numeric_env<T>(key: &'static str, default: T) -> Result<T, FediverseConfigError>
269 +where
270 + T: std::str::FromStr,
271 + T::Err: std::fmt::Display,
272 +{
273 + match std::env::var(key) {
274 + Ok(value) => {
275 + let trimmed = value.trim();
276 + if trimmed.is_empty() {
277 + return Ok(default);
278 + }
279 + trimmed
280 + .parse::<T>()
281 + .map_err(|e| FediverseConfigError::InvalidNumeric {
282 + name: key,
283 + value: trimmed.to_string(),
284 + reason: e.to_string(),
285 + })
286 + }
287 + Err(_) => Ok(default),
288 + }
289 +}
290 +
291 +#[cfg(test)]
292 +impl FediverseConfig {
293 + /// Build a deterministic config for unit tests, bypassing env-var loading.
294 + /// Always HTTPS (`https://{domain}/`), all numeric defaults applied,
295 + /// `debug_local_federation = false`.
296 + pub(crate) fn for_tests(domain: &str) -> Self {
297 + let base_url = Url::parse(&format!("https://{domain}/"))
298 + .expect("test domain must produce a valid URL");
299 + Self {
300 + domain: domain.to_string(),
301 + user_agent: format!("rustwarden/test (+{base_url})"),
302 + base_url,
303 + authorized_fetch: false,
304 + outbound_workers: 4,
305 + outbound_max_attempts: 8,
306 + outbound_request_timeout: Duration::from_secs(30),
307 + fetch_actor_cache_ttl: Duration::from_secs(86_400),
308 + auth_challenge_ttl: Duration::from_secs(300),
309 + debug_local_federation: false,
310 + }
311 + }
312 +}
313 +
314 +#[cfg(test)]
315 +mod tests {
316 + use super::*;
317 + use std::sync::Mutex;
318 +
319 + /// Serialises env-var mutations across tests in this module. Each test
320 + /// holds the lock for its full duration and clears the FEDIVERSE_* keyspace
321 + /// before setting fixtures, making tests order-independent.
322 + static ENV_LOCK: Mutex<()> = Mutex::new(());
323 +
324 + /// Reset every FEDIVERSE_* environment variable so the next test starts
325 + /// from a known empty state.
326 + fn clear_fediverse_env() {
327 + for key in [
328 + "FEDIVERSE_DOMAIN",
329 + "FEDIVERSE_AUTHORIZED_FETCH",
330 + "FEDIVERSE_OUTBOUND_WORKERS",
331 + "FEDIVERSE_OUTBOUND_MAX_ATTEMPTS",
332 + "FEDIVERSE_OUTBOUND_REQUEST_TIMEOUT_SECS",
333 + "FEDIVERSE_FETCH_ACTOR_CACHE_TTL_SECS",
334 + "FEDIVERSE_AUTH_CHALLENGE_TTL_SECS",
335 + "FEDIVERSE_DEBUG_LOCAL_FEDERATION",
336 + ] {
337 + // SAFETY: tests in this module are serialised through ENV_LOCK,
338 + // and the helper runs before any code that reads these vars in the
339 + // same test. No other thread observes the partial state.
340 + unsafe {
341 + std::env::remove_var(key);
342 + }
343 + }
344 + }
345 +
346 + /// Set an environment variable for the duration of the surrounding test.
347 + fn set_env(key: &str, value: &str) {
348 + // SAFETY: same rationale as `clear_fediverse_env` — serialised via
349 + // ENV_LOCK in the calling test.
350 + unsafe {
351 + std::env::set_var(key, value);
352 + }
353 + }
354 +
355 + // ─── Test 1 — Happy path ────────────────────────────────────────────
356 +
357 + #[test]
358 + fn from_env_returns_valid_config_with_required_var_only() {
359 + let _guard = ENV_LOCK.lock().expect("env lock poisoned");
360 + clear_fediverse_env();
361 + set_env("FEDIVERSE_DOMAIN", "example.org");
362 +
363 + let config = FediverseConfig::from_env().expect("valid config");
364 + assert_eq!(config.domain(), "example.org");
365 + assert_eq!(config.base_url().as_str(), "https://example.org/");
366 + assert!(!config.authorized_fetch());
367 + assert!(!config.debug_local_federation());
368 + }
369 +
370 + // ─── Test 2 — Missing domain ────────────────────────────────────────
371 +
372 + #[test]
373 + fn from_env_returns_missing_domain_when_var_unset() {
374 + let _guard = ENV_LOCK.lock().expect("env lock poisoned");
375 + clear_fediverse_env();
376 +
377 + let err = FediverseConfig::from_env().expect_err("must fail");
378 + assert!(
379 + matches!(err, FediverseConfigError::MissingDomain),
380 + "got {err:?}"
381 + );
382 + }
383 +
384 + // ─── Test 3 — Placeholder domains ───────────────────────────────────
385 +
386 + #[test]
387 + fn from_env_returns_placeholder_error_for_known_placeholders() {
388 + let _guard = ENV_LOCK.lock().expect("env lock poisoned");
389 + for placeholder in ["example.com", "localhost.localdomain", "your.tld"] {
390 + clear_fediverse_env();
391 + set_env("FEDIVERSE_DOMAIN", placeholder);
392 +
393 + let err = FediverseConfig::from_env().expect_err("must fail");
394 + assert!(
395 + matches!(err, FediverseConfigError::PlaceholderDomain(ref d) if d == placeholder),
396 + "domain={placeholder} got {err:?}"
397 + );
398 + }
399 + }
400 +
401 + // ─── Test 4 — Insecure base URL in production ───────────────────────
402 +
403 + #[test]
404 + fn from_env_rejects_http_base_url_in_production() {
405 + let _guard = ENV_LOCK.lock().expect("env lock poisoned");
406 + clear_fediverse_env();
407 + // Domain that yields http:// only in non-debug mode is conceptual;
408 + // we trigger the path by feeding a hostname that the parser maps
409 + // to HTTPS but with debug=false (default). To force HTTP, the
410 + // implementation interprets a leading `http://` scheme in the env
411 + // value as opt-in to plain HTTP — only valid under debug mode.
412 + set_env("FEDIVERSE_DOMAIN", "http://example.org");
413 +
414 + let err = FediverseConfig::from_env().expect_err("must fail");
415 + assert!(
416 + matches!(err, FediverseConfigError::InsecureBaseUrl(_)),
417 + "got {err:?}"
418 + );
419 + }
420 +
421 + // ─── Test 5 — Debug mode allows HTTP ────────────────────────────────
422 +
423 + #[test]
424 + fn from_env_allows_http_when_debug_local_federation() {
425 + let _guard = ENV_LOCK.lock().expect("env lock poisoned");
426 + clear_fediverse_env();
427 + set_env("FEDIVERSE_DOMAIN", "http://localhost:3000");
428 + set_env("FEDIVERSE_DEBUG_LOCAL_FEDERATION", "true");
429 +
430 + let config = FediverseConfig::from_env().expect("debug HTTP allowed");
431 + assert!(config.debug_local_federation());
432 + assert_eq!(config.base_url().scheme(), "http");
433 + assert_eq!(config.domain(), "localhost:3000");
434 + }
435 +
436 + // ─── Test 6 — Numeric var invalid ───────────────────────────────────
437 +
438 + #[test]
439 + fn from_env_returns_invalid_numeric_for_bad_workers_value() {
440 + let _guard = ENV_LOCK.lock().expect("env lock poisoned");
441 + clear_fediverse_env();
442 + set_env("FEDIVERSE_DOMAIN", "example.org");
443 + set_env("FEDIVERSE_OUTBOUND_WORKERS", "not-a-number");
444 +
445 + let err = FediverseConfig::from_env().expect_err("must fail");
446 + assert!(
447 + matches!(
448 + err,
449 + FediverseConfigError::InvalidNumeric { name, .. }
450 + if name == "FEDIVERSE_OUTBOUND_WORKERS"
451 + ),
452 + "got {err:?}"
453 + );
454 + }
455 +
456 + // ─── Test 7 — Defaults ──────────────────────────────────────────────
457 +
458 + #[test]
459 + fn defaults_match_documented_values() {
460 + let _guard = ENV_LOCK.lock().expect("env lock poisoned");
461 + clear_fediverse_env();
462 + set_env("FEDIVERSE_DOMAIN", "example.org");
463 +
464 + let config = FediverseConfig::from_env().expect("valid config");
465 + assert_eq!(config.outbound_workers(), 4);
466 + assert_eq!(config.outbound_max_attempts(), 8);
467 + assert_eq!(
468 + config.outbound_request_timeout(),
469 + Duration::from_secs(30),
470 + "outbound_request_timeout default"
471 + );
472 + assert_eq!(
473 + config.fetch_actor_cache_ttl(),
474 + Duration::from_secs(86_400),
475 + "fetch_actor_cache_ttl default (24 h)"
476 + );
477 + assert_eq!(
478 + config.auth_challenge_ttl(),
479 + Duration::from_secs(300),
480 + "auth_challenge_ttl default (5 min)"
481 + );
482 + assert!(!config.authorized_fetch());
483 + assert!(!config.debug_local_federation());
484 + assert!(
485 + config.user_agent().contains("rustwarden") || config.user_agent().contains("Rustwarden"),
486 + "user_agent={}",
487 + config.user_agent()
488 + );
489 + }
490 +}
M crates/rustwarden-core/src/config/mod.rs
+6 -0
@@ -11,9 +11,15 @@ pub mod auth;
11 11 pub mod database;
12 12 /// Email (SMTP) configuration.
13 13 pub mod email;
14 +/// ActivityPub federation configuration (RFC 0001).
15 +#[cfg(feature = "experimental-fediverse")]
16 +pub mod fediverse;
14 17 /// OAuth provider configuration.
15 18 #[cfg(feature = "oauth")]
16 19 pub mod oauth;
20 +
21 +#[cfg(feature = "experimental-fediverse")]
22 +pub use fediverse::{FediverseConfig, FediverseConfigError};
17 23 /// HTTP server configuration (host, port, TLS).
18 24 pub mod server;
19 25 /// Background task scheduler configuration.
A crates/rustwarden-core/src/fediverse/actor.rs
+310 -0
@@ -0,0 +1,310 @@
1 +//! Person actor handler with `Accept` content negotiation.
2 +//!
3 +//! Mounted at `GET /users/{username}`.
4 +
5 +use axum::{
6 + Extension, Json,
7 + extract::Path,
8 + http::{HeaderMap, HeaderValue, StatusCode, header},
9 + response::{IntoResponse, Response},
10 +};
11 +use url::Url;
12 +
13 +use super::error::FediverseError;
14 +use super::resolver::UserSummary;
15 +use super::state::FediverseState;
16 +use super::types::{EndpointsJson, PersonJson, PublicKeyJson, actor_context};
17 +
18 +/// `GET /users/{username}`.
19 +///
20 +/// Negotiates between ActivityPub JSON and HTML based on the `Accept` header:
21 +///
22 +/// | `Accept` value contains… | Response |
23 +/// |---|---|
24 +/// | `application/activity+json` | `200` JSON Person |
25 +/// | `application/ld+json` | `200` JSON Person |
26 +/// | `*/*` (e.g. `curl` default) | `200` JSON Person |
27 +/// | (no `Accept` header) | `200` JSON Person (Mastodon convention) |
28 +/// | only `text/html` (and UI not mounted at this layer) | `406 Not Acceptable` |
29 +pub async fn handle(
30 + Path(username): Path<String>,
31 + headers: HeaderMap,
32 + Extension(state): Extension<FediverseState>,
33 +) -> Result<Response, FediverseError> {
34 + if !prefers_activitypub(&headers) {
35 + return Err(FediverseError::NotAcceptable);
36 + }
37 +
38 + let user = state
39 + .resolver
40 + .find_local_user(&username)
41 + .await?
42 + .ok_or(FediverseError::UserNotFound)?;
43 +
44 + let person = build_person_json(&state, &user)?;
45 +
46 + Ok((
47 + StatusCode::OK,
48 + [(
49 + header::CONTENT_TYPE,
50 + HeaderValue::from_static("application/activity+json; charset=utf-8"),
51 + )],
52 + Json(person),
53 + )
54 + .into_response())
55 +}
56 +
57 +/// Inspect an `Accept` header and decide whether the caller will accept the
58 +/// ActivityPub JSON representation.
59 +///
60 +/// Per Mastodon convention, an absent `Accept` header is treated as a
61 +/// preference for ActivityPub (AP `id` URLs are the canonical resource).
62 +pub(crate) fn prefers_activitypub(headers: &HeaderMap) -> bool {
63 + let Some(value) = headers
64 + .get(header::ACCEPT)
65 + .and_then(|v| v.to_str().ok())
66 + else {
67 + return true;
68 + };
69 +
70 + let lower = value.to_ascii_lowercase();
71 + lower.contains("application/activity+json")
72 + || lower.contains("application/ld+json")
73 + || lower.contains("*/*")
74 +}
75 +
76 +/// Build a [`PersonJson`] for a local user, using base URLs derived from the
77 +/// federation config.
78 +pub(crate) fn build_person_json(
79 + state: &FediverseState,
80 + user: &UserSummary,
81 +) -> Result<PersonJson, FediverseError> {
82 + let base = state.config.base_url();
83 + let actor_url = url_join(base, &format!("users/{}", user.username))?;
84 + let inbox = url_join(base, &format!("users/{}/inbox", user.username))?;
85 + let outbox = url_join(base, &format!("users/{}/outbox", user.username))?;
86 + let followers = url_join(base, &format!("users/{}/followers", user.username))?;
87 + let following = url_join(base, &format!("users/{}/following", user.username))?;
88 + let profile = url_join(base, &format!("@{}", user.username))?;
89 + let shared_inbox = url_join(base, "inbox")?;
90 +
91 + let public_key = user.ap_public_key_pem.as_ref().map(|pem| PublicKeyJson {
92 + id: append_fragment(&actor_url, "main-key"),
93 + owner: actor_url.clone(),
94 + public_key_pem: pem.clone(),
95 + });
96 +
97 + Ok(PersonJson {
98 + context: actor_context(),
99 + kind: "Person".to_string(),
100 + id: actor_url,
101 + preferred_username: user.username.clone(),
102 + name: user.display_name.clone(),
103 + summary: user.bio.clone(),
104 + url: Some(profile),
105 + inbox,
106 + outbox,
107 + followers,
108 + following,
109 + endpoints: Some(EndpointsJson { shared_inbox }),
110 + public_key,
111 + manually_approves_followers: false,
112 + discoverable: true,
113 + })
114 +}
115 +
116 +fn url_join(base: &Url, path: &str) -> Result<Url, FediverseError> {
117 + base.join(path)
118 + .map_err(|e| FediverseError::App(crate::error::AppError::Internal(format!(
119 + "failed to build actor URL from {base} + {path}: {e}"
120 + ))))
121 +}
122 +
123 +fn append_fragment(url: &Url, fragment: &str) -> Url {
124 + let mut copy = url.clone();
125 + copy.set_fragment(Some(fragment));
126 + copy
127 +}
128 +
129 +#[cfg(test)]
130 +mod tests {
131 + use super::*;
132 + use crate::config::FediverseConfig;
133 + use crate::fediverse::resolver::{LocalUserResolver, UserSummary};
134 + use async_trait::async_trait;
135 + use axum::Router;
136 + use axum::body::Body;
137 + use axum::http::Request;
138 + use axum::routing::get;
139 + use std::sync::Arc;
140 + use tower::ServiceExt;
141 + use uuid::Uuid;
142 +
143 + struct StubResolver {
144 + user: Option<UserSummary>,
145 + }
146 +
147 + #[async_trait]
148 + impl LocalUserResolver for StubResolver {
149 + async fn find_local_user(
150 + &self,
151 + _username: &str,
152 + ) -> Result<Option<UserSummary>, crate::error::AppError> {
153 + Ok(self.user.clone())
154 + }
155 + async fn count_users_total(&self) -> Result<u64, crate::error::AppError> {
156 + Ok(0)
157 + }
158 + async fn count_users_active_since(&self, _days: u32) -> Result<u64, crate::error::AppError> {
159 + Ok(0)
160 + }
161 + async fn count_local_posts(&self) -> Result<u64, crate::error::AppError> {
162 + Ok(0)
163 + }
164 + }
165 +
166 + fn router_with_resolver(domain: &str, user: Option<UserSummary>) -> Router {
167 + let state = FediverseState {
168 + config: FediverseConfig::for_tests(domain),
169 + resolver: Arc::new(StubResolver { user }),
170 + };
171 + Router::new()
172 + .route("/users/{username}", get(handle))
173 + .layer(Extension(state))
174 + }
175 +
176 + fn alice() -> UserSummary {
177 + UserSummary {
178 + id: Uuid::new_v4(),
179 + username: "alice".into(),
180 + display_name: Some("Alice".into()),
181 + bio: Some("<p>Hello</p>".into()),
182 + ap_public_key_pem: None,
183 + }
184 + }
185 +
186 + async fn body_json(response: Response) -> serde_json::Value {
187 + let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024)
188 + .await
189 + .expect("body bytes");
190 + serde_json::from_slice(&bytes).expect("body is valid JSON")
191 + }
192 +
193 + #[tokio::test]
194 + async fn actor_returns_person_json_with_ap_accept() {
195 + let app = router_with_resolver("example.org", Some(alice()));
196 +
197 + let response = app
198 + .oneshot(
199 + Request::builder()
200 + .uri("/users/alice")
201 + .header(header::ACCEPT, "application/activity+json")
202 + .body(Body::empty())
203 + .unwrap(),
204 + )
205 + .await
206 + .unwrap();
207 +
208 + assert_eq!(response.status(), StatusCode::OK);
209 + let ct = response
210 + .headers()
211 + .get(header::CONTENT_TYPE)
212 + .and_then(|v| v.to_str().ok())
213 + .unwrap_or_default()
214 + .to_string();
215 + assert!(
216 + ct.contains("application/activity+json"),
217 + "content-type={ct}"
218 + );
219 +
220 + let body = body_json(response).await;
221 + assert_eq!(body["type"], "Person");
222 + assert_eq!(body["id"], "https://example.org/users/alice");
223 + assert_eq!(body["preferredUsername"], "alice");
224 + assert_eq!(body["inbox"], "https://example.org/users/alice/inbox");
225 + assert_eq!(body["outbox"], "https://example.org/users/alice/outbox");
226 + assert_eq!(body["followers"], "https://example.org/users/alice/followers");
227 + assert_eq!(body["following"], "https://example.org/users/alice/following");
228 + assert!(body.get("publicKey").is_none() || body["publicKey"].is_null());
229 + }
230 +
231 + #[tokio::test]
232 + async fn actor_returns_person_json_with_ld_json_accept() {
233 + let app = router_with_resolver("example.org", Some(alice()));
234 + let response = app
235 + .oneshot(
236 + Request::builder()
237 + .uri("/users/alice")
238 + .header(
239 + header::ACCEPT,
240 + "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"",
241 + )
242 + .body(Body::empty())
243 + .unwrap(),
244 + )
245 + .await
246 + .unwrap();
247 + assert_eq!(response.status(), StatusCode::OK);
248 + let body = body_json(response).await;
249 + assert_eq!(body["type"], "Person");
250 + }
251 +
252 + #[tokio::test]
253 + async fn actor_returns_404_for_unknown_user() {
254 + let app = router_with_resolver("example.org", None);
255 + let response = app
256 + .oneshot(
257 + Request::builder()
258 + .uri("/users/ghost")
259 + .header(header::ACCEPT, "application/activity+json")
260 + .body(Body::empty())
261 + .unwrap(),
262 + )
263 + .await
264 + .unwrap();
265 + assert_eq!(response.status(), StatusCode::NOT_FOUND);
266 + }
267 +
268 + #[tokio::test]
269 + async fn actor_returns_406_for_html_when_no_ui_handler() {
270 + let app = router_with_resolver("example.org", Some(alice()));
271 + let response = app
272 + .oneshot(
273 + Request::builder()
274 + .uri("/users/alice")
275 + .header(header::ACCEPT, "text/html")
276 + .body(Body::empty())
277 + .unwrap(),
278 + )
279 + .await
280 + .unwrap();
281 + assert_eq!(response.status(), StatusCode::NOT_ACCEPTABLE);
282 + }
283 +
284 + #[tokio::test]
285 + async fn actor_default_accept_returns_ap_json() {
286 + let app = router_with_resolver("example.org", Some(alice()));
287 + let response = app
288 + .oneshot(
289 + Request::builder()
290 + .uri("/users/alice")
291 + .body(Body::empty())
292 + .unwrap(),
293 + )
294 + .await
295 + .unwrap();
296 + assert_eq!(response.status(), StatusCode::OK);
297 + let body = body_json(response).await;
298 + assert_eq!(body["type"], "Person");
299 + }
300 +
301 + #[test]
302 + fn prefers_ap_for_explicit_accept() {
303 + let mut h = HeaderMap::new();
304 + h.insert(
305 + header::ACCEPT,
306 + HeaderValue::from_static("application/activity+json"),
307 + );
308 + assert!(prefers_activitypub(&h));
309 + }
310 +}
A crates/rustwarden-core/src/fediverse/auth_routes.rs
+536 -0
@@ -0,0 +1,536 @@
1 +//! HTTP routes for the AP-native authentication flow (RFC 0013).
2 +//!
3 +//! Five thin handlers around [`crate::services::fediverse_auth_service::FediverseAuthService`]:
4 +//!
5 +//! | Path | Verb | Caller side |
6 +//! |---|---|---|
7 +//! | `/api/v1/auth/login/fediverse/initiate` | POST | client (`b.tld`) |
8 +//! | `/api/v1/auth/login/fediverse/status/{id}` | GET | client |
9 +//! | `/api/v1/auth/login/fediverse/consume/{id}` | POST | client |
10 +//! | `/fediverse/auth/inbox` | POST | IdP (`a.tld`) |
11 +//! | `/api/v1/fediverse/auth/consent` | POST | IdP |
12 +//!
13 +//! Gated by `experimental-fediverse`.
14 +
15 +use std::sync::Arc;
16 +use std::time::Duration;
17 +
18 +use axum::{
19 + Extension, Json, Router,
20 + extract::Path,
21 + http::{HeaderMap, StatusCode, header},
22 + routing::{get, post},
23 +};
24 +use sea_orm::DatabaseConnection;
25 +use serde::{Deserialize, Serialize};
26 +use uuid::Uuid;
27 +
28 +use super::error::FediverseError;
29 +use crate::config::auth::AuthConfig;
30 +use crate::services::fediverse_auth_service::{
31 + ChallengeDecision, FediverseAuthService, InitiateRequest,
32 +};
33 +#[cfg(test)]
34 +use crate::services::fediverse_auth_service::ChallengeStatus;
35 +use crate::services::fediverse_signal_service::SignatureVerifier;
36 +
37 +/// Per-router state injected into the auth-flow handlers.
38 +#[derive(Clone)]
39 +pub struct AuthFlowState {
40 + /// Same `AuthConfig` as the local login flow — used to mint the
41 + /// remote JWT on `consume`.
42 + pub auth_config: Arc<AuthConfig>,
43 + /// Pluggable signature verifier — used by `auth/inbox` to gate
44 + /// inbound `AuthRequest` activities.
45 + pub verifier: Arc<dyn SignatureVerifier>,
46 + /// TTL of every freshly-initiated challenge.
47 + pub challenge_ttl: Duration,
48 +}
49 +
50 +/// `POST /api/v1/auth/login/fediverse/initiate` body.
51 +#[derive(Debug, Deserialize)]
52 +pub struct InitiateBody {
53 + /// `@user@domain` handle the user typed.
54 + pub handle: String,
55 + /// Optional URL to redirect to after successful consume.
56 + pub redirect_url: Option<String>,
57 +}
58 +
59 +/// `POST /api/v1/auth/login/fediverse/initiate` 201 response.
60 +#[derive(Debug, Serialize)]
61 +pub struct InitiateResponse {
62 + /// Stable challenge identifier.
63 + pub challenge_id: Uuid,
64 + /// Cryptographic nonce embedded in the AP `AuthRequest` activity.
65 + pub nonce: String,
66 + /// RFC3339 expiry timestamp.
67 + pub expires_at: String,
68 +}
69 +
70 +/// `GET /api/v1/auth/login/fediverse/status/{id}` 200 response.
71 +#[derive(Debug, Serialize)]
72 +pub struct StatusResponse {
73 + /// Same identifier as the request.
74 + pub challenge_id: Uuid,
75 + /// Wire form (`pending`, `approved`, `rejected`, `expired`, `consumed`).
76 + pub status: String,
77 +}
78 +
79 +/// `POST /api/v1/auth/login/fediverse/consume/{id}` 200 response.
80 +#[derive(Debug, Serialize)]
81 +pub struct ConsumeResponse {
82 + /// Signed remote JWT (RFC 0007 `IdentityKind::Remote`).
83 + pub jwt: String,
84 + /// FK of the federated identity the JWT speaks for.
85 + pub remote_identity_id: Uuid,
86 + /// `@user@domain` handle for log/audit cohesion.
87 + pub handle: String,
88 +}
89 +
90 +/// `POST /api/v1/fediverse/auth/consent` body.
91 +#[derive(Debug, Deserialize)]
92 +pub struct ConsentBody {
93 + /// Challenge being decided on.
94 + pub challenge_id: Uuid,
95 + /// `"approve"` or `"reject"`.
96 + pub decision: String,
97 + /// On approve only: which `remote_identities.id` is bound to the
98 + /// IdP's user that clicked approve. The handler trusts this value
99 + /// because the request itself is authenticated as the IdP user
100 + /// (CSRF-protected by the consumer middleware).
101 + pub remote_identity_id: Option<Uuid>,
102 +}
103 +
104 +// ─── Handlers ────────────────────────────────────────────────────────
105 +
106 +async fn initiate(
107 + Extension(state): Extension<AuthFlowState>,
108 + Extension(db): Extension<DatabaseConnection>,
109 + Json(body): Json<InitiateBody>,
110 +) -> Result<(StatusCode, Json<InitiateResponse>), FediverseError> {
111 + let challenge = FediverseAuthService::initiate(
112 + &db,
113 + InitiateRequest {
114 + handle: body.handle,
115 + redirect_url: body.redirect_url,
116 + },
117 + state.challenge_ttl,
118 + )
119 + .await?;
120 + Ok((
121 + StatusCode::CREATED,
122 + Json(InitiateResponse {
123 + challenge_id: challenge.id,
124 + nonce: challenge.nonce,
125 + expires_at: challenge.expires_at.to_rfc3339(),
126 + }),
127 + ))
128 +}
129 +
130 +async fn status(
131 + Extension(_state): Extension<AuthFlowState>,
132 + Extension(db): Extension<DatabaseConnection>,
133 + Path(challenge_id): Path<Uuid>,
134 +) -> Result<Json<StatusResponse>, FediverseError> {
135 + let model = FediverseAuthService::status(&db, challenge_id).await?;
136 + Ok(Json(StatusResponse {
137 + challenge_id: model.id,
138 + status: model.status,
139 + }))
140 +}
141 +
142 +async fn consume(
143 + Extension(state): Extension<AuthFlowState>,
144 + Extension(db): Extension<DatabaseConnection>,
145 + Path(challenge_id): Path<Uuid>,
146 +) -> Result<Json<ConsumeResponse>, FediverseError> {
147 + let session =
148 + FediverseAuthService::consume(&db, &state.auth_config, challenge_id).await?;
149 + Ok(Json(ConsumeResponse {
150 + jwt: session.jwt,
151 + remote_identity_id: session.remote_identity_id,
152 + handle: session.handle,
153 + }))
154 +}
155 +
156 +async fn auth_inbox(
157 + Extension(state): Extension<AuthFlowState>,
158 + Extension(_db): Extension<DatabaseConnection>,
159 + headers: HeaderMap,
160 + body: axum::body::Bytes,
161 +) -> Result<StatusCode, FediverseError> {
162 + let signature = headers
163 + .get(header::HeaderName::from_static("signature"))
164 + .and_then(|v| v.to_str().ok())
165 + .ok_or_else(|| FediverseError::Unauthorized("missing Signature header".into()))?;
166 +
167 + // Best-effort actor extraction. The current AP `AuthRequest` body has
168 + // an `actor` URL string at the top level.
169 + let actor = serde_json::from_slice::<serde_json::Value>(&body)
170 + .ok()
171 + .and_then(|v| v.get("actor").and_then(|a| a.as_str()).map(str::to_string))
172 + .unwrap_or_default();
173 +
174 + state
175 + .verifier
176 + .verify(&actor, signature, &body, "")
177 + .await
178 + .map_err(|e| FediverseError::Unauthorized(e.to_string()))?;
179 +
180 + // Persisting the inbound challenge to `fediverse_auth_challenges`
181 + // happens once the IdP-side service exposes a dedicated method
182 + // (currently `initiate` is client-side only — handled through the
183 + // `consent` endpoint by the IdP user). Acknowledging acceptance is
184 + // sufficient for now; the IdP UI / API is responsible for prompting
185 + // the user.
186 + Ok(StatusCode::ACCEPTED)
187 +}
188 +
189 +async fn consent(
190 + Extension(_state): Extension<AuthFlowState>,
191 + Extension(db): Extension<DatabaseConnection>,
192 + Json(body): Json<ConsentBody>,
193 +) -> Result<StatusCode, FediverseError> {
194 + let decision = match body.decision.as_str() {
195 + "approve" => {
196 + let remote = body.remote_identity_id.ok_or_else(|| {
197 + FediverseError::BadRequest(
198 + "approve requires remote_identity_id".into(),
199 + )
200 + })?;
201 + ChallengeDecision::Approve {
202 + remote_identity_id: remote,
203 + }
204 + }
205 + "reject" => ChallengeDecision::Reject,
206 + other => {
207 + return Err(FediverseError::BadRequest(format!(
208 + "invalid decision '{other}'",
209 + )));
210 + }
211 + };
212 + FediverseAuthService::record_decision(&db, body.challenge_id, decision).await?;
213 + Ok(StatusCode::OK)
214 +}
215 +
216 +/// Build the auth-flow router. Mounted by
217 +/// [`crate::fediverse::routes::router_with`] alongside the discovery
218 +/// + lifecycle inbox routes.
219 +pub fn router(state: AuthFlowState) -> Router<DatabaseConnection> {
220 + Router::new()
221 + .route("/api/v1/auth/login/fediverse/initiate", post(initiate))
222 + .route(
223 + "/api/v1/auth/login/fediverse/status/{challenge_id}",
224 + get(status),
225 + )
226 + .route(
227 + "/api/v1/auth/login/fediverse/consume/{challenge_id}",
228 + post(consume),
229 + )
230 + .route("/fediverse/auth/inbox", post(auth_inbox))
231 + .route("/api/v1/fediverse/auth/consent", post(consent))
232 + .layer(Extension(state))
233 +}
234 +
235 +#[cfg(test)]
236 +mod tests {
237 + use super::*;
238 + use crate::error::AppError;
239 + use crate::models::fediverse_auth_challenge;
240 + use async_trait::async_trait;
241 + use axum::body::Body;
242 + use axum::http::Request;
243 + use chrono::{Duration as ChronoDuration, Utc};
244 + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};
245 + use tower::ServiceExt;
246 +
247 + fn test_auth_config() -> AuthConfig {
248 + AuthConfig {
249 + jwt_secret: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
250 + .to_string(),
251 + jwt_expiration_minutes: 15,
252 + jwt_issuer: "rustwarden-test".to_string(),
253 + refresh_token_expiration_days: 7,
254 + remember_me_expiration_days: 30,
255 + session_timeout_minutes: 30,
256 + session_backend: crate::config::auth::SessionBackend::Memory,
257 + redis_url: None,
258 + rate_limit_login_per_minute: 5,
259 + rate_limit_refresh_per_minute: 10,
260 + rate_limit_general_per_minute: 100,
261 + cookie_secure: false,
262 + cookie_same_site: "Strict".to_string(),
263 + }
264 + }
265 +
266 + struct AlwaysAccept;
267 +
268 + #[async_trait]
269 + impl SignatureVerifier for AlwaysAccept {
270 + async fn verify(
271 + &self,
272 + _: &str,
273 + _: &str,
274 + _: &[u8],
275 + _: &str,
276 + ) -> Result<(), AppError> {
277 + Ok(())
278 + }
279 + }
280 +
281 + fn build_router(db: DatabaseConnection) -> Router {
282 + let state = AuthFlowState {
283 + auth_config: Arc::new(test_auth_config()),
284 + verifier: Arc::new(AlwaysAccept),
285 + challenge_ttl: Duration::from_secs(300),
286 + };
287 + router(state)
288 + .layer(Extension(db))
289 + .with_state(MockDatabase::new(DatabaseBackend::Postgres).into_connection())
290 + }
291 +
292 + fn fresh_challenge(id: Uuid) -> fediverse_auth_challenge::Model {
293 + let now = Utc::now();
294 + fediverse_auth_challenge::Model {
295 + id,
296 + nonce: "nonce-xxx".into(),
297 + handle: "alice@a.tld".into(),
298 + domain: "a.tld".into(),
299 + requested_remote_id: None,
300 + status: ChallengeStatus::Pending.as_str().into(),
301 + redirect_url: None,
302 + created_at: now,
303 + expires_at: now + ChronoDuration::minutes(5),
304 + consumed_at: None,
305 + }
306 + }
307 +
308 + fn req_post(uri: &str, body: &str) -> Request<Body> {
309 + Request::builder()
310 + .uri(uri)
311 + .method("POST")
312 + .header("content-type", "application/json")
313 + .body(Body::from(body.to_string()))
314 + .unwrap()
315 + }
316 +
317 + fn req_get(uri: &str) -> Request<Body> {
318 + Request::builder()
319 + .uri(uri)
320 + .method("GET")
321 + .body(Body::empty())
322 + .unwrap()
323 + }
324 +
325 + // ─── initiate ────────────────────────────────────────────────────
326 +
327 + #[tokio::test]
328 + async fn initiate_returns_201_with_challenge_id() {
329 + let row = fresh_challenge(Uuid::new_v4());
330 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
331 + .append_query_results([vec![row]])
332 + .into_connection();
333 + let body = serde_json::json!({
334 + "handle": "alice@a.tld",
335 + })
336 + .to_string();
337 + let resp = build_router(db)
338 + .oneshot(req_post("/api/v1/auth/login/fediverse/initiate", &body))
339 + .await
340 + .unwrap();
341 + assert_eq!(resp.status(), StatusCode::CREATED);
342 + }
343 +
344 + #[tokio::test]
345 + async fn initiate_returns_400_for_invalid_handle() {
346 + let db: DatabaseConnection =
347 + MockDatabase::new(DatabaseBackend::Postgres).into_connection();
348 + let body = serde_json::json!({
349 + "handle": "no-at-sign",
350 + })
351 + .to_string();
352 + let resp = build_router(db)
353 + .oneshot(req_post("/api/v1/auth/login/fediverse/initiate", &body))
354 + .await
355 + .unwrap();
356 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
357 + }
358 +
359 + // ─── status ──────────────────────────────────────────────────────
360 +
361 + #[tokio::test]
362 + async fn status_returns_pending_for_fresh_challenge() {
363 + let id = Uuid::new_v4();
364 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
365 + .append_query_results([vec![fresh_challenge(id)]])
366 + .into_connection();
367 + let resp = build_router(db)
368 + .oneshot(req_get(&format!(
369 + "/api/v1/auth/login/fediverse/status/{id}"
370 + )))
371 + .await
372 + .unwrap();
373 + assert_eq!(resp.status(), StatusCode::OK);
374 + }
375 +
376 + #[tokio::test]
377 + async fn status_returns_404_for_unknown_id() {
378 + let id = Uuid::new_v4();
379 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
380 + .append_query_results([Vec::<fediverse_auth_challenge::Model>::new()])
381 + .into_connection();
382 + let resp = build_router(db)
383 + .oneshot(req_get(&format!(
384 + "/api/v1/auth/login/fediverse/status/{id}"
385 + )))
386 + .await
387 + .unwrap();
388 + assert_eq!(resp.status(), StatusCode::NOT_FOUND);
389 + }
390 +
391 + // ─── consume ─────────────────────────────────────────────────────
392 +
393 + #[tokio::test]
394 + async fn consume_returns_jwt_for_approved_challenge() {
395 + let id = Uuid::new_v4();
396 + let remote_id = Uuid::new_v4();
397 + let mut row = fresh_challenge(id);
398 + row.status = ChallengeStatus::Approved.as_str().into();
399 + row.requested_remote_id = Some(remote_id);
400 + let mut after = row.clone();
401 + after.status = ChallengeStatus::Consumed.as_str().into();
402 + after.consumed_at = Some(Utc::now());
403 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
404 + .append_query_results([vec![row]])
405 + .append_query_results([vec![after]])
406 + .append_exec_results([MockExecResult {
407 + last_insert_id: 0,
408 + rows_affected: 1,
409 + }])
410 + .into_connection();
411 + let resp = build_router(db)
412 + .oneshot(req_post(
413 + &format!("/api/v1/auth/login/fediverse/consume/{id}"),
414 + "",
415 + ))
416 + .await
417 + .unwrap();
418 + assert_eq!(resp.status(), StatusCode::OK);
419 + }
420 +
421 + #[tokio::test]
422 + async fn consume_returns_400_when_pending() {
423 + let id = Uuid::new_v4();
424 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
425 + .append_query_results([vec![fresh_challenge(id)]])
426 + .into_connection();
427 + let resp = build_router(db)
428 + .oneshot(req_post(
429 + &format!("/api/v1/auth/login/fediverse/consume/{id}"),
430 + "",
431 + ))
432 + .await
433 + .unwrap();
434 + // Validation error from the service maps to 400 via AppError.
435 + assert!(
436 + resp.status() == StatusCode::BAD_REQUEST
437 + || resp.status() == StatusCode::UNPROCESSABLE_ENTITY,
438 + "expected 400/422, got {}",
439 + resp.status()
440 + );
441 + }
442 +
443 + // ─── auth_inbox ──────────────────────────────────────────────────
444 +
445 + #[tokio::test]
446 + async fn auth_inbox_returns_202_for_signed_authrequest() {
447 + let body = serde_json::json!({
448 + "type": "AuthRequest",
449 + "actor": "https://b.tld",
450 + "target": "alice@a.tld",
451 + "nonce": "n",
452 + })
453 + .to_string();
454 + let req = Request::builder()
455 + .uri("/fediverse/auth/inbox")
456 + .method("POST")
457 + .header("signature", "keyId=...")
458 + .header("content-type", "application/activity+json")
459 + .body(Body::from(body))
460 + .unwrap();
461 + let db: DatabaseConnection =
462 + MockDatabase::new(DatabaseBackend::Postgres).into_connection();
463 + let resp = build_router(db).oneshot(req).await.unwrap();
464 + assert_eq!(resp.status(), StatusCode::ACCEPTED);
465 + }
466 +
467 + #[tokio::test]
468 + async fn auth_inbox_returns_401_when_signature_missing() {
469 + let req = Request::builder()
470 + .uri("/fediverse/auth/inbox")
471 + .method("POST")
472 + .body(Body::from("{}"))
473 + .unwrap();
474 + let db: DatabaseConnection =
475 + MockDatabase::new(DatabaseBackend::Postgres).into_connection();
476 + let resp = build_router(db).oneshot(req).await.unwrap();
477 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
478 + }
479 +
480 + // ─── consent ─────────────────────────────────────────────────────
481 +
482 + #[tokio::test]
483 + async fn consent_approve_marks_challenge_approved() {
484 + let id = Uuid::new_v4();
485 + let remote_id = Uuid::new_v4();
486 + let row = fresh_challenge(id);
487 + let mut after = row.clone();
488 + after.status = ChallengeStatus::Approved.as_str().into();
489 + after.requested_remote_id = Some(remote_id);
490 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
491 + .append_query_results([vec![row]])
492 + .append_query_results([vec![after]])
493 + .append_exec_results([MockExecResult {
494 + last_insert_id: 0,
495 + rows_affected: 1,
496 + }])
497 + .into_connection();
498 + let body = serde_json::json!({
499 + "challenge_id": id,
500 + "decision": "approve",
501 + "remote_identity_id": remote_id,
502 + })
503 + .to_string();
504 + let resp = build_router(db)
505 + .oneshot(req_post("/api/v1/fediverse/auth/consent", &body))
506 + .await
507 + .unwrap();
508 + assert_eq!(resp.status(), StatusCode::OK);
509 + }
510 +
511 + #[tokio::test]
512 + async fn consent_reject_marks_challenge_rejected() {
513 + let id = Uuid::new_v4();
514 + let row = fresh_challenge(id);
515 + let mut after = row.clone();
516 + after.status = ChallengeStatus::Rejected.as_str().into();
517 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
518 + .append_query_results([vec![row]])
519 + .append_query_results([vec![after]])
520 + .append_exec_results([MockExecResult {
521 + last_insert_id: 0,
522 + rows_affected: 1,
523 + }])
524 + .into_connection();
525 + let body = serde_json::json!({
526 + "challenge_id": id,
527 + "decision": "reject",
528 + })
529 + .to_string();
530 + let resp = build_router(db)
531 + .oneshot(req_post("/api/v1/fediverse/auth/consent", &body))
532 + .await
533 + .unwrap();
534 + assert_eq!(resp.status(), StatusCode::OK);
535 + }
536 +}
A crates/rustwarden-core/src/fediverse/error.rs
+67 -0
@@ -0,0 +1,67 @@
1 +//! Federation-specific error type.
2 +
3 +use axum::http::StatusCode;
4 +use axum::response::{IntoResponse, Response};
5 +
6 +use crate::error::AppError;
7 +
8 +/// Errors produced by federation handlers (WebFinger / Actor / NodeInfo).
9 +///
10 +/// Mapped to HTTP status codes via [`IntoResponse`]:
11 +///
12 +/// | Variant | HTTP status |
13 +/// |---|---|
14 +/// | `UserNotFound` | 404 |
15 +/// | `DomainMismatch` | 404 |
16 +/// | `InvalidResource` | 400 |
17 +/// | `NotAcceptable` | 406 |
18 +/// | `App(_)` | inherited from [`AppError`] |
19 +#[derive(Debug, thiserror::Error)]
20 +pub enum FediverseError {
21 + /// The requested local user does not exist or is disabled.
22 + #[error("user not found")]
23 + UserNotFound,
24 +
25 + /// The WebFinger `resource` parameter is malformed.
26 + #[error("invalid WebFinger resource: {0}")]
27 + InvalidResource(String),
28 +
29 + /// The WebFinger `resource` targets a domain that is not ours.
30 + #[error("WebFinger domain mismatch")]
31 + DomainMismatch,
32 +
33 + /// No representation exists matching the request `Accept` header.
34 + #[error("acceptable representation not available")]
35 + NotAcceptable,
36 +
37 + /// RFC 0012: signature missing or rejected by the verifier.
38 + #[error("unauthorized: {0}")]
39 + Unauthorized(String),
40 +
41 + /// RFC 0012: request body is invalid (bad JSON or missing required fields).
42 + #[error("bad request: {0}")]
43 + BadRequest(String),
44 +
45 + /// RFC 0012: activity type is not implemented by this server.
46 + #[error("activity type not implemented: {0}")]
47 + Unsupported(String),
48 +
49 + /// Wraps a generic application error from [`AppError`].
50 + #[error(transparent)]
51 + App(#[from] AppError),
52 +}
53 +
54 +impl IntoResponse for FediverseError {
55 + fn into_response(self) -> Response {
56 + match self {
57 + Self::UserNotFound | Self::DomainMismatch => StatusCode::NOT_FOUND.into_response(),
58 + Self::InvalidResource(_) | Self::BadRequest(_) => {
59 + StatusCode::BAD_REQUEST.into_response()
60 + }
61 + Self::NotAcceptable => StatusCode::NOT_ACCEPTABLE.into_response(),
62 + Self::Unauthorized(_) => StatusCode::UNAUTHORIZED.into_response(),
63 + Self::Unsupported(_) => StatusCode::NOT_IMPLEMENTED.into_response(),
64 + Self::App(err) => err.into_response(),
65 + }
66 + }
67 +}
A crates/rustwarden-core/src/fediverse/fetcher.rs
+64 -0
@@ -0,0 +1,64 @@
1 +//! HTTP client abstraction used by [`RemoteIdentityService`](crate::services::remote_identity_service::RemoteIdentityService).
2 +//!
3 +//! The trait [`RemoteFetcher`] decouples the orchestration logic from the
4 +//! actual reqwest / signed-HTTP implementation, which keeps unit tests
5 +//! deterministic and offline.
6 +//!
7 +//! The real signed-HTTP implementation arrives with RFC 0010 (authorized
8 +//! fetch + outbound worker). For now this module ships only the trait and
9 +//! a no-op implementation that always errors, so consumers cannot
10 +//! accidentally rely on a non-functional default.
11 +
12 +use async_trait::async_trait;
13 +use url::Url;
14 +
15 +use super::error::FediverseError;
16 +use super::types::{PersonJson, WebFingerResponse};
17 +
18 +/// Abstraction over the HTTP I/O performed when resolving remote actors.
19 +///
20 +/// Implementors are expected to handle TLS, redirects, and (eventually)
21 +/// HTTP signatures. They MUST validate the `Content-Type` of WebFinger
22 +/// (`application/jrd+json`) and actor responses (`application/activity+json`
23 +/// or `application/ld+json`) before deserialising.
24 +#[async_trait]
25 +pub trait RemoteFetcher: Send + Sync {
26 + /// Resolve a `@user@domain` handle via WebFinger.
27 + ///
28 + /// Returns [`FediverseError::UserNotFound`] if the remote server
29 + /// responded with `404`. Returns [`FediverseError::App`] for any other
30 + /// transport or deserialisation error.
31 + async fn webfinger(&self, handle: &str) -> Result<WebFingerResponse, FediverseError>;
32 +
33 + /// Fetch the JSON Person document at the given actor URL.
34 + ///
35 + /// Returns [`FediverseError::UserNotFound`] for `404`,
36 + /// [`FediverseError::App`] for any other failure.
37 + async fn fetch_actor(&self, actor_url: &Url) -> Result<PersonJson, FediverseError>;
38 +}
39 +
40 +/// Placeholder [`RemoteFetcher`] used until RFC 0010 ships the real
41 +/// signed-HTTP implementation. Both methods always return
42 +/// [`FediverseError::App`] with a configuration-style error.
43 +///
44 +/// Useful as the default when consumers wire their own fetcher (e.g.
45 +/// in tests, or in a deployment that has not yet enabled outbound HTTP).
46 +#[derive(Debug, Default, Clone)]
47 +pub struct UnconfiguredFetcher;
48 +
49 +#[async_trait]
50 +impl RemoteFetcher for UnconfiguredFetcher {
51 + async fn webfinger(&self, _handle: &str) -> Result<WebFingerResponse, FediverseError> {
52 + Err(FediverseError::App(crate::error::AppError::Config(
53 + "RemoteFetcher not configured — wire a real implementation via FediverseState"
54 + .to_string(),
55 + )))
56 + }
57 +
58 + async fn fetch_actor(&self, _actor_url: &Url) -> Result<PersonJson, FediverseError> {
59 + Err(FediverseError::App(crate::error::AppError::Config(
60 + "RemoteFetcher not configured — wire a real implementation via FediverseState"
61 + .to_string(),
62 + )))
63 + }
64 +}
A crates/rustwarden-core/src/fediverse/http_signatures.rs
+325 -0
@@ -0,0 +1,325 @@
1 +//! Mastodon-compatible HTTP signatures (draft-cavage-12) (RFC 0015).
2 +//!
3 +//! This module ships the production-ready cryptographic primitives
4 +//! that plug into the [`SignatureVerifier`] / [`OutboundDelivery`]
5 +//! traits. The current `SignatureVerifier` trait signature only carries
6 +//! `signature_header` + `body` + `public_key_pem`; real HTTP-Sig
7 +//! verification also needs the request method, path, and `host` /
8 +//! `date` headers. Until a follow-up RFC widens the trait, callers
9 +//! invoke [`parse_and_verify_cavage`] directly with the full request
10 +//! context.
11 +//!
12 +//! The signing side is integrated end-to-end via [`HttpSignedDelivery`].
13 +//!
14 +//! Gated by `experimental-fediverse`.
15 +
16 +use std::collections::BTreeMap;
17 +
18 +use base64::Engine;
19 +use rsa::pkcs1v15::{Signature, SigningKey, VerifyingKey};
20 +use rsa::pkcs8::{DecodePrivateKey, DecodePublicKey};
21 +use rsa::signature::{SignatureEncoding, Signer, Verifier};
22 +use rsa::{RsaPrivateKey, RsaPublicKey};
23 +use sha2::Sha256;
24 +
25 +use crate::error::AppError;
26 +
27 +/// Canonical fields extracted from a draft-cavage `Signature` header.
28 +#[derive(Debug, Clone, PartialEq, Eq)]
29 +pub struct ParsedSignature {
30 + /// Stable identifier of the signing key (e.g.
31 + /// `https://a.tld/users/alice#main-key`).
32 + pub key_id: String,
33 + /// Algorithm. The verifier only accepts `rsa-sha256` for now.
34 + pub algorithm: String,
35 + /// Ordered list of signed headers (lowercased).
36 + pub headers: Vec<String>,
37 + /// Decoded signature bytes (raw, **not** base64-encoded).
38 + pub signature: Vec<u8>,
39 +}
40 +
41 +/// Parse a `Signature: keyId="…",algorithm="…",headers="…",signature="…"`
42 +/// header into a [`ParsedSignature`].
43 +///
44 +/// Returns `Err(AppError::Validation(_))` when a required field is
45 +/// missing or the signature is not valid base64.
46 +pub fn parse_signature_header(raw: &str) -> Result<ParsedSignature, AppError> {
47 + let mut map: BTreeMap<&str, String> = BTreeMap::new();
48 + for part in raw.split(',') {
49 + let part = part.trim();
50 + let Some(eq) = part.find('=') else { continue };
51 + let (k, v) = part.split_at(eq);
52 + let v = &v[1..]; // skip '='
53 + let v = v.trim();
54 + let unquoted = v.trim_start_matches('"').trim_end_matches('"');
55 + map.insert(k.trim(), unquoted.to_string());
56 + }
57 +
58 + let key_id = map
59 + .remove("keyId")
60 + .ok_or_else(|| AppError::Validation("Signature missing keyId".into()))?;
61 + let algorithm = map
62 + .remove("algorithm")
63 + .ok_or_else(|| AppError::Validation("Signature missing algorithm".into()))?;
64 + let headers_raw = map
65 + .remove("headers")
66 + .ok_or_else(|| AppError::Validation("Signature missing headers".into()))?;
67 + let signature_b64 = map
68 + .remove("signature")
69 + .ok_or_else(|| AppError::Validation("Signature missing signature".into()))?;
70 +
71 + let headers = headers_raw
72 + .split_whitespace()
73 + .map(|h| h.to_ascii_lowercase())
74 + .collect();
75 +
76 + let signature = base64::engine::general_purpose::STANDARD
77 + .decode(&signature_b64)
78 + .map_err(|e| {
79 + AppError::Validation(format!("Signature base64 decode failed: {e}"))
80 + })?;
81 +
82 + Ok(ParsedSignature {
83 + key_id,
84 + algorithm,
85 + headers,
86 + signature,
87 + })
88 +}
89 +
90 +/// Build the **signature input string** that draft-cavage signs / verifies.
91 +///
92 +/// `signed_headers` is the ordered list from the `Signature: headers="…"`
93 +/// field. `request_target` is the lowercased HTTP method + space +
94 +/// path-and-query (e.g. `"post /inbox"`). `lookup` returns the canonical
95 +/// header value when called with a lowercased header name.
96 +pub fn build_signature_input(
97 + signed_headers: &[String],
98 + request_target: &str,
99 + lookup: impl Fn(&str) -> Option<String>,
100 +) -> Result<String, AppError> {
101 + let mut lines = Vec::with_capacity(signed_headers.len());
102 + for header in signed_headers {
103 + let line = if header == "(request-target)" {
104 + format!("(request-target): {request_target}")
105 + } else {
106 + let value = lookup(header).ok_or_else(|| {
107 + AppError::Validation(format!(
108 + "Signature header references missing field `{header}`",
109 + ))
110 + })?;
111 + format!("{header}: {}", value.trim())
112 + };
113 + lines.push(line);
114 + }
115 + Ok(lines.join("\n"))
116 +}
117 +
118 +/// Sign an arbitrary byte slice with an RSA-SHA256 PKCS#1 v1.5
119 +/// signature, returning the raw signature bytes.
120 +pub fn rsa_sha256_sign(private_pem: &str, input: &[u8]) -> Result<Vec<u8>, AppError> {
121 + let private_key = RsaPrivateKey::from_pkcs8_pem(private_pem)
122 + .map_err(|e| AppError::Internal(format!("invalid PKCS#8 PEM: {e}")))?;
123 + let signing_key = SigningKey::<Sha256>::new(private_key);
124 + let signature = signing_key.sign(input);
125 + Ok(signature.to_bytes().to_vec())
126 +}
127 +
128 +/// Verify an RSA-SHA256 PKCS#1 v1.5 signature against `input`.
129 +pub fn rsa_sha256_verify(
130 + public_pem: &str,
131 + input: &[u8],
132 + signature: &[u8],
133 +) -> Result<(), AppError> {
134 + let public_key = RsaPublicKey::from_public_key_pem(public_pem)
135 + .map_err(|e| AppError::Internal(format!("invalid SPKI PEM: {e}")))?;
136 + let verifying_key = VerifyingKey::<Sha256>::new(public_key);
137 + let sig = Signature::try_from(signature)
138 + .map_err(|e| AppError::Internal(format!("signature shape: {e}")))?;
139 + verifying_key
140 + .verify(input, &sig)
141 + .map_err(|_| AppError::Internal("HTTP signature mismatch".into()))?;
142 + Ok(())
143 +}
144 +
145 +/// Higher-level helper: parse + verify a draft-cavage Signature header
146 +/// against the given request context. Use this in inbox handlers that
147 +/// have access to the method, path, headers and body.
148 +pub fn parse_and_verify_cavage(
149 + signature_header: &str,
150 + request_target: &str,
151 + body: &[u8],
152 + public_pem: &str,
153 + headers: impl Fn(&str) -> Option<String>,
154 +) -> Result<(), AppError> {
155 + let parsed = parse_signature_header(signature_header)?;
156 + if parsed.algorithm != "rsa-sha256" {
157 + return Err(AppError::Validation(format!(
158 + "unsupported algorithm: {}",
159 + parsed.algorithm,
160 + )));
161 + }
162 + let input = build_signature_input(&parsed.headers, request_target, headers)?;
163 + let _ = body; // body is hashed via the `digest` header by Mastodon when present.
164 + rsa_sha256_verify(public_pem, input.as_bytes(), &parsed.signature)
165 +}
166 +
167 +#[cfg(test)]
168 +mod tests {
169 + use super::*;
170 + use crate::services::actor_key_service::ActorKeyService;
171 +
172 + fn fresh_keypair() -> (String, String) {
173 + // Generate once per test for honest end-to-end coverage.
174 + let material = ActorKeyService::generate().expect("RSA gen");
175 + (material.public_pem, material.private_pem)
176 + }
177 +
178 + fn b64(bytes: &[u8]) -> String {
179 + base64::engine::general_purpose::STANDARD.encode(bytes)
180 + }
181 +
182 + #[test]
183 + fn parse_signature_header_extracts_keyid_algorithm_headers_signature() {
184 + let raw = r#"keyId="https://a.tld/users/alice#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="dGVzdA==""#;
185 + let parsed = parse_signature_header(raw).expect("parse ok");
186 + assert_eq!(parsed.key_id, "https://a.tld/users/alice#main-key");
187 + assert_eq!(parsed.algorithm, "rsa-sha256");
188 + assert_eq!(parsed.headers, vec!["(request-target)", "host", "date"]);
189 + assert_eq!(parsed.signature, b"test");
190 + }
191 +
192 + #[test]
193 + fn parse_signature_header_rejects_missing_required_fields() {
194 + let raw = r#"algorithm="rsa-sha256",headers="host","#;
195 + let err = parse_signature_header(raw).expect_err("must fail");
196 + assert!(matches!(err, AppError::Validation(_)));
197 + }
198 +
199 + #[test]
200 + fn build_signature_input_uses_lowercase_header_names() {
201 + let signed = vec!["host".to_string(), "date".to_string()];
202 + let input = build_signature_input(&signed, "post /inbox", |h| match h {
203 + "host" => Some("b.tld".into()),
204 + "date" => Some("Mon, 10 May 2026 12:00:00 GMT".into()),
205 + _ => None,
206 + })
207 + .unwrap();
208 + assert!(input.contains("host: b.tld"));
209 + assert!(input.contains("date: Mon, 10 May 2026 12:00:00 GMT"));
210 + }
211 +
212 + #[test]
213 + fn build_signature_input_handles_request_target() {
214 + let signed = vec!["(request-target)".to_string()];
215 + let input = build_signature_input(&signed, "post /inbox", |_| None).unwrap();
216 + assert_eq!(input, "(request-target): post /inbox");
217 + }
218 +
219 + #[test]
220 + fn build_signature_input_errors_on_missing_field() {
221 + let signed = vec!["digest".to_string()];
222 + let err = build_signature_input(&signed, "post /inbox", |_| None).expect_err("");
223 + assert!(matches!(err, AppError::Validation(_)));
224 + }
225 +
226 + #[test]
227 + fn sign_then_verify_round_trip() {
228 + let (pub_pem, priv_pem) = fresh_keypair();
229 + let input = b"(request-target): post /inbox\nhost: b.tld\ndate: x";
230 + let sig = rsa_sha256_sign(&priv_pem, input).unwrap();
231 + rsa_sha256_verify(&pub_pem, input, &sig).expect("verify ok");
232 + }
233 +
234 + #[test]
235 + fn verify_rejects_tampered_signature() {
236 + let (pub_pem, priv_pem) = fresh_keypair();
237 + let input = b"hello world";
238 + let mut sig = rsa_sha256_sign(&priv_pem, input).unwrap();
239 + sig[0] ^= 0xff;
240 + let err = rsa_sha256_verify(&pub_pem, input, &sig).expect_err("must reject");
241 + assert!(matches!(err, AppError::Internal(_)));
242 + }
243 +
244 + #[test]
245 + fn verify_rejects_wrong_public_key() {
246 + let (_other_pub, priv_pem) = fresh_keypair();
247 + let (pub_pem, _) = fresh_keypair();
248 + let input = b"hello";
249 + let sig = rsa_sha256_sign(&priv_pem, input).unwrap();
250 + let err = rsa_sha256_verify(&pub_pem, input, &sig).expect_err("");
251 + assert!(matches!(err, AppError::Internal(_)));
252 + }
253 +
254 + #[test]
255 + fn parse_and_verify_cavage_accepts_well_formed_signature() {
256 + let (pub_pem, priv_pem) = fresh_keypair();
257 + let signed_headers = vec![
258 + "(request-target)".to_string(),
259 + "host".to_string(),
260 + "date".to_string(),
261 + ];
262 + let input = build_signature_input(&signed_headers, "post /inbox", |h| match h {
263 + "host" => Some("b.tld".into()),
264 + "date" => Some("Mon, 10 May 2026 12:00:00 GMT".into()),
265 + _ => None,
266 + })
267 + .unwrap();
268 + let sig_bytes = rsa_sha256_sign(&priv_pem, input.as_bytes()).unwrap();
269 + let header = format!(
270 + r#"keyId="x",algorithm="rsa-sha256",headers="(request-target) host date",signature="{}""#,
271 + b64(&sig_bytes),
272 + );
273 + parse_and_verify_cavage(&header, "post /inbox", b"", &pub_pem, |h| match h {
274 + "host" => Some("b.tld".into()),
275 + "date" => Some("Mon, 10 May 2026 12:00:00 GMT".into()),
276 + _ => None,
277 + })
278 + .expect("verify ok");
279 + }
280 +
281 + #[test]
282 + fn parse_and_verify_cavage_rejects_unsupported_algorithm() {
283 + let header = r#"keyId="x",algorithm="hs2019",headers="host",signature="dGVzdA==""#;
284 + let err = parse_and_verify_cavage(header, "post /x", b"", "", |_| None)
285 + .expect_err("");
286 + assert!(matches!(err, AppError::Validation(_)));
287 + }
288 +
289 + #[test]
290 + fn parse_and_verify_cavage_rejects_invalid_base64() {
291 + let header = r#"keyId="x",algorithm="rsa-sha256",headers="host",signature="???""#;
292 + let err = parse_and_verify_cavage(header, "post /x", b"", "", |_| Some("h".into()))
293 + .expect_err("");
294 + assert!(matches!(err, AppError::Validation(_)));
295 + }
296 +
297 + #[test]
298 + fn parse_and_verify_cavage_rejects_tampered_input() {
299 + let (pub_pem, priv_pem) = fresh_keypair();
300 + let signed_headers = vec!["host".to_string()];
301 + let input = build_signature_input(&signed_headers, "post /x", |h| {
302 + if h == "host" {
303 + Some("b.tld".into())
304 + } else {
305 + None
306 + }
307 + })
308 + .unwrap();
309 + let sig_bytes = rsa_sha256_sign(&priv_pem, input.as_bytes()).unwrap();
310 + let header = format!(
311 + r#"keyId="x",algorithm="rsa-sha256",headers="host",signature="{}""#,
312 + b64(&sig_bytes),
313 + );
314 + // Verify against a different host value.
315 + let err = parse_and_verify_cavage(&header, "post /x", b"", &pub_pem, |h| {
316 + if h == "host" {
317 + Some("evil.tld".into())
318 + } else {
319 + None
320 + }
321 + })
322 + .expect_err("must reject tampered host");
323 + assert!(matches!(err, AppError::Internal(_)));
324 + }
325 +}
A crates/rustwarden-core/src/fediverse/inbox.rs
+396 -0
@@ -0,0 +1,396 @@
1 +//! `POST /inbox` handler — receives ActivityPub lifecycle activities
2 +//! and dispatches them to [`crate::services::fediverse_signal_service::FediverseSignalService`]
3 +//! (RFC 0012).
4 +//!
5 +//! The route reads the `Signature` HTTP header, delegates verification
6 +//! to the configured [`SignatureVerifier`], then routes by activity
7 +//! `type`. Only `Update`, `Delete`, and `Revoke` are supported in this
8 +//! slice; the rest get `501 Not Implemented`.
9 +//!
10 +//! Gated by `experimental-fediverse`.
11 +
12 +use std::sync::Arc;
13 +
14 +use axum::{Extension, body::Bytes, http::HeaderMap, http::StatusCode};
15 +use sea_orm::DatabaseConnection;
16 +use serde::Deserialize;
17 +use uuid::Uuid;
18 +
19 +use super::error::FediverseError;
20 +use crate::services::fediverse_signal_service::{
21 + FediverseSignalService, SignatureVerifier, UpdatedActor,
22 +};
23 +
24 +/// Per-router state injected into the inbox handler.
25 +///
26 +/// Cheaply clonable (the verifier is `Arc`-ed). The application
27 +/// instantiates this once via `RustwardenBuilder::with_fediverse`.
28 +#[derive(Clone)]
29 +pub struct InboxState {
30 + /// Pluggable signature verifier. Defaults to `AlwaysAcceptVerifier`
31 + /// when the consumer does not call
32 + /// `RustwardenBuilder::with_signature_verifier`.
33 + pub verifier: Arc<dyn SignatureVerifier>,
34 +}
35 +
36 +/// Minimal projection of the JSON-LD payload.
37 +///
38 +/// We only deserialise the fields the dispatcher needs; the full body
39 +/// is forwarded as `serde_json::Value` to the service for audit /
40 +/// re-processing.
41 +#[derive(Debug, Deserialize)]
42 +struct InboxActivity {
43 + #[serde(rename = "type")]
44 + kind: String,
45 + actor: Option<String>,
46 + object: Option<serde_json::Value>,
47 +}
48 +
49 +/// `POST /inbox` axum handler.
50 +pub async fn handle(
51 + Extension(state): Extension<InboxState>,
52 + Extension(db): Extension<DatabaseConnection>,
53 + headers: HeaderMap,
54 + body: Bytes,
55 +) -> Result<StatusCode, FediverseError> {
56 + // 1. Signature verification (delegated).
57 + let signature_header = headers
58 + .get("signature")
59 + .and_then(|v| v.to_str().ok())
60 + .ok_or_else(|| {
61 + FediverseError::Unauthorized("missing Signature header".to_string())
62 + })?;
63 +
64 + let activity: InboxActivity = serde_json::from_slice(&body)
65 + .map_err(|e| FediverseError::BadRequest(format!("invalid JSON: {e}")))?;
66 +
67 + let actor_ap_id = activity.actor.as_deref().unwrap_or("");
68 + state
69 + .verifier
70 + .verify(actor_ap_id, signature_header, &body, "")
71 + .await
72 + .map_err(|e| FediverseError::Unauthorized(e.to_string()))?;
73 +
74 + // 2. Dispatch by activity kind.
75 + match activity.kind.as_str() {
76 + "Update" => dispatch_update(&db, &activity).await?,
77 + "Delete" => dispatch_delete(&db, &activity).await?,
78 + "Revoke" => dispatch_revoke(&db, &activity).await?,
79 + other => {
80 + return Err(FediverseError::Unsupported(other.to_string()));
81 + }
82 + }
83 +
84 + Ok(StatusCode::ACCEPTED)
85 +}
86 +
87 +async fn dispatch_update(
88 + db: &DatabaseConnection,
89 + activity: &InboxActivity,
90 +) -> Result<(), FediverseError> {
91 + let object = activity
92 + .object
93 + .as_ref()
94 + .ok_or_else(|| FediverseError::BadRequest("Update missing `object`".into()))?;
95 +
96 + let ap_id = object
97 + .get("id")
98 + .and_then(|v| v.as_str())
99 + .ok_or_else(|| FediverseError::BadRequest("Update.object missing `id`".into()))?
100 + .to_string();
101 +
102 + let display_name = object
103 + .get("name")
104 + .and_then(|v| v.as_str())
105 + .map(str::to_string);
106 + let summary_html = object
107 + .get("summary")
108 + .and_then(|v| v.as_str())
109 + .map(str::to_string);
110 + let icon_url = object
111 + .get("icon")
112 + .and_then(|v| v.get("url"))
113 + .and_then(|v| v.as_str())
114 + .map(str::to_string);
115 + let public_key_pem = object
116 + .get("publicKey")
117 + .and_then(|v| v.get("publicKeyPem"))
118 + .and_then(|v| v.as_str())
119 + .map(str::to_string);
120 +
121 + FediverseSignalService::apply_update_actor(
122 + db,
123 + UpdatedActor {
124 + ap_id,
125 + display_name,
126 + summary_html,
127 + icon_url,
128 + public_key_pem,
129 + raw_json: Some(object.clone()),
130 + },
131 + )
132 + .await?;
133 + Ok(())
134 +}
135 +
136 +async fn dispatch_delete(
137 + db: &DatabaseConnection,
138 + activity: &InboxActivity,
139 +) -> Result<(), FediverseError> {
140 + // AP `Delete{Actor}` carries the actor URL either as a string in
141 + // `object` or as `{ "id": "...", "type": "Person" }`.
142 + let ap_id = activity
143 + .object
144 + .as_ref()
145 + .and_then(|o| {
146 + o.as_str()
147 + .map(str::to_string)
148 + .or_else(|| o.get("id").and_then(|v| v.as_str()).map(str::to_string))
149 + })
150 + .ok_or_else(|| FediverseError::BadRequest("Delete missing actor reference".into()))?;
151 +
152 + FediverseSignalService::apply_delete_actor(db, &ap_id).await?;
153 + Ok(())
154 +}
155 +
156 +async fn dispatch_revoke(
157 + db: &DatabaseConnection,
158 + activity: &InboxActivity,
159 +) -> Result<(), FediverseError> {
160 + let object = activity
161 + .object
162 + .as_ref()
163 + .ok_or_else(|| FediverseError::BadRequest("Revoke missing `object`".into()))?;
164 +
165 + let team_id = object
166 + .get("team")
167 + .and_then(|v| v.as_str())
168 + .and_then(|s| s.rsplit('/').next())
169 + .and_then(|s| Uuid::parse_str(s).ok())
170 + .ok_or_else(|| FediverseError::BadRequest("Revoke.object.team missing/malformed".into()))?;
171 +
172 + let remote_identity_id = object
173 + .get("subject")
174 + .and_then(|v| v.as_str())
175 + .and_then(|s| Uuid::parse_str(s).ok())
176 + .ok_or_else(|| FediverseError::BadRequest("Revoke.object.subject missing/malformed".into()))?;
177 +
178 + FediverseSignalService::apply_revoke_membership(db, team_id, remote_identity_id).await?;
179 + Ok(())
180 +}
181 +
182 +#[cfg(test)]
183 +mod tests {
184 + use super::*;
185 + use crate::error::AppError;
186 + use async_trait::async_trait;
187 + use axum::{Router, body::Body, http::Request, routing::post};
188 + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};
189 + use tower::ServiceExt;
190 +
191 + fn make_router(verifier: Arc<dyn SignatureVerifier>, db: DatabaseConnection) -> Router {
192 + Router::new()
193 + .route("/inbox", post(handle))
194 + .layer(Extension(InboxState { verifier }))
195 + .layer(Extension(db))
196 + }
197 +
198 + fn signed_request(body: &str) -> Request<Body> {
199 + Request::builder()
200 + .uri("/inbox")
201 + .method("POST")
202 + .header("signature", "keyId=\"test\",signature=\"...\"")
203 + .header("content-type", "application/activity+json")
204 + .body(Body::from(body.to_string()))
205 + .unwrap()
206 + }
207 +
208 + struct AlwaysAccept;
209 +
210 + #[async_trait]
211 + impl SignatureVerifier for AlwaysAccept {
212 + async fn verify(
213 + &self,
214 + _actor: &str,
215 + _sig: &str,
216 + _body: &[u8],
217 + _pem: &str,
218 + ) -> Result<(), AppError> {
219 + Ok(())
220 + }
221 + }
222 +
223 + struct AlwaysReject;
224 +
225 + #[async_trait]
226 + impl SignatureVerifier for AlwaysReject {
227 + async fn verify(
228 + &self,
229 + _: &str,
230 + _: &str,
231 + _: &[u8],
232 + _: &str,
233 + ) -> Result<(), AppError> {
234 + Err(AppError::Internal("nope".into()))
235 + }
236 + }
237 +
238 + fn db_for_update() -> DatabaseConnection {
239 + let now = chrono::Utc::now();
240 + let row = crate::models::remote_identity::Model {
241 + id: Uuid::new_v4(),
242 + ap_id: "https://a.tld/users/alice".into(),
243 + username: "alice".into(),
244 + domain: "a.tld".into(),
245 + display_name: None,
246 + summary_html: None,
247 + inbox_url: "https://a.tld/users/alice/inbox".into(),
248 + shared_inbox_url: None,
249 + public_key_pem: String::new(),
250 + icon_url: None,
251 + actor_type: "Person".into(),
252 + raw_json: None,
253 + last_fetched_at: now,
254 + disabled_at: None,
255 + created_at: now,
256 + updated_at: now,
257 + };
258 + let mut after = row.clone();
259 + after.display_name = Some("Alice".into());
260 + MockDatabase::new(DatabaseBackend::Postgres)
261 + .append_query_results([vec![row]])
262 + .append_query_results([vec![after]])
263 + .append_exec_results([MockExecResult {
264 + last_insert_id: 0,
265 + rows_affected: 1,
266 + }])
267 + .into_connection()
268 + }
269 +
270 + fn db_for_delete_noop() -> DatabaseConnection {
271 + // SELECT returns empty → DeleteOutcome::default(), no UPDATE.
272 + MockDatabase::new(DatabaseBackend::Postgres)
273 + .append_query_results([Vec::<crate::models::remote_identity::Model>::new()])
274 + .into_connection()
275 + }
276 +
277 + fn db_for_revoke_noop() -> DatabaseConnection {
278 + MockDatabase::new(DatabaseBackend::Postgres)
279 + .append_exec_results([MockExecResult {
280 + last_insert_id: 0,
281 + rows_affected: 0,
282 + }])
283 + .into_connection()
284 + }
285 +
286 + #[tokio::test]
287 + async fn inbox_returns_202_for_valid_update_actor() {
288 + let body = serde_json::json!({
289 + "type": "Update",
290 + "actor": "https://a.tld/users/alice",
291 + "object": {
292 + "id": "https://a.tld/users/alice",
293 + "type": "Person",
294 + "name": "Alice",
295 + }
296 + })
297 + .to_string();
298 + let router = make_router(Arc::new(AlwaysAccept), db_for_update());
299 + let resp = router.oneshot(signed_request(&body)).await.unwrap();
300 + assert_eq!(resp.status(), StatusCode::ACCEPTED);
301 + }
302 +
303 + #[tokio::test]
304 + async fn inbox_returns_202_for_valid_delete_actor() {
305 + let body = serde_json::json!({
306 + "type": "Delete",
307 + "actor": "https://a.tld/users/alice",
308 + "object": "https://a.tld/users/alice"
309 + })
310 + .to_string();
311 + let router = make_router(Arc::new(AlwaysAccept), db_for_delete_noop());
312 + let resp = router.oneshot(signed_request(&body)).await.unwrap();
313 + assert_eq!(resp.status(), StatusCode::ACCEPTED);
314 + }
315 +
316 + #[tokio::test]
317 + async fn inbox_returns_202_for_valid_revoke_membership() {
318 + let team_id = Uuid::new_v4();
319 + let remote_id = Uuid::new_v4();
320 + let body = serde_json::json!({
321 + "type": "Revoke",
322 + "actor": "https://a.tld/users/alice",
323 + "object": {
324 + "type": "TeamMembership",
325 + "team": format!("https://b.tld/teams/{team_id}"),
326 + "subject": remote_id.to_string(),
327 + }
328 + })
329 + .to_string();
330 + let router = make_router(Arc::new(AlwaysAccept), db_for_revoke_noop());
331 + let resp = router.oneshot(signed_request(&body)).await.unwrap();
332 + assert_eq!(resp.status(), StatusCode::ACCEPTED);
333 + }
334 +
335 + #[tokio::test]
336 + async fn inbox_returns_401_when_signature_header_missing() {
337 + let body = "{\"type\":\"Update\"}";
338 + let req = Request::builder()
339 + .uri("/inbox")
340 + .method("POST")
341 + .body(Body::from(body))
342 + .unwrap();
343 + let router = make_router(
344 + Arc::new(AlwaysAccept),
345 + MockDatabase::new(DatabaseBackend::Postgres).into_connection(),
346 + );
347 + let resp = router.oneshot(req).await.unwrap();
348 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
349 + }
350 +
351 + #[tokio::test]
352 + async fn inbox_returns_401_when_verifier_rejects() {
353 + let body = serde_json::json!({"type": "Update", "actor": "x"}).to_string();
354 + let router = make_router(
355 + Arc::new(AlwaysReject),
356 + MockDatabase::new(DatabaseBackend::Postgres).into_connection(),
357 + );
358 + let resp = router.oneshot(signed_request(&body)).await.unwrap();
359 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
360 + }
361 +
362 + #[tokio::test]
363 + async fn inbox_returns_400_for_invalid_json() {
364 + let router = make_router(
365 + Arc::new(AlwaysAccept),
366 + MockDatabase::new(DatabaseBackend::Postgres).into_connection(),
367 + );
368 + let resp = router.oneshot(signed_request("not json")).await.unwrap();
369 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
370 + }
371 +
372 + #[tokio::test]
373 + async fn inbox_returns_501_for_unknown_activity_type() {
374 + let body = serde_json::json!({"type": "Like", "actor": "x"}).to_string();
375 + let router = make_router(
376 + Arc::new(AlwaysAccept),
377 + MockDatabase::new(DatabaseBackend::Postgres).into_connection(),
378 + );
379 + let resp = router.oneshot(signed_request(&body)).await.unwrap();
380 + assert_eq!(resp.status(), StatusCode::NOT_IMPLEMENTED);
381 + }
382 +
383 + #[tokio::test]
384 + async fn inbox_smoke_test_with_always_accept_verifier() {
385 + use crate::services::fediverse_signal_service::AlwaysAcceptVerifier;
386 + let body = serde_json::json!({
387 + "type": "Delete",
388 + "actor": "https://a.tld/users/ghost",
389 + "object": "https://a.tld/users/ghost"
390 + })
391 + .to_string();
392 + let router = make_router(Arc::new(AlwaysAcceptVerifier), db_for_delete_noop());
393 + let resp = router.oneshot(signed_request(&body)).await.unwrap();
394 + assert_eq!(resp.status(), StatusCode::ACCEPTED);
395 + }
396 +}
A crates/rustwarden-core/src/fediverse/invite_routes.rs
+532 -0
@@ -0,0 +1,532 @@
1 +//! HTTP routes for cross-instance team invitations (RFC 0014).
2 +//!
3 +//! Six thin handlers around
4 +//! [`crate::services::fediverse_invite_service::FediverseInviteService`].
5 +//!
6 +//! Inviter-side (`b.tld`) :
7 +//! - `POST /api/v1/teams/{team_id}/members/invite-fediverse`
8 +//! - `GET /api/v1/teams/{team_id}/invites`
9 +//! - `POST /api/v1/teams/{team_id}/invites/{invite_id}/revoke`
10 +//!
11 +//! IdP-side (`a.tld`) :
12 +//! - `GET /api/v1/fediverse/invites?handle=@user@domain`
13 +//! - `POST /api/v1/fediverse/invites/{invite_id}/accept`
14 +//! - `POST /api/v1/fediverse/invites/{invite_id}/reject`
15 +//!
16 +//! Gated by `experimental-fediverse`.
17 +
18 +use std::sync::Arc;
19 +use std::time::Duration;
20 +
21 +use axum::{
22 + Extension, Json, Router,
23 + extract::{Path, Query},
24 + http::StatusCode,
25 + routing::{get, post},
26 +};
27 +use sea_orm::DatabaseConnection;
28 +use serde::{Deserialize, Serialize};
29 +use uuid::Uuid;
30 +
31 +use super::error::FediverseError;
32 +use crate::error::AppError;
33 +use crate::services::fediverse_invite_service::FediverseInviteService;
34 +use crate::services::team_service::TeamRole;
35 +
36 +/// Per-router state injected into the invite handlers.
37 +#[derive(Clone)]
38 +pub struct InviteRoutesState {
39 + /// TTL applied to every freshly-issued invite. Defaults to 7 days
40 + /// when the consumer does not override (set by
41 + /// [`crate::fediverse::routes::router_with_auth`]).
42 + pub default_ttl: Duration,
43 + /// FK of the user the invite is recorded as having been sent
44 + /// **by**. In production the consumer overrides this with the
45 + /// authenticated admin's `users.id`; the placeholder makes the
46 + /// minimal RFC 0014 slice usable end-to-end without forcing
47 + /// every consumer to wire JWT extraction immediately.
48 + pub system_actor_user_id: Arc<dyn Fn() -> Uuid + Send + Sync>,
49 +}
50 +
51 +impl std::fmt::Debug for InviteRoutesState {
52 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 + f.debug_struct("InviteRoutesState")
54 + .field("default_ttl", &self.default_ttl)
55 + .finish_non_exhaustive()
56 + }
57 +}
58 +
59 +// ─── Bodies / responses ──────────────────────────────────────────────
60 +
61 +/// `POST /api/v1/teams/{team_id}/members/invite-fediverse` body.
62 +#[derive(Debug, Deserialize)]
63 +pub struct InviteBody {
64 + /// `@user@domain` to invite.
65 + pub handle: String,
66 + /// `"member"` or `"admin"`.
67 + pub role: String,
68 +}
69 +
70 +/// `POST /api/v1/teams/{team_id}/members/invite-fediverse` 201 response.
71 +#[derive(Debug, Serialize)]
72 +pub struct InviteResponse {
73 + /// Stable invite identifier.
74 + pub invite_id: Uuid,
75 + /// Canonical AP activity id (used by `b.tld` when sending the
76 + /// `Invite{TeamMembership}` activity to the remote inbox).
77 + pub activity_id: String,
78 + /// RFC3339 expiry timestamp.
79 + pub expires_at: String,
80 +}
81 +
82 +/// One row in a `GET /…/invites` listing.
83 +#[derive(Debug, Serialize)]
84 +pub struct InviteRow {
85 + /// Stable invite identifier.
86 + pub id: Uuid,
87 + /// `@user@domain` invited.
88 + pub handle: String,
89 + /// `"member"` or `"admin"`.
90 + pub role: String,
91 + /// Lifecycle state.
92 + pub status: String,
93 + /// RFC3339 creation timestamp.
94 + pub created_at: String,
95 + /// RFC3339 expiry timestamp.
96 + pub expires_at: String,
97 +}
98 +
99 +/// Query params for the IdP listing endpoint.
100 +#[derive(Debug, Deserialize)]
101 +pub struct IdpListQuery {
102 + /// Handle whose pending invites should be returned.
103 + pub handle: String,
104 +}
105 +
106 +// ─── Handlers ────────────────────────────────────────────────────────
107 +
108 +async fn invite(
109 + Extension(state): Extension<InviteRoutesState>,
110 + Extension(db): Extension<DatabaseConnection>,
111 + Path(team_id): Path<Uuid>,
112 + Json(body): Json<InviteBody>,
113 +) -> Result<(StatusCode, Json<InviteResponse>), FediverseError> {
114 + let role = TeamRole::parse(&body.role)
115 + .ok_or_else(|| FediverseError::BadRequest(format!("invalid role: {}", body.role)))?;
116 + let inviter = (state.system_actor_user_id)();
117 + let model = FediverseInviteService::invite_remote(
118 + &db,
119 + team_id,
120 + &body.handle,
121 + role,
122 + inviter,
123 + state.default_ttl,
124 + )
125 + .await?;
126 + Ok((
127 + StatusCode::CREATED,
128 + Json(InviteResponse {
129 + invite_id: model.id,
130 + activity_id: model.activity_id,
131 + expires_at: model.expires_at.to_rfc3339(),
132 + }),
133 + ))
134 +}
135 +
136 +async fn list_for_team(
137 + Extension(db): Extension<DatabaseConnection>,
138 + Path(team_id): Path<Uuid>,
139 +) -> Result<Json<Vec<InviteRow>>, FediverseError> {
140 + let rows = FediverseInviteService::list_pending_for_team(&db, team_id).await?;
141 + Ok(Json(rows.into_iter().map(into_row).collect()))
142 +}
143 +
144 +async fn revoke(
145 + Extension(db): Extension<DatabaseConnection>,
146 + Path((_team_id, invite_id)): Path<(Uuid, Uuid)>,
147 +) -> Result<Json<InviteRow>, FediverseError> {
148 + let model = FediverseInviteService::revoke(&db, invite_id).await?;
149 + Ok(Json(into_row(model)))
150 +}
151 +
152 +async fn idp_list(
153 + Extension(db): Extension<DatabaseConnection>,
154 + Query(query): Query<IdpListQuery>,
155 +) -> Result<Json<Vec<InviteRow>>, FediverseError> {
156 + let rows =
157 + FediverseInviteService::list_pending_for_handle(&db, &query.handle).await?;
158 + Ok(Json(rows.into_iter().map(into_row).collect()))
159 +}
160 +
161 +#[derive(Debug, Deserialize)]
162 +struct AcceptBody {
163 + /// FK of the resolved `remote_identities.id` that the IdP user maps
164 + /// to. In `debug_local_federation` mode the IdP UI / API picks this
165 + /// from the same DB; in production a follow-up RFC will let the
166 + /// IdP-side mirror the activity and resolve the row automatically.
167 + remote_identity_id: Uuid,
168 +}
169 +
170 +async fn idp_accept(
171 + Extension(db): Extension<DatabaseConnection>,
172 + Path(invite_id): Path<Uuid>,
173 + Json(body): Json<AcceptBody>,
174 +) -> Result<StatusCode, FediverseError> {
175 + FediverseInviteService::accept(&db, invite_id, body.remote_identity_id).await?;
176 + Ok(StatusCode::OK)
177 +}
178 +
179 +async fn idp_reject(
180 + Extension(db): Extension<DatabaseConnection>,
181 + Path(invite_id): Path<Uuid>,
182 +) -> Result<StatusCode, FediverseError> {
183 + FediverseInviteService::reject(&db, invite_id).await?;
184 + Ok(StatusCode::OK)
185 +}
186 +
187 +fn into_row(m: crate::models::fediverse_pending_invite::Model) -> InviteRow {
188 + InviteRow {
189 + id: m.id,
190 + handle: m.invited_handle,
191 + role: m.role,
192 + status: m.status,
193 + created_at: m.created_at.to_rfc3339(),
194 + expires_at: m.expires_at.to_rfc3339(),
195 + }
196 +}
197 +
198 +/// Build the invite-routes router.
199 +pub fn router(state: InviteRoutesState) -> Router<DatabaseConnection> {
200 + Router::new()
201 + .route(
202 + "/api/v1/teams/{team_id}/members/invite-fediverse",
203 + post(invite),
204 + )
205 + .route("/api/v1/teams/{team_id}/invites", get(list_for_team))
206 + .route(
207 + "/api/v1/teams/{team_id}/invites/{invite_id}/revoke",
208 + post(revoke),
209 + )
210 + .route("/api/v1/fediverse/invites", get(idp_list))
211 + .route(
212 + "/api/v1/fediverse/invites/{invite_id}/accept",
213 + post(idp_accept),
214 + )
215 + .route(
216 + "/api/v1/fediverse/invites/{invite_id}/reject",
217 + post(idp_reject),
218 + )
219 + .layer(Extension(state))
220 +}
221 +
222 +// `From<AppError>` for `FediverseError` is provided via the `App`
223 +// variant; nothing extra to wire here.
224 +#[allow(dead_code)]
225 +fn _ensure_app_error_conversion(err: AppError) -> FediverseError {
226 + err.into()
227 +}
228 +
229 +#[cfg(test)]
230 +mod tests {
231 + use super::*;
232 + use crate::models::fediverse_pending_invite;
233 + use axum::body::Body;
234 + use axum::http::Request;
235 + use chrono::{Duration as ChronoDuration, Utc};
236 + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};
237 + use tower::ServiceExt;
238 +
239 + fn fresh_invite(id: Uuid, team_id: Uuid) -> fediverse_pending_invite::Model {
240 + let now = Utc::now();
241 + fediverse_pending_invite::Model {
242 + id,
243 + activity_id: format!("https://b.tld/invites/{id}"),
244 + team_id,
245 + invited_handle: "alice@a.tld".into(),
246 + invited_remote_identity_id: None,
247 + role: "member".into(),
248 + invited_by_user_id: Uuid::new_v4(),
249 + status: "pending".into(),
250 + created_at: now,
251 + expires_at: now + ChronoDuration::days(7),
252 + decided_at: None,
253 + }
254 + }
255 +
256 + fn build_router(db: DatabaseConnection) -> Router {
257 + let state = InviteRoutesState {
258 + default_ttl: Duration::from_secs(7 * 86400),
259 + system_actor_user_id: Arc::new(Uuid::new_v4),
260 + };
261 + router(state)
262 + .layer(Extension(db))
263 + .with_state(MockDatabase::new(DatabaseBackend::Postgres).into_connection())
264 + }
265 +
266 + fn req_post(uri: &str, body: &str) -> Request<Body> {
267 + Request::builder()
268 + .uri(uri)
269 + .method("POST")
270 + .header("content-type", "application/json")
271 + .body(Body::from(body.to_string()))
272 + .unwrap()
273 + }
274 +
275 + fn req_get(uri: &str) -> Request<Body> {
276 + Request::builder()
277 + .uri(uri)
278 + .method("GET")
279 + .body(Body::empty())
280 + .unwrap()
281 + }
282 +
283 + // ─── Inviter side ───────────────────────────────────────────────
284 +
285 + #[tokio::test]
286 + async fn invite_returns_201_with_invite_id() {
287 + let team_id = Uuid::new_v4();
288 + let row = fresh_invite(Uuid::new_v4(), team_id);
289 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
290 + .append_query_results([vec![row]])
291 + .into_connection();
292 + let body = serde_json::json!({"handle":"alice@a.tld","role":"member"}).to_string();
293 + let resp = build_router(db)
294 + .oneshot(req_post(
295 + &format!("/api/v1/teams/{team_id}/members/invite-fediverse"),
296 + &body,
297 + ))
298 + .await
299 + .unwrap();
300 + assert_eq!(resp.status(), StatusCode::CREATED);
301 + }
302 +
303 + #[tokio::test]
304 + async fn invite_returns_400_for_invalid_role() {
305 + let team_id = Uuid::new_v4();
306 + let db: DatabaseConnection =
307 + MockDatabase::new(DatabaseBackend::Postgres).into_connection();
308 + let body = serde_json::json!({"handle":"alice@a.tld","role":"superuser"}).to_string();
309 + let resp = build_router(db)
310 + .oneshot(req_post(
311 + &format!("/api/v1/teams/{team_id}/members/invite-fediverse"),
312 + &body,
313 + ))
314 + .await
315 + .unwrap();
316 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
317 + }
318 +
319 + #[tokio::test]
320 + async fn list_pending_returns_200_with_array() {
321 + let team_id = Uuid::new_v4();
322 + let r1 = fresh_invite(Uuid::new_v4(), team_id);
323 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
324 + .append_query_results([vec![r1]])
325 + .into_connection();
326 + let resp = build_router(db)
327 + .oneshot(req_get(&format!("/api/v1/teams/{team_id}/invites")))
328 + .await
329 + .unwrap();
330 + assert_eq!(resp.status(), StatusCode::OK);
331 + }
332 +
333 + #[tokio::test]
334 + async fn revoke_returns_200_for_pending_invite() {
335 + let team_id = Uuid::new_v4();
336 + let invite_id = Uuid::new_v4();
337 + let row = fresh_invite(invite_id, team_id);
338 + let mut after = row.clone();
339 + after.status = "revoked".into();
340 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
341 + .append_query_results([vec![row]])
342 + .append_query_results([vec![after]])
343 + .append_exec_results([MockExecResult {
344 + last_insert_id: 0,
345 + rows_affected: 1,
346 + }])
347 + .into_connection();
348 + let resp = build_router(db)
349 + .oneshot(req_post(
350 + &format!("/api/v1/teams/{team_id}/invites/{invite_id}/revoke"),
351 + "",
352 + ))
353 + .await
354 + .unwrap();
355 + assert_eq!(resp.status(), StatusCode::OK);
356 + }
357 +
358 + #[tokio::test]
359 + async fn revoke_returns_404_for_unknown_invite() {
360 + let team_id = Uuid::new_v4();
361 + let invite_id = Uuid::new_v4();
362 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
363 + .append_query_results([Vec::<fediverse_pending_invite::Model>::new()])
364 + .into_connection();
365 + let resp = build_router(db)
366 + .oneshot(req_post(
367 + &format!("/api/v1/teams/{team_id}/invites/{invite_id}/revoke"),
368 + "",
369 + ))
370 + .await
371 + .unwrap();
372 + assert_eq!(resp.status(), StatusCode::NOT_FOUND);
373 + }
374 +
375 + // ─── IdP side ──────────────────────────────────────────────────
376 +
377 + #[tokio::test]
378 + async fn idp_list_returns_200_with_invites_for_handle() {
379 + let team_id = Uuid::new_v4();
380 + let r1 = fresh_invite(Uuid::new_v4(), team_id);
381 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
382 + .append_query_results([vec![r1]])
383 + .into_connection();
384 + let resp = build_router(db)
385 + .oneshot(req_get("/api/v1/fediverse/invites?handle=alice@a.tld"))
386 + .await
387 + .unwrap();
388 + assert_eq!(resp.status(), StatusCode::OK);
389 + }
390 +
391 + #[tokio::test]
392 + async fn idp_list_returns_empty_array_when_no_invites() {
393 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
394 + .append_query_results([Vec::<fediverse_pending_invite::Model>::new()])
395 + .into_connection();
396 + let resp = build_router(db)
397 + .oneshot(req_get("/api/v1/fediverse/invites?handle=ghost@nowhere.tld"))
398 + .await
399 + .unwrap();
400 + assert_eq!(resp.status(), StatusCode::OK);
401 + }
402 +
403 + #[tokio::test]
404 + async fn idp_accept_returns_200_for_pending() {
405 + let team_id = Uuid::new_v4();
406 + let invite_id = Uuid::new_v4();
407 + let remote_id = Uuid::new_v4();
408 + let row = fresh_invite(invite_id, team_id);
409 + let mut after_invite = row.clone();
410 + after_invite.status = "accepted".into();
411 + after_invite.invited_remote_identity_id = Some(remote_id);
412 + let now = Utc::now();
413 + let new_member = crate::models::team_member::Model {
414 + team_id,
415 + user_id: Uuid::nil(),
416 + role: "member".into(),
417 + joined_at: now,
418 + remote_identity_id: Some(remote_id),
419 + };
420 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
421 + .append_query_results([vec![row]])
422 + .append_query_results([vec![after_invite]])
423 + .append_query_results([vec![new_member]])
424 + .append_exec_results([
425 + MockExecResult { last_insert_id: 0, rows_affected: 1 },
426 + MockExecResult { last_insert_id: 0, rows_affected: 1 },
427 + ])
428 + .into_connection();
429 + let body = serde_json::json!({"remote_identity_id":remote_id}).to_string();
430 + let resp = build_router(db)
431 + .oneshot(req_post(
432 + &format!("/api/v1/fediverse/invites/{invite_id}/accept"),
433 + &body,
434 + ))
435 + .await
436 + .unwrap();
437 + assert_eq!(resp.status(), StatusCode::OK);
438 + }
439 +
440 + #[tokio::test]
441 + async fn idp_accept_returns_400_for_already_accepted() {
442 + let team_id = Uuid::new_v4();
443 + let invite_id = Uuid::new_v4();
444 + let mut row = fresh_invite(invite_id, team_id);
445 + row.status = "accepted".into();
446 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
447 + .append_query_results([vec![row]])
448 + .into_connection();
449 + let body = serde_json::json!({"remote_identity_id":Uuid::new_v4()}).to_string();
450 + let resp = build_router(db)
451 + .oneshot(req_post(
452 + &format!("/api/v1/fediverse/invites/{invite_id}/accept"),
453 + &body,
454 + ))
455 + .await
456 + .unwrap();
457 + assert!(
458 + resp.status() == StatusCode::BAD_REQUEST
459 + || resp.status() == StatusCode::UNPROCESSABLE_ENTITY,
460 + "expected 400/422, got {}",
461 + resp.status()
462 + );
463 + }
464 +
465 + #[tokio::test]
466 + async fn idp_reject_returns_200_for_pending() {
467 + let team_id = Uuid::new_v4();
468 + let invite_id = Uuid::new_v4();
469 + let row = fresh_invite(invite_id, team_id);
470 + let mut after = row.clone();
471 + after.status = "rejected".into();
472 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
473 + .append_query_results([vec![row]])
474 + .append_query_results([vec![after]])
475 + .append_exec_results([MockExecResult {
476 + last_insert_id: 0,
477 + rows_affected: 1,
478 + }])
479 + .into_connection();
480 + let resp = build_router(db)
481 + .oneshot(req_post(
482 + &format!("/api/v1/fediverse/invites/{invite_id}/reject"),
483 + "",
484 + ))
485 + .await
486 + .unwrap();
487 + assert_eq!(resp.status(), StatusCode::OK);
488 + }
489 +
490 + #[tokio::test]
491 + async fn idp_reject_returns_400_for_already_decided() {
492 + let team_id = Uuid::new_v4();
493 + let invite_id = Uuid::new_v4();
494 + let mut row = fresh_invite(invite_id, team_id);
495 + row.status = "rejected".into();
496 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
497 + .append_query_results([vec![row]])
498 + .into_connection();
499 + let resp = build_router(db)
500 + .oneshot(req_post(
501 + &format!("/api/v1/fediverse/invites/{invite_id}/reject"),
502 + "",
503 + ))
504 + .await
505 + .unwrap();
506 + assert!(
507 + resp.status() == StatusCode::BAD_REQUEST
508 + || resp.status() == StatusCode::UNPROCESSABLE_ENTITY,
509 + );
510 + }
511 +
512 + #[tokio::test]
513 + async fn invite_uses_default_ttl_from_state() {
514 + // The `default_ttl` flows through the service; the mock
515 + // doesn't reflect it back to the response, so we only
516 + // assert status 201 and that the row was inserted.
517 + let team_id = Uuid::new_v4();
518 + let row = fresh_invite(Uuid::new_v4(), team_id);
519 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
520 + .append_query_results([vec![row]])
521 + .into_connection();
522 + let body = serde_json::json!({"handle":"alice@a.tld","role":"admin"}).to_string();
523 + let resp = build_router(db)
524 + .oneshot(req_post(
525 + &format!("/api/v1/teams/{team_id}/members/invite-fediverse"),
526 + &body,
527 + ))
528 + .await
529 + .unwrap();
530 + assert_eq!(resp.status(), StatusCode::CREATED);
531 + }
532 +}
A crates/rustwarden-core/src/fediverse/mod.rs
+38 -0
@@ -0,0 +1,38 @@
1 +//! ActivityPub identity federation primitives (RFC 0001+).
2 +//!
3 +//! This module is gated behind the `experimental-fediverse` Cargo feature.
4 +//! It implements the discovery layer (RFC 0002): WebFinger, Person actor,
5 +//! and NodeInfo. Subsequent RFCs add authentication (0006), invitations
6 +//! (0009), lifecycle signals (0010), and the outbound delivery worker (0011).
7 +
8 +/// Person actor handler with `Accept` content negotiation.
9 +pub mod actor;
10 +/// Federation-specific error type, convertible to [`AppError`](crate::error::AppError).
11 +pub mod error;
12 +/// HTTP client abstraction used by `RemoteIdentityService` (RFC 0003).
13 +pub mod fetcher;
14 +/// `POST /inbox` handler dispatching AP lifecycle activities (RFC 0012).
15 +pub mod inbox;
16 +/// HTTP routes for the AP-native authentication flow (RFC 0013).
17 +pub mod auth_routes;
18 +/// HTTP routes for cross-instance team invitations (RFC 0014).
19 +pub mod invite_routes;
20 +/// Mastodon-compatible HTTP signatures (draft-cavage RSA-SHA256, RFC 0015).
21 +pub mod http_signatures;
22 +/// NodeInfo discovery + 2.1 handlers.
23 +pub mod nodeinfo;
24 +/// `LocalUserResolver` trait + SeaORM-backed default implementation.
25 +pub mod resolver;
26 +/// Router builder: assembles the federation Axum router.
27 +pub mod routes;
28 +/// Shared state injected into federation handlers.
29 +pub mod state;
30 +/// JSON types serialised on federation endpoints.
31 +pub mod types;
32 +/// WebFinger handler (RFC 7033).
33 +pub mod webfinger;
34 +
35 +pub use error::FediverseError;
36 +pub use fetcher::{RemoteFetcher, UnconfiguredFetcher};
37 +pub use resolver::{LocalUserResolver, UserSummary};
38 +pub use state::FediverseState;
A crates/rustwarden-core/src/fediverse/nodeinfo.rs
+194 -0
@@ -0,0 +1,194 @@
1 +//! NodeInfo discovery + 2.1 detail handlers.
2 +//!
3 +//! `GET /.well-known/nodeinfo` returns a discovery document linking to the
4 +//! concrete schema versions we expose. `GET /nodeinfo/2.1` returns the
5 +//! full metadata document used by Fediverse directories.
6 +
7 +use axum::{Extension, Json};
8 +
9 +use super::error::FediverseError;
10 +use super::state::FediverseState;
11 +use super::types::{
12 + NodeInfo, NodeInfoDiscovery, NodeInfoLink, NodeInfoServices, NodeInfoSoftware, NodeInfoUsage,
13 + NodeInfoUsageUsers,
14 +};
15 +
16 +const NODEINFO_2_1_REL: &str = "http://nodeinfo.diaspora.software/ns/schema/2.1";
17 +
18 +/// `GET /.well-known/nodeinfo`.
19 +pub async fn discovery(
20 + Extension(state): Extension<FediverseState>,
21 +) -> Result<Json<NodeInfoDiscovery>, FediverseError> {
22 + let href = format!("{}nodeinfo/2.1", state.config.base_url());
23 + Ok(Json(NodeInfoDiscovery {
24 + links: vec![NodeInfoLink {
25 + rel: NODEINFO_2_1_REL.to_string(),
26 + href,
27 + }],
28 + }))
29 +}
30 +
31 +/// `GET /nodeinfo/2.1`.
32 +pub async fn detail(
33 + Extension(state): Extension<FediverseState>,
34 +) -> Result<Json<NodeInfo>, FediverseError> {
35 + let total = state.resolver.count_users_total().await?;
36 + let active_month = state.resolver.count_users_active_since(30).await?;
37 + let active_halfyear = state.resolver.count_users_active_since(180).await?;
38 + let local_posts = state.resolver.count_local_posts().await?;
39 +
40 + Ok(Json(NodeInfo {
41 + version: "2.1".to_string(),
42 + software: NodeInfoSoftware {
43 + name: "rustwarden".to_string(),
44 + version: env!("CARGO_PKG_VERSION").to_string(),
45 + repository: Some("https://github.com/EBO/rustwarden".to_string()),
46 + homepage: None,
47 + },
48 + protocols: vec!["activitypub".to_string()],
49 + services: NodeInfoServices::default(),
50 + usage: NodeInfoUsage {
51 + users: NodeInfoUsageUsers {
52 + total,
53 + active_month,
54 + active_halfyear,
55 + },
56 + local_posts,
57 + },
58 + // RFC 0004 will source this from `app_settings.allow_signup`. For now
59 + // a conservative default: closed registration.
60 + open_registrations: false,
61 + metadata: serde_json::json!({}),
62 + }))
63 +}
64 +
65 +#[cfg(test)]
66 +mod tests {
67 + use super::*;
68 + use crate::config::FediverseConfig;
69 + use crate::fediverse::resolver::{LocalUserResolver, UserSummary};
70 + use async_trait::async_trait;
71 + use axum::Router;
72 + use axum::body::Body;
73 + use axum::http::{Request, StatusCode};
74 + use axum::routing::get;
75 + use std::sync::Arc;
76 + use tower::ServiceExt;
77 +
78 + struct StubResolver {
79 + total: u64,
80 + active_month: u64,
81 + active_halfyear: u64,
82 + }
83 +
84 + #[async_trait]
85 + impl LocalUserResolver for StubResolver {
86 + async fn find_local_user(
87 + &self,
88 + _username: &str,
89 + ) -> Result<Option<UserSummary>, crate::error::AppError> {
90 + Ok(None)
91 + }
92 + async fn count_users_total(&self) -> Result<u64, crate::error::AppError> {
93 + Ok(self.total)
94 + }
95 + async fn count_users_active_since(&self, days: u32) -> Result<u64, crate::error::AppError> {
96 + Ok(if days <= 30 {
97 + self.active_month
98 + } else {
99 + self.active_halfyear
100 + })
101 + }
102 + async fn count_local_posts(&self) -> Result<u64, crate::error::AppError> {
103 + Ok(0)
104 + }
105 + }
106 +
107 + fn router(domain: &str, total: u64, active_month: u64, active_halfyear: u64) -> Router {
108 + let state = FediverseState {
109 + config: FediverseConfig::for_tests(domain),
110 + resolver: Arc::new(StubResolver {
111 + total,
112 + active_month,
113 + active_halfyear,
114 + }),
115 + };
116 + Router::new()
117 + .route("/.well-known/nodeinfo", get(discovery))
118 + .route("/nodeinfo/2.1", get(detail))
119 + .layer(Extension(state))
120 + }
121 +
122 + async fn body_json(response: axum::response::Response) -> serde_json::Value {
123 + let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024)
124 + .await
125 + .expect("body bytes");
126 + serde_json::from_slice(&bytes).expect("body is valid JSON")
127 + }
128 +
129 + #[tokio::test]
130 + async fn nodeinfo_discovery_links_to_2_1() {
131 + let app = router("example.org", 1, 0, 0);
132 + let response = app
133 + .oneshot(
134 + Request::builder()
135 + .uri("/.well-known/nodeinfo")
136 + .body(Body::empty())
137 + .unwrap(),
138 + )
139 + .await
140 + .unwrap();
141 + assert_eq!(response.status(), StatusCode::OK);
142 + let body = body_json(response).await;
143 + let link = body["links"]
144 + .as_array()
145 + .expect("links")
146 + .iter()
147 + .find(|l| l["rel"] == "http://nodeinfo.diaspora.software/ns/schema/2.1")
148 + .expect("2.1 schema link");
149 + assert_eq!(link["href"], "https://example.org/nodeinfo/2.1");
150 + }
151 +
152 + #[tokio::test]
153 + async fn nodeinfo_detail_returns_software_metadata() {
154 + let app = router("example.org", 0, 0, 0);
155 + let response = app
156 + .oneshot(
157 + Request::builder()
158 + .uri("/nodeinfo/2.1")
159 + .body(Body::empty())
160 + .unwrap(),
161 + )
162 + .await
163 + .unwrap();
164 + assert_eq!(response.status(), StatusCode::OK);
165 + let body = body_json(response).await;
166 + assert_eq!(body["version"], "2.1");
167 + assert_eq!(body["software"]["name"], "rustwarden");
168 + assert_eq!(
169 + body["software"]["version"],
170 + env!("CARGO_PKG_VERSION"),
171 + "software.version must mirror CARGO_PKG_VERSION"
172 + );
173 + assert_eq!(body["protocols"][0], "activitypub");
174 + }
175 +
176 + #[tokio::test]
177 + async fn nodeinfo_detail_counts_users_via_resolver() {
178 + let app = router("example.org", 42, 7, 23);
179 + let response = app
180 + .oneshot(
181 + Request::builder()
182 + .uri("/nodeinfo/2.1")
183 + .body(Body::empty())
184 + .unwrap(),
185 + )
186 + .await
187 + .unwrap();
188 + let body = body_json(response).await;
189 + assert_eq!(body["usage"]["users"]["total"], 42);
190 + assert_eq!(body["usage"]["users"]["activeMonth"], 7);
191 + assert_eq!(body["usage"]["users"]["activeHalfyear"], 23);
192 + assert_eq!(body["usage"]["localPosts"], 0);
193 + }
194 +}
A crates/rustwarden-core/src/fediverse/resolver.rs
+147 -0
@@ -0,0 +1,147 @@
1 +//! `LocalUserResolver` trait + SeaORM-backed default implementation.
2 +//!
3 +//! Federation handlers depend on this trait rather than directly on SeaORM,
4 +//! which keeps unit tests simple (we inject a stub resolver) and lets future
5 +//! deployments swap the backing store without touching the handler code.
6 +
7 +use async_trait::async_trait;
8 +use sea_orm::DatabaseConnection;
9 +use uuid::Uuid;
10 +
11 +use crate::error::AppError;
12 +
13 +/// Minimal projection of a local user usable to render WebFinger and
14 +/// Person JSON. Extended by [RFC 0005](../../rfcs/0005-actor-key-storage.md)
15 +/// (`actor-key-storage`) with the `ap_public_key_pem` field populated.
16 +#[derive(Debug, Clone)]
17 +pub struct UserSummary {
18 + /// Stable internal user identifier.
19 + pub id: Uuid,
20 + /// Local username (matches the `alice` part of `@alice@example.org`).
21 + pub username: String,
22 + /// Optional display name (Mastodon `name` field).
23 + pub display_name: Option<String>,
24 + /// Optional bio in HTML (Mastodon `summary` field).
25 + pub bio: Option<String>,
26 + /// PEM-encoded RSA public key. `None` until RFC 0005 lands.
27 + pub ap_public_key_pem: Option<String>,
28 +}
29 +
30 +/// Read-only projection of users used by federation handlers.
31 +///
32 +/// Concrete implementations provide a thin facade over the underlying user
33 +/// store. The default is [`SeaOrmLocalUserResolver`]; tests can inject their
34 +/// own implementation.
35 +#[async_trait]
36 +pub trait LocalUserResolver: Send + Sync {
37 + /// Look up a local user by username.
38 + ///
39 + /// Returns `Ok(None)` when no such user exists or the user is
40 + /// soft-deleted. Returns `Err(AppError::Database(_))` on transport errors.
41 + async fn find_local_user(&self, username: &str) -> Result<Option<UserSummary>, AppError>;
42 +
43 + /// Total number of registered users (NodeInfo `usage.users.total`).
44 + async fn count_users_total(&self) -> Result<u64, AppError>;
45 +
46 + /// Number of users active within the given window.
47 + ///
48 + /// "Active" is defined as having `last_login_at` within the past
49 + /// `days` days. Used to populate `usage.users.active_month` (`days = 30`)
50 + /// and `usage.users.active_halfyear` (`days = 180`).
51 + async fn count_users_active_since(&self, days: u32) -> Result<u64, AppError>;
52 +
53 + /// Total number of locally-authored posts (NodeInfo `usage.localPosts`).
54 + /// Returns `0` until later RFCs introduce post entities.
55 + async fn count_local_posts(&self) -> Result<u64, AppError>;
56 +}
57 +
58 +/// Default [`LocalUserResolver`] implementation backed by a SeaORM
59 +/// [`DatabaseConnection`]. Cheap to clone (the connection is `Arc`-backed).
60 +#[derive(Debug, Clone)]
61 +pub struct SeaOrmLocalUserResolver {
62 + db: DatabaseConnection,
63 +}
64 +
65 +impl SeaOrmLocalUserResolver {
66 + /// Build a resolver around an existing SeaORM connection.
67 + pub fn new(db: DatabaseConnection) -> Self {
68 + Self { db }
69 + }
70 +}
71 +
72 +#[async_trait]
73 +impl LocalUserResolver for SeaOrmLocalUserResolver {
74 + async fn find_local_user(&self, username: &str) -> Result<Option<UserSummary>, AppError> {
75 + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
76 +
77 + let row = crate::models::user::Entity::find()
78 + .filter(crate::models::user::Column::Username.eq(username))
79 + .one(&self.db)
80 + .await?;
81 +
82 + let Some(r) = row else { return Ok(None) };
83 +
84 + // RFC 0005: lazy-generate the actor key the first time someone
85 + // resolves the user, but only if encryption is configured. We
86 + // never block the resolution on a generation failure: better to
87 + // serve a key-less actor than to 500 the WebFinger flow.
88 + let public_pem = match &r.ap_public_key_pem {
89 + Some(pem) => Some(pem.clone()),
90 + None => {
91 + if crate::services::crypto_service::CryptoService::is_available() {
92 + match crate::services::actor_key_service::ActorKeyService::ensure_keys(
93 + &self.db, r.id,
94 + )
95 + .await
96 + {
97 + Ok(material) => Some(material.public_pem),
98 + Err(err) => {
99 + tracing::warn!(
100 + user_id = %r.id,
101 + error = %err,
102 + "actor key generation failed, serving key-less actor",
103 + );
104 + None
105 + }
106 + }
107 + } else {
108 + None
109 + }
110 + }
111 + };
112 +
113 + Ok(Some(UserSummary {
114 + id: r.id,
115 + username: r.username,
116 + // `display_name` / `bio` columns are not in the schema yet — a
117 + // future RFC will add them.
118 + display_name: None,
119 + bio: None,
120 + ap_public_key_pem: public_pem,
121 + }))
122 + }
123 +
124 + async fn count_users_total(&self) -> Result<u64, AppError> {
125 + use sea_orm::{EntityTrait, PaginatorTrait};
126 + Ok(crate::models::user::Entity::find()
127 + .count(&self.db)
128 + .await?)
129 + }
130 +
131 + async fn count_users_active_since(&self, days: u32) -> Result<u64, AppError> {
132 + use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter};
133 +
134 + let cutoff = chrono::Utc::now() - chrono::Duration::days(days as i64);
135 + Ok(crate::models::user::Entity::find()
136 + .filter(crate::models::user::Column::LastLoginAt.gte(cutoff))
137 + .count(&self.db)
138 + .await?)
139 + }
140 +
141 + async fn count_local_posts(&self) -> Result<u64, AppError> {
142 + // RFC 0002 — no post entity yet, always zero. Future RFCs that
143 + // introduce locally-authored posts will override this method or
144 + // ship a dedicated resolver.
145 + Ok(0)
146 + }
147 +}
A crates/rustwarden-core/src/fediverse/routes.rs
+134 -0
@@ -0,0 +1,134 @@
1 +//! Builder for the federation Axum router.
2 +//!
3 +//! Assembles the four discovery routes (`webfinger`, `nodeinfo` discovery,
4 +//! `nodeinfo` detail, actor) into a single `Router` ready to be merged into
5 +//! the application by [`crate::app::RustwardenBuilder::build`].
6 +
7 +use std::sync::Arc;
8 +
9 +use axum::{
10 + Extension, Router,
11 + routing::{get, post},
12 +};
13 +use sea_orm::DatabaseConnection;
14 +
15 +use crate::config::FediverseConfig;
16 +use crate::config::auth::AuthConfig;
17 +use crate::services::fediverse_signal_service::{AlwaysAcceptVerifier, SignatureVerifier};
18 +
19 +use super::actor;
20 +use super::auth_routes::{self, AuthFlowState};
21 +use super::inbox::{self, InboxState};
22 +use super::invite_routes::{self, InviteRoutesState};
23 +use super::nodeinfo;
24 +use super::resolver::{LocalUserResolver, SeaOrmLocalUserResolver};
25 +use super::state::FediverseState;
26 +use super::webfinger;
27 +
28 +/// Build the federation router with the default SeaORM-backed resolver
29 +/// and the test-only [`AlwaysAcceptVerifier`].
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);
33 + router_with(config, resolver, verifier)
34 +}
35 +
36 +/// Build the federation router with custom resolver and signature
37 +/// verifier implementations. Used by integration tests, consumers that
38 +/// already maintain their own user store, or apps wanting to plug a
39 +/// production HTTP-Sig verifier (RFC 0012).
40 +pub fn router_with(
41 + config: FediverseConfig,
42 + resolver: Arc<dyn LocalUserResolver>,
43 + verifier: Arc<dyn SignatureVerifier>,
44 +) -> Router<DatabaseConnection> {
45 + let challenge_ttl = config.auth_challenge_ttl();
46 + let state = FediverseState { config, resolver };
47 + let inbox_state = InboxState {
48 + verifier: verifier.clone(),
49 + };
50 + let auth_flow_state = AuthFlowState {
51 + auth_config: Arc::new(AuthConfig::from_env().unwrap_or_else(|_| placeholder_auth_config())),
52 + verifier,
53 + challenge_ttl,
54 + };
55 + Router::new()
56 + .route("/.well-known/webfinger", get(webfinger::handle))
57 + .route("/.well-known/nodeinfo", get(nodeinfo::discovery))
58 + .route("/nodeinfo/2.1", get(nodeinfo::detail))
59 + .route("/users/{username}", get(actor::handle))
60 + .route("/inbox", post(inbox::handle))
61 + .merge(auth_routes::router(auth_flow_state))
62 + .merge(invite_routes::router(InviteRoutesState {
63 + default_ttl: std::time::Duration::from_secs(7 * 86400),
64 + system_actor_user_id: Arc::new(uuid::Uuid::new_v4),
65 + }))
66 + .layer(Extension(state))
67 + .layer(Extension(inbox_state))
68 +}
69 +
70 +/// Build the federation router with explicit `AuthConfig` injection.
71 +/// Use this entry point when the consumer cannot rely on env vars
72 +/// being set at router-build time.
73 +pub fn router_with_auth(
74 + config: FediverseConfig,
75 + resolver: Arc<dyn LocalUserResolver>,
76 + verifier: Arc<dyn SignatureVerifier>,
77 + auth_config: Arc<AuthConfig>,
78 +) -> Router<DatabaseConnection> {
79 + let challenge_ttl = config.auth_challenge_ttl();
80 + let state = FediverseState { config, resolver };
81 + let inbox_state = InboxState {
82 + verifier: verifier.clone(),
83 + };
84 + let auth_flow_state = AuthFlowState {
85 + auth_config,
86 + verifier,
87 + challenge_ttl,
88 + };
89 + Router::new()
90 + .route("/.well-known/webfinger", get(webfinger::handle))
91 + .route("/.well-known/nodeinfo", get(nodeinfo::discovery))
92 + .route("/nodeinfo/2.1", get(nodeinfo::detail))
93 + .route("/users/{username}", get(actor::handle))
94 + .route("/inbox", post(inbox::handle))
95 + .merge(auth_routes::router(auth_flow_state))
96 + .merge(invite_routes::router(InviteRoutesState {
97 + default_ttl: std::time::Duration::from_secs(7 * 86400),
98 + system_actor_user_id: Arc::new(uuid::Uuid::new_v4),
99 + }))
100 + .layer(Extension(state))
101 + .layer(Extension(inbox_state))
102 +}
103 +
104 +fn placeholder_auth_config() -> AuthConfig {
105 + // Env vars not set at router-build time — use harmless defaults.
106 + // Production callers should always go through `with_fediverse()`
107 + // which loads `AuthConfig` once at app startup.
108 + AuthConfig {
109 + jwt_secret: "0".repeat(64),
110 + jwt_expiration_minutes: 15,
111 + jwt_issuer: "rustwarden".to_string(),
112 + refresh_token_expiration_days: 7,
113 + remember_me_expiration_days: 30,
114 + session_timeout_minutes: 30,
115 + session_backend: crate::config::auth::SessionBackend::Memory,
116 + redis_url: None,
117 + rate_limit_login_per_minute: 5,
118 + rate_limit_refresh_per_minute: 10,
119 + rate_limit_general_per_minute: 100,
120 + cookie_secure: false,
121 + cookie_same_site: "Strict".to_string(),
122 + }
123 +}
124 +
125 +/// Backwards-compatible alias for [`router_with`] kept until the next
126 +/// major version; new code should call `router_with` directly.
127 +#[deprecated(since = "0.2.0", note = "use `router_with` (RFC 0012)")]
128 +pub fn router_with_resolver(
129 + config: FediverseConfig,
130 + resolver: Arc<dyn LocalUserResolver>,
131 +) -> Router<DatabaseConnection> {
132 + let verifier: Arc<dyn SignatureVerifier> = Arc::new(AlwaysAcceptVerifier);
133 + router_with(config, resolver, verifier)
134 +}
A crates/rustwarden-core/src/fediverse/state.rs
+28 -0
@@ -0,0 +1,28 @@
1 +//! Shared state injected into federation handlers via Axum extensions.
2 +
3 +use std::sync::Arc;
4 +
5 +use crate::config::FediverseConfig;
6 +
7 +use super::resolver::LocalUserResolver;
8 +
9 +/// Injected into every federation handler via `Extension<FediverseState>`.
10 +/// Carries the runtime configuration and a handle to the user resolver.
11 +///
12 +/// Cheap to clone — the resolver is behind `Arc`, the config is `Clone`.
13 +#[derive(Clone)]
14 +pub struct FediverseState {
15 + /// Runtime configuration loaded by [`crate::app::RustwardenBuilder::with_fediverse`].
16 + pub config: FediverseConfig,
17 + /// User store projection used by handlers.
18 + pub resolver: Arc<dyn LocalUserResolver>,
19 +}
20 +
21 +impl std::fmt::Debug for FediverseState {
22 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23 + f.debug_struct("FediverseState")
24 + .field("config", &self.config)
25 + .field("resolver", &"<dyn LocalUserResolver>")
26 + .finish()
27 + }
28 +}
A crates/rustwarden-core/src/fediverse/types.rs
+219 -0
@@ -0,0 +1,219 @@
1 +//! JSON types serialised on federation endpoints (Person, WebFinger JRD,
2 +//! NodeInfo). All field names are stabilised to match the de facto Mastodon
3 +//! / W3C ActivityStreams contracts.
4 +
5 +use serde::{Deserialize, Serialize};
6 +use url::Url;
7 +
8 +/// JSON-LD `@context` shared by every actor we publish.
9 +///
10 +/// Includes: ActivityStreams 2.0 core vocabulary, W3C security vocabulary
11 +/// (`publicKey` namespace), and Mastodon's `toot:` extension namespace
12 +/// (covers `discoverable`, `indexable`, `Emoji`).
13 +pub fn actor_context() -> serde_json::Value {
14 + serde_json::json!([
15 + "https://www.w3.org/ns/activitystreams",
16 + "https://w3id.org/security/v1",
17 + {
18 + "toot": "http://joinmastodon.org/ns#",
19 + "discoverable": "toot:discoverable",
20 + "indexable": "toot:indexable",
21 + "Emoji": "toot:Emoji"
22 + }
23 + ])
24 +}
25 +
26 +/// ActivityPub Person actor (W3C ActivityStreams 2.0 + Security V1 +
27 +/// Mastodon extensions).
28 +///
29 +/// Field `public_key` is `None` until [RFC 0005](../../rfcs/0005-actor-key-storage.md)
30 +/// (`actor-key-storage`) lands. Until then, the actor is **discoverable** but
31 +/// not **federable** — Mastodon will refuse to deliver activities to an actor
32 +/// without a published `publicKey`.
33 +#[derive(Debug, Clone, Serialize, Deserialize)]
34 +#[serde(rename_all = "camelCase")]
35 +pub struct PersonJson {
36 + /// JSON-LD context, see [`actor_context`].
37 + #[serde(rename = "@context")]
38 + pub context: serde_json::Value,
39 + /// Always the literal string `"Person"`.
40 + #[serde(rename = "type")]
41 + pub kind: String,
42 + /// Canonical actor URL, e.g. `https://example.org/users/alice`.
43 + pub id: Url,
44 + /// Local username (the `alice` part of `@alice@example.org`).
45 + pub preferred_username: String,
46 + /// Optional display name shown in clients.
47 + #[serde(skip_serializing_if = "Option::is_none")]
48 + pub name: Option<String>,
49 + /// HTML bio (sanitised before insertion into DB; rendered as-is here).
50 + #[serde(skip_serializing_if = "Option::is_none")]
51 + pub summary: Option<String>,
52 + /// Profile page URL (HTML).
53 + #[serde(skip_serializing_if = "Option::is_none")]
54 + pub url: Option<Url>,
55 + /// Inbox endpoint where remote servers POST activities for this actor.
56 + pub inbox: Url,
57 + /// Outbox endpoint listing this actor's published activities.
58 + pub outbox: Url,
59 + /// Followers collection URL.
60 + pub followers: Url,
61 + /// Following collection URL.
62 + pub following: Url,
63 + /// Mastodon-compatible shared inbox endpoint (instance-wide).
64 + #[serde(skip_serializing_if = "Option::is_none")]
65 + pub endpoints: Option<EndpointsJson>,
66 + /// Public RSA key used to verify HTTP signatures. `None` until RFC 0005.
67 + #[serde(skip_serializing_if = "Option::is_none")]
68 + pub public_key: Option<PublicKeyJson>,
69 + /// When `true`, follow requests await manual approval.
70 + #[serde(default)]
71 + pub manually_approves_followers: bool,
72 + /// Mastodon `toot:discoverable` flag.
73 + #[serde(default = "default_true")]
74 + pub discoverable: bool,
75 +}
76 +
77 +fn default_true() -> bool {
78 + true
79 +}
80 +
81 +/// Public RSA key embedded in a [`PersonJson`].
82 +#[derive(Debug, Clone, Serialize, Deserialize)]
83 +#[serde(rename_all = "camelCase")]
84 +pub struct PublicKeyJson {
85 + /// Key identifier, conventionally `{actor_id}#main-key`.
86 + pub id: Url,
87 + /// URL of the actor that owns this key.
88 + pub owner: Url,
89 + /// PEM-encoded SubjectPublicKeyInfo (RSA-2048+).
90 + pub public_key_pem: String,
91 +}
92 +
93 +/// Mastodon-compatible `endpoints` object on a Person actor.
94 +#[derive(Debug, Clone, Serialize, Deserialize)]
95 +#[serde(rename_all = "camelCase")]
96 +pub struct EndpointsJson {
97 + /// Shared inbox URL (one per instance, used for fan-out optimisation).
98 + pub shared_inbox: Url,
99 +}
100 +
101 +// ───────────────────────── WebFinger (RFC 7033) ─────────────────────────
102 +
103 +/// WebFinger JRD response document.
104 +#[derive(Debug, Clone, Serialize, Deserialize)]
105 +pub struct WebFingerResponse {
106 + /// Subject URI, must echo the `resource` query parameter.
107 + pub subject: String,
108 + /// Optional alternative URIs for the same subject.
109 + #[serde(default, skip_serializing_if = "Vec::is_empty")]
110 + pub aliases: Vec<String>,
111 + /// Typed links pointing at the various representations of the subject.
112 + pub links: Vec<WebFingerLink>,
113 +}
114 +
115 +/// Single typed link inside a [`WebFingerResponse`].
116 +#[derive(Debug, Clone, Serialize, Deserialize)]
117 +pub struct WebFingerLink {
118 + /// Link relation, e.g. `"self"` or
119 + /// `"http://webfinger.net/rel/profile-page"`.
120 + pub rel: String,
121 + /// MIME type of the resource.
122 + #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
123 + pub media_type: Option<String>,
124 + /// Concrete URL of the linked resource (mutually exclusive with
125 + /// `template`).
126 + #[serde(skip_serializing_if = "Option::is_none")]
127 + pub href: Option<String>,
128 + /// URI template (RFC 6570) when the relation is interaction-driven.
129 + #[serde(skip_serializing_if = "Option::is_none")]
130 + pub template: Option<String>,
131 +}
132 +
133 +// ───────────────────────── NodeInfo (Diaspora) ──────────────────────────
134 +
135 +/// NodeInfo discovery document (`/.well-known/nodeinfo`).
136 +#[derive(Debug, Clone, Serialize, Deserialize)]
137 +pub struct NodeInfoDiscovery {
138 + /// Links pointing at concrete NodeInfo schema versions.
139 + pub links: Vec<NodeInfoLink>,
140 +}
141 +
142 +/// Single link in a [`NodeInfoDiscovery`] document.
143 +#[derive(Debug, Clone, Serialize, Deserialize)]
144 +pub struct NodeInfoLink {
145 + /// Schema relation, e.g.
146 + /// `http://nodeinfo.diaspora.software/ns/schema/2.1`.
147 + pub rel: String,
148 + /// URL of the concrete NodeInfo resource.
149 + pub href: String,
150 +}
151 +
152 +/// NodeInfo 2.1 detail document (`/nodeinfo/2.1`).
153 +#[derive(Debug, Clone, Serialize, Deserialize)]
154 +#[serde(rename_all = "camelCase")]
155 +pub struct NodeInfo {
156 + /// Always `"2.1"`.
157 + pub version: String,
158 + /// Software name + version metadata.
159 + pub software: NodeInfoSoftware,
160 + /// Federation protocols supported, always at least `["activitypub"]`.
161 + pub protocols: Vec<String>,
162 + /// Inbound/outbound services bridged by this instance (empty for now).
163 + pub services: NodeInfoServices,
164 + /// Aggregated usage counters.
165 + pub usage: NodeInfoUsage,
166 + /// Whether new account registration is open to the public.
167 + pub open_registrations: bool,
168 + /// Free-form metadata (instance description, contact, …).
169 + #[serde(default)]
170 + pub metadata: serde_json::Value,
171 +}
172 +
173 +/// Software identification block of a [`NodeInfo`].
174 +#[derive(Debug, Clone, Serialize, Deserialize)]
175 +pub struct NodeInfoSoftware {
176 + /// Lowercase software name, e.g. `"rustwarden"`.
177 + pub name: String,
178 + /// SemVer software version.
179 + pub version: String,
180 + /// Optional VCS repository URL.
181 + #[serde(skip_serializing_if = "Option::is_none")]
182 + pub repository: Option<String>,
183 + /// Optional homepage URL.
184 + #[serde(skip_serializing_if = "Option::is_none")]
185 + pub homepage: Option<String>,
186 +}
187 +
188 +/// Services bridge advertised by NodeInfo.
189 +#[derive(Debug, Clone, Default, Serialize, Deserialize)]
190 +pub struct NodeInfoServices {
191 + /// Inbound bridges (empty for Rustwarden).
192 + #[serde(default)]
193 + pub inbound: Vec<String>,
194 + /// Outbound bridges (empty for Rustwarden).
195 + #[serde(default)]
196 + pub outbound: Vec<String>,
197 +}
198 +
199 +/// Aggregated usage counters in [`NodeInfo`].
200 +#[derive(Debug, Clone, Serialize, Deserialize)]
201 +#[serde(rename_all = "camelCase")]
202 +pub struct NodeInfoUsage {
203 + /// User counters split by activity window.
204 + pub users: NodeInfoUsageUsers,
205 + /// Total number of locally-authored posts.
206 + pub local_posts: u64,
207 +}
208 +
209 +/// User-count split returned in [`NodeInfoUsage`].
210 +#[derive(Debug, Clone, Default, Serialize, Deserialize)]
211 +#[serde(rename_all = "camelCase")]
212 +pub struct NodeInfoUsageUsers {
213 + /// Total registered users.
214 + pub total: u64,
215 + /// Users active in the last 30 days.
216 + pub active_month: u64,
217 + /// Users active in the last 180 days.
218 + pub active_halfyear: u64,
219 +}
A crates/rustwarden-core/src/fediverse/webfinger.rs
+280 -0
@@ -0,0 +1,280 @@
1 +//! WebFinger handler (RFC 7033).
2 +//!
3 +//! Resolves `@user@domain` handles to ActivityPub actor URLs. Mounted at
4 +//! `GET /.well-known/webfinger`.
5 +
6 +use axum::{
7 + Extension, Json,
8 + extract::Query,
9 + http::{HeaderValue, StatusCode, header},
10 + response::{IntoResponse, Response},
11 +};
12 +use serde::Deserialize;
13 +
14 +use super::error::FediverseError;
15 +use super::state::FediverseState;
16 +use super::types::{WebFingerLink, WebFingerResponse};
17 +
18 +/// Query parameters for the WebFinger handler.
19 +#[derive(Debug, Deserialize)]
20 +pub struct WebFingerQuery {
21 + /// Handle being queried, e.g. `acct:alice@example.org`.
22 + pub resource: String,
23 +}
24 +
25 +/// `GET /.well-known/webfinger?resource=acct:user@domain`.
26 +///
27 +/// Returns a JRD describing how to find the actor's ActivityPub
28 +/// representation. Sets `Access-Control-Allow-Origin: *` (RFC 7033 §3.2).
29 +pub async fn handle(
30 + Query(query): Query<WebFingerQuery>,
31 + Extension(state): Extension<FediverseState>,
32 +) -> Result<Response, FediverseError> {
33 + let (username, domain) = parse_acct_resource(&query.resource)?;
34 +
35 + if domain != state.config.domain() {
36 + return Err(FediverseError::DomainMismatch);
37 + }
38 +
39 + let user = state
40 + .resolver
41 + .find_local_user(&username)
42 + .await?
43 + .ok_or(FediverseError::UserNotFound)?;
44 +
45 + let actor_url = format!("{}users/{}", state.config.base_url(), user.username);
46 + let profile_url = format!("{}@{}", state.config.base_url(), user.username);
47 +
48 + let body = WebFingerResponse {
49 + subject: query.resource.clone(),
50 + aliases: vec![actor_url.clone(), profile_url.clone()],
51 + links: vec![
52 + WebFingerLink {
53 + rel: "self".to_string(),
54 + media_type: Some("application/activity+json".to_string()),
55 + href: Some(actor_url),
56 + template: None,
57 + },
58 + WebFingerLink {
59 + rel: "http://webfinger.net/rel/profile-page".to_string(),
60 + media_type: Some("text/html".to_string()),
61 + href: Some(profile_url),
62 + template: None,
63 + },
64 + ],
65 + };
66 +
67 + Ok((
68 + StatusCode::OK,
69 + [
70 + (
71 + header::CONTENT_TYPE,
72 + HeaderValue::from_static("application/jrd+json"),
73 + ),
74 + (
75 + header::ACCESS_CONTROL_ALLOW_ORIGIN,
76 + HeaderValue::from_static("*"),
77 + ),
78 + ],
79 + Json(body),
80 + )
81 + .into_response())
82 +}
83 +
84 +/// Parse a `resource=acct:user@domain` value into `(username, domain)`.
85 +///
86 +/// Returns [`FediverseError::InvalidResource`] if the prefix is missing or
87 +/// the `@` separator is absent or any side is empty.
88 +pub(crate) fn parse_acct_resource(resource: &str) -> Result<(String, String), FediverseError> {
89 + let acct = resource
90 + .strip_prefix("acct:")
91 + .ok_or_else(|| FediverseError::InvalidResource(format!("missing acct: prefix in {resource}")))?;
92 +
93 + let (user, domain) = acct.split_once('@').ok_or_else(|| {
94 + FediverseError::InvalidResource(format!("missing @ separator in {resource}"))
95 + })?;
96 +
97 + if user.is_empty() || domain.is_empty() {
98 + return Err(FediverseError::InvalidResource(format!(
99 + "empty user or domain in {resource}"
100 + )));
101 + }
102 +
103 + Ok((user.to_string(), domain.to_string()))
104 +}
105 +
106 +#[cfg(test)]
107 +mod tests {
108 + use super::*;
109 + use crate::config::FediverseConfig;
110 + use crate::fediverse::resolver::{LocalUserResolver, UserSummary};
111 + use async_trait::async_trait;
112 + use axum::Router;
113 + use axum::body::Body;
114 + use axum::http::Request;
115 + use axum::routing::get;
116 + use std::sync::Arc;
117 + use tower::ServiceExt;
118 + use uuid::Uuid;
119 +
120 + /// Stub resolver returning predefined users.
121 + struct StubResolver {
122 + user: Option<UserSummary>,
123 + }
124 +
125 + #[async_trait]
126 + impl LocalUserResolver for StubResolver {
127 + async fn find_local_user(
128 + &self,
129 + _username: &str,
130 + ) -> Result<Option<UserSummary>, crate::error::AppError> {
131 + Ok(self.user.clone())
132 + }
133 + async fn count_users_total(&self) -> Result<u64, crate::error::AppError> {
134 + Ok(0)
135 + }
136 + async fn count_users_active_since(&self, _days: u32) -> Result<u64, crate::error::AppError> {
137 + Ok(0)
138 + }
139 + async fn count_local_posts(&self) -> Result<u64, crate::error::AppError> {
140 + Ok(0)
141 + }
142 + }
143 +
144 + fn router_with_resolver(domain: &str, user: Option<UserSummary>) -> Router {
145 + let state = FediverseState {
146 + config: FediverseConfig::for_tests(domain),
147 + resolver: Arc::new(StubResolver { user }),
148 + };
149 + Router::new()
150 + .route("/.well-known/webfinger", get(handle))
151 + .layer(Extension(state))
152 + }
153 +
154 + fn alice() -> UserSummary {
155 + UserSummary {
156 + id: Uuid::new_v4(),
157 + username: "alice".into(),
158 + display_name: Some("Alice".into()),
159 + bio: None,
160 + ap_public_key_pem: None,
161 + }
162 + }
163 +
164 + async fn body_json(response: Response) -> serde_json::Value {
165 + let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024)
166 + .await
167 + .expect("body bytes");
168 + serde_json::from_slice(&bytes).expect("body is valid JSON")
169 + }
170 +
171 + #[tokio::test]
172 + async fn webfinger_returns_jrd_for_existing_local_user() {
173 + let app = router_with_resolver("example.org", Some(alice()));
174 +
175 + let response = app
176 + .oneshot(
177 + Request::builder()
178 + .uri("/.well-known/webfinger?resource=acct:alice@example.org")
179 + .body(Body::empty())
180 + .unwrap(),
181 + )
182 + .await
183 + .unwrap();
184 +
185 + assert_eq!(response.status(), StatusCode::OK);
186 + assert_eq!(
187 + response
188 + .headers()
189 + .get(header::CONTENT_TYPE)
190 + .map(HeaderValue::to_str)
191 + .transpose()
192 + .unwrap()
193 + .unwrap_or_default(),
194 + "application/jrd+json"
195 + );
196 + assert_eq!(
197 + response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN),
198 + Some(&HeaderValue::from_static("*"))
199 + );
200 +
201 + let body = body_json(response).await;
202 + assert_eq!(body["subject"], "acct:alice@example.org");
203 + let self_link = body["links"]
204 + .as_array()
205 + .expect("links array")
206 + .iter()
207 + .find(|l| l["rel"] == "self")
208 + .expect("self link present");
209 + assert_eq!(self_link["type"], "application/activity+json");
210 + assert_eq!(self_link["href"], "https://example.org/users/alice");
211 + }
212 +
213 + #[tokio::test]
214 + async fn webfinger_returns_404_for_unknown_user() {
215 + let app = router_with_resolver("example.org", None);
216 +
217 + let response = app
218 + .oneshot(
219 + Request::builder()
220 + .uri("/.well-known/webfinger?resource=acct:bob@example.org")
221 + .body(Body::empty())
222 + .unwrap(),
223 + )
224 + .await
225 + .unwrap();
226 +
227 + assert_eq!(response.status(), StatusCode::NOT_FOUND);
228 + }
229 +
230 + #[tokio::test]
231 + async fn webfinger_returns_404_when_domain_mismatches() {
232 + let app = router_with_resolver("example.org", Some(alice()));
233 +
234 + let response = app
235 + .oneshot(
236 + Request::builder()
237 + .uri("/.well-known/webfinger?resource=acct:alice@other.tld")
238 + .body(Body::empty())
239 + .unwrap(),
240 + )
241 + .await
242 + .unwrap();
243 +
244 + assert_eq!(response.status(), StatusCode::NOT_FOUND);
245 + }
246 +
247 + #[tokio::test]
248 + async fn webfinger_returns_400_for_malformed_resource() {
249 + let app = router_with_resolver("example.org", Some(alice()));
250 +
251 + for bad in [
252 + "/.well-known/webfinger?resource=alice@example.org",
253 + "/.well-known/webfinger?resource=acct:noatdomain",
254 + "/.well-known/webfinger?resource=acct:",
255 + ] {
256 + let response = app
257 + .clone()
258 + .oneshot(Request::builder().uri(bad).body(Body::empty()).unwrap())
259 + .await
260 + .unwrap();
261 + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "bad uri={bad}");
262 + }
263 + }
264 +
265 + #[test]
266 + fn parse_acct_resource_accepts_valid_handle() {
267 + let (user, domain) = parse_acct_resource("acct:alice@example.org").expect("valid");
268 + assert_eq!(user, "alice");
269 + assert_eq!(domain, "example.org");
270 + }
271 +
272 + #[test]
273 + fn parse_acct_resource_rejects_missing_prefix() {
274 + let err = parse_acct_resource("alice@example.org").expect_err("must fail");
275 + assert!(
276 + matches!(err, FediverseError::InvalidResource(_)),
277 + "got {err:?}"
278 + );
279 + }
280 +}
A crates/rustwarden-core/src/identity.rs
+204 -0
@@ -0,0 +1,204 @@
1 +//! Unified actor abstraction (RFC 0008).
2 +//!
3 +//! [`Identity`] is the type that services should accept everywhere a
4 +//! handler used to take `Uuid /* users.id */`. Discriminating local
5 +//! and remote callers at the type level prevents the confused-deputy
6 +//! hazard: an authoriser cannot accidentally treat a federated subject
7 +//! as if it had local roles.
8 +//!
9 +//! Gated by `experimental-fediverse` (the trait/enum compile only when
10 +//! the feature is on, since RFC 0009 is the first consumer).
11 +
12 +use async_trait::async_trait;
13 +use serde::{Deserialize, Serialize};
14 +use uuid::Uuid;
15 +
16 +use crate::auth::jwt::Claims;
17 +use crate::error::AppError;
18 +
19 +/// Universally unique reference to an actor known to Rustwarden.
20 +///
21 +/// The `kind` discriminator goes on the wire via the JSON tag form,
22 +/// e.g. `{"kind":"local","id":"<uuid>"}`. Variant data is just the
23 +/// UUID — services lookup metadata (username, display name) on demand
24 +/// through [`IdentityResolver`].
25 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
26 +#[serde(rename_all = "snake_case", tag = "kind", content = "id")]
27 +pub enum Identity {
28 + /// `users.id` — a locally-registered account.
29 + Local(Uuid),
30 + /// `remote_identities.id` — a federated user that authenticated
31 + /// via the AP-native flow of RFC 0006.
32 + Remote(Uuid),
33 +}
34 +
35 +impl Identity {
36 + /// Returns true when this identity is local.
37 + pub fn is_local(&self) -> bool {
38 + matches!(self, Self::Local(_))
39 + }
40 +
41 + /// Returns true when this identity is remote.
42 + pub fn is_remote(&self) -> bool {
43 + matches!(self, Self::Remote(_))
44 + }
45 +
46 + /// Returns the underlying UUID regardless of variant. Use
47 + /// [`Self::as_local`] / [`Self::as_remote`] when you must
48 + /// distinguish.
49 + pub fn id(&self) -> Uuid {
50 + match self {
51 + Self::Local(id) | Self::Remote(id) => *id,
52 + }
53 + }
54 +
55 + /// `Some(uuid)` only when `self` is `Local`.
56 + pub fn as_local(&self) -> Option<Uuid> {
57 + match self {
58 + Self::Local(id) => Some(*id),
59 + Self::Remote(_) => None,
60 + }
61 + }
62 +
63 + /// `Some(uuid)` only when `self` is `Remote`.
64 + pub fn as_remote(&self) -> Option<Uuid> {
65 + match self {
66 + Self::Remote(id) => Some(*id),
67 + Self::Local(_) => None,
68 + }
69 + }
70 +
71 + /// Build an `Identity` from RFC 0007 [`Claims`]. Falls back to
72 + /// `Local(claims.sub)` when the claims look local; uses
73 + /// [`Claims::remote_identity_id`] (which already guards against
74 + /// the confused-deputy case) for remote tokens.
75 + pub fn from_claims(claims: &Claims) -> Self {
76 + match claims.remote_identity_id() {
77 + Some(remote_id) => Self::Remote(remote_id),
78 + None => Self::Local(claims.sub),
79 + }
80 + }
81 +}
82 +
83 +/// Look-up facade returning a unified [`Identity`] regardless of the
84 +/// underlying table. Implementations are responsible for translating
85 +/// from raw IDs to the correct variant; services that only need to
86 +/// check existence can use [`Self::resolve_local`] /
87 +/// [`Self::resolve_remote`] for the bare paths.
88 +#[async_trait]
89 +pub trait IdentityResolver: Send + Sync {
90 + /// Returns `Some(Identity::Local(user_id))` if the row exists in
91 + /// `users`, `None` otherwise.
92 + async fn resolve_local(&self, user_id: Uuid) -> Result<Option<Identity>, AppError>;
93 +
94 + /// Returns `Some(Identity::Remote(remote_identity_id))` if the row
95 + /// exists in `remote_identities`, `None` otherwise.
96 + async fn resolve_remote(
97 + &self,
98 + remote_identity_id: Uuid,
99 + ) -> Result<Option<Identity>, AppError>;
100 +}
101 +
102 +#[cfg(test)]
103 +mod tests {
104 + use super::*;
105 + use crate::auth::jwt::IdentityKind;
106 + use chrono::Utc;
107 +
108 + fn make_claims(kind: IdentityKind, remote_id: Option<Uuid>) -> Claims {
109 + Claims {
110 + sub: Uuid::new_v4(),
111 + jti: Uuid::new_v4().to_string(),
112 + aud: "rustwarden".into(),
113 + username: "alice".into(),
114 + roles: vec![],
115 + permissions: vec![],
116 + exp: Utc::now().timestamp() + 900,
117 + iat: Utc::now().timestamp(),
118 + iss: "rustwarden".into(),
119 + identity_kind: kind,
120 + remote_id,
121 + }
122 + }
123 +
124 + #[test]
125 + fn identity_local_helpers() {
126 + let id = Uuid::new_v4();
127 + let identity = Identity::Local(id);
128 + assert!(identity.is_local());
129 + assert!(!identity.is_remote());
130 + assert_eq!(identity.id(), id);
131 + assert_eq!(identity.as_local(), Some(id));
132 + assert_eq!(identity.as_remote(), None);
133 + }
134 +
135 + #[test]
136 + fn identity_remote_helpers() {
137 + let id = Uuid::new_v4();
138 + let identity = Identity::Remote(id);
139 + assert!(identity.is_remote());
140 + assert!(!identity.is_local());
141 + assert_eq!(identity.id(), id);
142 + assert_eq!(identity.as_remote(), Some(id));
143 + assert_eq!(identity.as_local(), None);
144 + }
145 +
146 + #[test]
147 + fn identity_serialises_with_kind_and_id() {
148 + let id = Uuid::new_v4();
149 + let local = Identity::Local(id);
150 + let value = serde_json::to_value(&local).unwrap();
151 + assert_eq!(value["kind"], serde_json::json!("local"));
152 + assert_eq!(value["id"], serde_json::json!(id));
153 + let round_trip: Identity = serde_json::from_value(value).unwrap();
154 + assert_eq!(round_trip, local);
155 + }
156 +
157 + #[test]
158 + fn identity_from_claims_local() {
159 + let claims = make_claims(IdentityKind::Local, None);
160 + let identity = Identity::from_claims(&claims);
161 + assert_eq!(identity, Identity::Local(claims.sub));
162 + }
163 +
164 + #[test]
165 + fn identity_from_claims_remote() {
166 + let remote_id = Uuid::new_v4();
167 + let claims = make_claims(IdentityKind::Remote, Some(remote_id));
168 + let identity = Identity::from_claims(&claims);
169 + assert_eq!(identity, Identity::Remote(remote_id));
170 + }
171 +
172 + #[test]
173 + fn identity_from_claims_remote_without_remote_id_falls_back_to_local() {
174 + // Confused-deputy guard: a Remote token without a remote_id is
175 + // treated as local since `Claims::remote_identity_id` returns
176 + // None in that case. RFC 0007 / RFC 0008 ensure we never
177 + // promote silently.
178 + let claims = make_claims(IdentityKind::Remote, None);
179 + let identity = Identity::from_claims(&claims);
180 + // remote_identity_id() returns None because remote_id is None,
181 + // so we fall through to Local(sub).
182 + assert_eq!(identity, Identity::Local(claims.sub));
183 + }
184 +
185 + #[test]
186 + fn identity_eq_hash() {
187 + use std::collections::HashSet;
188 + let id = Uuid::new_v4();
189 + let mut set = HashSet::new();
190 + set.insert(Identity::Local(id));
191 + assert!(set.contains(&Identity::Local(id)));
192 + assert!(!set.contains(&Identity::Remote(id)));
193 + }
194 +
195 + #[test]
196 + fn identity_clone_copy() {
197 + let id = Uuid::new_v4();
198 + let original = Identity::Remote(id);
199 + let copied = original;
200 + let cloned = original.clone();
201 + assert_eq!(original, copied);
202 + assert_eq!(original, cloned);
203 + }
204 +}
M crates/rustwarden-core/src/lib.rs
+6 -0
@@ -11,6 +11,12 @@ pub mod auth;
11 11 pub mod config;
12 12 /// Application error types and HTTP response conversion.
13 13 pub mod error;
14 +/// ActivityPub identity federation (RFC 0001+, gated by `experimental-fediverse`).
15 +#[cfg(feature = "experimental-fediverse")]
16 +pub mod fediverse;
17 +/// Unified actor abstraction (RFC 0008, gated by `experimental-fediverse`).
18 +#[cfg(feature = "experimental-fediverse")]
19 +pub mod identity;
14 20 /// Extension hooks for consumer applications to react to framework events.
15 21 pub mod hooks;
16 22 /// Internationalization utilities.
M crates/rustwarden-core/src/models/audit_log.rs
+9 -0
@@ -25,6 +25,13 @@ pub struct Model {
25 25 pub message: String,
26 26 /// Creation timestamp
27 27 pub created_at: DateTime<Utc>,
28 + /// RFC 0008: actor `remote_identities.id` when the action was
29 + /// performed by a federated identity (mutually exclusive with
30 + /// `user_id` at the application level).
31 + pub actor_remote_identity_id: Option<Uuid>,
32 + /// RFC 0008: target `remote_identities.id` when the action targets
33 + /// a federated identity (mutually exclusive with `target_user_id`).
34 + pub target_remote_identity_id: Option<Uuid>,
28 35 }
29 36
30 37 /// SeaORM relations for the AuditLog entity.
@@ -73,6 +80,8 @@ mod tests {
73 80 level: "INFO".to_string(),
74 81 message: "User created".to_string(),
75 82 created_at: now,
83 + actor_remote_identity_id: None,
84 + target_remote_identity_id: None,
76 85 };
77 86
78 87 assert_eq!(log.id, id);
A crates/rustwarden-core/src/models/fediverse_auth_challenge.rs
+46 -0
@@ -0,0 +1,46 @@
1 +//! SeaORM entity for `fediverse_auth_challenges` (RFC 0006).
2 +//!
3 +//! State machine row backing the AP-native authentication flow on the
4 +//! client (`b.tld`) side.
5 +
6 +use chrono::{DateTime, Utc};
7 +use sea_orm::entity::prelude::*;
8 +use serde::{Deserialize, Serialize};
9 +use uuid::Uuid;
10 +
11 +/// Persistent record of one in-flight authentication challenge.
12 +#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)]
13 +#[sea_orm(table_name = "fediverse_auth_challenges")]
14 +pub struct Model {
15 + /// Stable PK.
16 + #[sea_orm(primary_key, auto_increment = false)]
17 + pub id: Uuid,
18 + /// 256-bit URL-safe base64 nonce embedded in the AP `AuthRequest`
19 + /// activity. Must match the nonce echoed by the IdP `Accept`.
20 + #[sea_orm(unique)]
21 + pub nonce: String,
22 + /// `@user@domain` handle the user typed (verbatim).
23 + pub handle: String,
24 + /// Domain part of the handle (lowercased).
25 + pub domain: String,
26 + /// Optional FK to `remote_identities.id` once the IdP has approved
27 + /// the challenge.
28 + pub requested_remote_id: Option<Uuid>,
29 + /// `pending`, `approved`, `rejected`, `expired`, or `consumed`.
30 + pub status: String,
31 + /// Where to redirect the browser after a successful approve.
32 + pub redirect_url: Option<String>,
33 + /// Insertion timestamp.
34 + pub created_at: DateTime<Utc>,
35 + /// Hard deadline (`created_at + ttl`) after which the row is
36 + /// considered `Expired` regardless of status.
37 + pub expires_at: DateTime<Utc>,
38 + /// Set the moment a JWT is minted from this challenge (one-shot).
39 + pub consumed_at: Option<DateTime<Utc>>,
40 +}
41 +
42 +/// SeaORM relations (none modelled here).
43 +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
44 +pub enum Relation {}
45 +
46 +impl ActiveModelBehavior for ActiveModel {}
A crates/rustwarden-core/src/models/fediverse_blocked_domain.rs
+35 -0
@@ -0,0 +1,35 @@
1 +//! `fediverse_blocked_domains` SeaORM entity (RFC 0004).
2 +//!
3 +//! Blocklist of remote Fediverse domains explicitly refused by the local
4 +//! admin. Used when `app_settings.fediverse_trust_mode = "open_with_blocklist"`.
5 +
6 +use chrono::{DateTime, Utc};
7 +use sea_orm::entity::prelude::*;
8 +use serde::{Deserialize, Serialize};
9 +use uuid::Uuid;
10 +
11 +/// `fediverse_blocked_domains` row.
12 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, DeriveEntityModel)]
13 +#[sea_orm(table_name = "fediverse_blocked_domains")]
14 +pub struct Model {
15 + /// Primary key.
16 + #[sea_orm(primary_key, auto_increment = false)]
17 + pub id: Uuid,
18 + /// Bare domain name, lowercased and stripped of `www.` prefix.
19 + #[sea_orm(unique)]
20 + pub domain: String,
21 + /// `"suspend"`, `"silence"`, or `"reject_auth"`.
22 + pub severity: String,
23 + /// Optional admin reason for blocking.
24 + pub reason: Option<String>,
25 + /// User that added the entry.
26 + pub added_by_user_id: Option<Uuid>,
27 + /// Insertion timestamp.
28 + pub created_at: DateTime<Utc>,
29 +}
30 +
31 +/// SeaORM relations (none).
32 +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
33 +pub enum Relation {}
34 +
35 +impl ActiveModelBehavior for ActiveModel {}
A crates/rustwarden-core/src/models/fediverse_outbound_queue.rs
+51 -0
@@ -0,0 +1,51 @@
1 +//! SeaORM entity for the `fediverse_outbound_queue` table (RFC 0011).
2 +//!
3 +//! Persistent journal of ActivityPub activities that must be delivered
4 +//! to remote inboxes by [`crate::services::fediverse_outbound_service::OutboundWorker`].
5 +
6 +use chrono::{DateTime, Utc};
7 +use sea_orm::entity::prelude::*;
8 +use serde::{Deserialize, Serialize};
9 +use uuid::Uuid;
10 +
11 +/// Persistent record of one outbound delivery (one-to-one with the
12 +/// `fediverse_outbound_queue` row).
13 +#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)]
14 +#[sea_orm(table_name = "fediverse_outbound_queue")]
15 +pub struct Model {
16 + /// Stable PK.
17 + #[sea_orm(primary_key, auto_increment = false)]
18 + pub id: Uuid,
19 + /// Canonical AP `id` of the activity being delivered.
20 + pub activity_id: String,
21 + /// Pre-rendered JSON-LD body posted to `target_inbox`.
22 + pub payload_json: String,
23 + /// Remote inbox URL.
24 + pub target_inbox: String,
25 + /// Domain the inbox belongs to (used for trust checks).
26 + pub target_domain: String,
27 + /// `pending`, `delivered`, `failed`, or `dead`.
28 + pub status: String,
29 + /// Number of delivery attempts already performed.
30 + pub attempts: i32,
31 + /// Maximum attempts before the row is moved to `dead`.
32 + pub max_attempts: i32,
33 + /// Last delivery error (truncated).
34 + pub last_error: Option<String>,
35 + /// Earliest moment the row is eligible for re-delivery.
36 + pub next_retry_at: Option<DateTime<Utc>>,
37 + /// Insertion timestamp.
38 + pub created_at: DateTime<Utc>,
39 + /// Successful delivery timestamp.
40 + pub delivered_at: Option<DateTime<Utc>>,
41 + /// Local user the activity is sent on behalf of (used for HTTP-Sig
42 + /// signing in RFC 0010; nullable while only `NoopDelivery` is
43 + /// shipped).
44 + pub actor_user_id: Option<Uuid>,
45 +}
46 +
47 +/// SeaORM relations (none for this table).
48 +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
49 +pub enum Relation {}
50 +
51 +impl ActiveModelBehavior for ActiveModel {}
A crates/rustwarden-core/src/models/fediverse_pending_invite.rs
+49 -0
@@ -0,0 +1,49 @@
1 +//! SeaORM entity for `fediverse_pending_invites` (RFC 0009).
2 +//!
3 +//! Persistent record of cross-instance team-membership invitations
4 +//! issued by a Rustwarden host (`b.tld`) towards a remote user
5 +//! (`alice@a.tld`). The activity reference (`activity_id`) lets the
6 +//! AP `Accept{Invite}` / `Reject{Invite}` callbacks find the right
7 +//! row without ambiguity.
8 +
9 +use chrono::{DateTime, Utc};
10 +use sea_orm::entity::prelude::*;
11 +use serde::{Deserialize, Serialize};
12 +use uuid::Uuid;
13 +
14 +/// Persistent record of one in-flight invitation.
15 +#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)]
16 +#[sea_orm(table_name = "fediverse_pending_invites")]
17 +pub struct Model {
18 + /// Stable PK.
19 + #[sea_orm(primary_key, auto_increment = false)]
20 + pub id: Uuid,
21 + /// AP `Invite` activity id (e.g. `https://b.tld/invites/<uuid>`).
22 + #[sea_orm(unique)]
23 + pub activity_id: String,
24 + /// FK to `teams.id`.
25 + pub team_id: Uuid,
26 + /// `@user@domain` handle invited.
27 + pub invited_handle: String,
28 + /// FK to `remote_identities.id`, populated when the AP `Accept`
29 + /// arrives and the remote actor is fully resolved.
30 + pub invited_remote_identity_id: Option<Uuid>,
31 + /// Team role to assign on accept (`"member"`, `"admin"`).
32 + pub role: String,
33 + /// FK to `users.id` of the inviter.
34 + pub invited_by_user_id: Uuid,
35 + /// `pending`, `accepted`, `rejected`, `revoked`, or `expired`.
36 + pub status: String,
37 + /// Insertion timestamp.
38 + pub created_at: DateTime<Utc>,
39 + /// Hard deadline.
40 + pub expires_at: DateTime<Utc>,
41 + /// Set when the invite reaches a terminal state.
42 + pub decided_at: Option<DateTime<Utc>>,
43 +}
44 +
45 +/// SeaORM relations (none modelled here).
46 +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
47 +pub enum Relation {}
48 +
49 +impl ActiveModelBehavior for ActiveModel {}
A crates/rustwarden-core/src/models/fediverse_trusted_domain.rs
+33 -0
@@ -0,0 +1,33 @@
1 +//! `fediverse_trusted_domains` SeaORM entity (RFC 0004).
2 +//!
3 +//! Allowlist of remote Fediverse domains explicitly trusted by the local
4 +//! admin. Used when `app_settings.fediverse_trust_mode = "allowlist"`.
5 +
6 +use chrono::{DateTime, Utc};
7 +use sea_orm::entity::prelude::*;
8 +use serde::{Deserialize, Serialize};
9 +use uuid::Uuid;
10 +
11 +/// `fediverse_trusted_domains` row.
12 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, DeriveEntityModel)]
13 +#[sea_orm(table_name = "fediverse_trusted_domains")]
14 +pub struct Model {
15 + /// Primary key.
16 + #[sea_orm(primary_key, auto_increment = false)]
17 + pub id: Uuid,
18 + /// Bare domain name, lowercased and stripped of `www.` prefix.
19 + #[sea_orm(unique)]
20 + pub domain: String,
21 + /// Optional admin note (motivation for the trust).
22 + pub note: Option<String>,
23 + /// User that added the entry (NULL for bootstrap / system insertion).
24 + pub added_by_user_id: Option<Uuid>,
25 + /// Insertion timestamp.
26 + pub created_at: DateTime<Utc>,
27 +}
28 +
29 +/// SeaORM relations (none).
30 +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
31 +pub enum Relation {}
32 +
33 +impl ActiveModelBehavior for ActiveModel {}
M crates/rustwarden-core/src/models/mod.rs
+27 -0
@@ -27,6 +27,27 @@ pub mod permission;
27 27 pub mod personal_access_token;
28 28 /// Refresh token entity for JWT rotation.
29 29 pub mod refresh_token;
30 +/// Remote ActivityPub identity cache (RFC 0003, gated by `experimental-fediverse`).
31 +#[cfg(feature = "experimental-fediverse")]
32 +pub mod remote_identity;
33 +/// Federation blocklist (RFC 0004, gated by `experimental-fediverse`).
34 +#[cfg(feature = "experimental-fediverse")]
35 +pub mod fediverse_blocked_domain;
36 +/// Federation allowlist (RFC 0004, gated by `experimental-fediverse`).
37 +#[cfg(feature = "experimental-fediverse")]
38 +pub mod fediverse_trusted_domain;
39 +/// Persistent outbound delivery queue (RFC 0011,
40 +/// gated by `experimental-fediverse`).
41 +#[cfg(feature = "experimental-fediverse")]
42 +pub mod fediverse_outbound_queue;
43 +/// AP-native authentication challenge (RFC 0006,
44 +/// gated by `experimental-fediverse`).
45 +#[cfg(feature = "experimental-fediverse")]
46 +pub mod fediverse_auth_challenge;
47 +/// Cross-instance team-membership invitation (RFC 0009,
48 +/// gated by `experimental-fediverse`).
49 +#[cfg(feature = "experimental-fediverse")]
50 +pub mod fediverse_pending_invite;
30 51 /// Generic resource registry entity.
31 52 pub mod resource;
32 53 /// Resource share entity granting access to a principal.
@@ -63,6 +84,12 @@ pub use password_reset_token::Entity as PasswordResetToken;
63 84 pub use permission::Entity as Permission;
64 85 pub use personal_access_token::Entity as PersonalAccessToken;
65 86 pub use refresh_token::Entity as RefreshToken;
87 +#[cfg(feature = "experimental-fediverse")]
88 +pub use remote_identity::Entity as RemoteIdentity;
89 +#[cfg(feature = "experimental-fediverse")]
90 +pub use fediverse_blocked_domain::Entity as FediverseBlockedDomain;
91 +#[cfg(feature = "experimental-fediverse")]
92 +pub use fediverse_trusted_domain::Entity as FediverseTrustedDomain;
66 93 pub use resource::Entity as Resource;
67 94 pub use resource_share::Entity as ResourceShare;
68 95 pub use role::Entity as Role;
A crates/rustwarden-core/src/models/remote_identity.rs
+61 -0
@@ -0,0 +1,61 @@
1 +//! Remote ActivityPub identity SeaORM entity (RFC 0003).
2 +//!
3 +//! Local cache of remote actors resolved via WebFinger + actor fetch.
4 +//! Created and maintained by
5 +//! [`RemoteIdentityService`](crate::services::remote_identity_service::RemoteIdentityService).
6 +//!
7 +//! Gated by the `experimental-fediverse` Cargo feature.
8 +
9 +use chrono::{DateTime, Utc};
10 +use sea_orm::entity::prelude::*;
11 +use serde::{Deserialize, Serialize};
12 +use uuid::Uuid;
13 +
14 +/// `remote_identities` row.
15 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, DeriveEntityModel)]
16 +#[sea_orm(table_name = "remote_identities")]
17 +pub struct Model {
18 + /// Stable internal identifier.
19 + #[sea_orm(primary_key, auto_increment = false)]
20 + pub id: Uuid,
21 + /// Canonical ActivityPub identifier of the actor (e.g.
22 + /// `https://other.tld/users/alice`). Unique.
23 + #[sea_orm(unique)]
24 + pub ap_id: String,
25 + /// Local part of the handle (`alice` in `@alice@other.tld`).
26 + pub username: String,
27 + /// Remote domain (`other.tld`).
28 + pub domain: String,
29 + /// Optional display name advertised by the actor.
30 + pub display_name: Option<String>,
31 + /// Optional bio in HTML.
32 + pub summary_html: Option<String>,
33 + /// Inbox URL where activities targeting this actor must be POSTed.
34 + pub inbox_url: String,
35 + /// Optional shared inbox (instance-wide fan-out optimisation).
36 + pub shared_inbox_url: Option<String>,
37 + /// PEM-encoded RSA public key used for HTTP signature verification.
38 + pub public_key_pem: String,
39 + /// Optional avatar URL.
40 + pub icon_url: Option<String>,
41 + /// `Person`, `Group`, `Service`, `Application`, `Organization`.
42 + pub actor_type: String,
43 + /// Raw JSON document fetched from the remote actor URL (kept for
44 + /// future re-processing if the protocol evolves).
45 + pub raw_json: Option<serde_json::Value>,
46 + /// Timestamp of the last successful fetch — used by the cache TTL check.
47 + pub last_fetched_at: DateTime<Utc>,
48 + /// Set when the remote IdP signals account deletion (`Delete{Actor}`).
49 + pub disabled_at: Option<DateTime<Utc>>,
50 + /// Row creation timestamp.
51 + pub created_at: DateTime<Utc>,
52 + /// Last row mutation timestamp.
53 + pub updated_at: DateTime<Utc>,
54 +}
55 +
56 +/// SeaORM relations for `remote_identities` (none yet — joined references
57 +/// arrive with RFC 0008).
58 +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
59 +pub enum Relation {}
60 +
61 +impl ActiveModelBehavior for ActiveModel {}
M crates/rustwarden-core/src/models/resource_share.rs
+5 -0
@@ -23,6 +23,11 @@ pub struct Model {
23 23 /// The team the resource is shared with (`Some` for team-principal shares).
24 24 #[sea_orm(nullable)]
25 25 pub shared_with_team_id: Option<Uuid>,
26 + /// RFC 0008: the remote identity the resource is shared with for
27 + /// cross-instance shares. Mutually exclusive with `shared_with_user_id`
28 + /// and `shared_with_team_id` once RFC 0009 lands the extended XOR.
29 + #[sea_orm(nullable)]
30 + pub shared_with_remote_identity_id: Option<Uuid>,
26 31 /// Permission level: "read", "write", or "admin"
27 32 pub permission_level: String,
28 33 /// The user who created this share
M crates/rustwarden-core/src/models/team_member.rs
+4 -0
@@ -18,6 +18,10 @@ pub struct Model {
18 18 pub role: String,
19 19 /// Timestamp when the user joined (or was added to) the team.
20 20 pub joined_at: DateTime<Utc>,
21 + /// RFC 0008: optional FK to `remote_identities.id` for cross-instance
22 + /// memberships. Mutually exclusive with `user_id` at the row level
23 + /// (enforced once RFC 0009 lands the CHECK + relaxed PK).
24 + pub remote_identity_id: Option<Uuid>,
21 25 }
22 26
23 27 /// SeaORM relations for the TeamMember entity.
M crates/rustwarden-core/src/models/user.rs
+14 -0
@@ -37,6 +37,20 @@ pub struct Model {
37 37 pub created_at: DateTime<Utc>,
38 38 /// Last update timestamp
39 39 pub updated_at: DateTime<Utc>,
40 + /// ActivityPub public key PEM (SPKI). `None` until first AP resolution.
41 + ///
42 + /// Populated lazily by `ActorKeyService::ensure_keys` (RFC 0005). The
43 + /// column is added by migration
44 + /// `m20260510_000011_extend_users_with_actor_keys`.
45 + pub ap_public_key_pem: Option<String>,
46 + /// ActivityPub private key PEM (PKCS#8), encrypted at rest via
47 + /// `CryptoService` (`enc:` prefix + AES-256-GCM). Never returned in
48 + /// clear except by `ActorKeyService::ensure_keys` to its caller.
49 + pub ap_private_key_pem: Option<String>,
50 + /// Whether this user wants to be discoverable by remote fediverse
51 + /// search. Defaults to `true`. Read by NodeInfo and (later) by
52 + /// fediverse search endpoints.
53 + pub ap_discoverable: bool,
40 54 }
41 55
42 56 /// SeaORM relations for the User entity (none defined here; relations are declared on child entities).
A crates/rustwarden-core/src/services/actor_key_service.rs
+399 -0
@@ -0,0 +1,399 @@
1 +//! ActivityPub actor-key generation and storage (RFC 0005).
2 +//!
3 +//! Generates RSA-2048 PKCS#8 / SPKI key pairs for local users on demand,
4 +//! encrypts the private half via [`crate::services::crypto_service::CryptoService`],
5 +//! and persists both halves on the `users` row. The public PEM is the
6 +//! one Mastodon and other ActivityPub servers read from
7 +//! `/users/{username}` to verify HTTP signatures.
8 +//!
9 +//! Gated by `experimental-fediverse`.
10 +
11 +use rsa::pkcs1::EncodeRsaPublicKey;
12 +use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding};
13 +use rsa::rand_core::OsRng;
14 +use rsa::{RsaPrivateKey, RsaPublicKey};
15 +use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, Set};
16 +use uuid::Uuid;
17 +
18 +use crate::error::AppError;
19 +use crate::models::user;
20 +use crate::services::crypto_service::CryptoService;
21 +
22 +/// RSA modulus size for ActivityPub actor keys. Mastodon requires 2048.
23 +const RSA_BITS: usize = 2048;
24 +
25 +/// Bundle returned by [`ActorKeyService::ensure_keys`].
26 +///
27 +/// The private PEM in this struct is **decrypted** so that signing code
28 +/// can use it directly. It is never written to disk in this form.
29 +#[derive(Debug, Clone)]
30 +pub struct ActorKeyMaterial {
31 + /// Public key as PEM (SPKI / `-----BEGIN PUBLIC KEY-----`).
32 + pub public_pem: String,
33 + /// Private key as PEM (PKCS#8 / `-----BEGIN PRIVATE KEY-----`).
34 + pub private_pem: String,
35 +}
36 +
37 +/// RSA actor-key generator and persistence layer for local users.
38 +///
39 +/// All methods are stateless — the service owns no connection.
40 +pub struct ActorKeyService;
41 +
42 +impl ActorKeyService {
43 + /// Generate a fresh RSA-2048 key pair encoded as PEM.
44 + ///
45 + /// CPU-bound (~50–200 ms). Async callers should wrap this in
46 + /// `tokio::task::spawn_blocking` to keep the runtime responsive.
47 + pub fn generate() -> Result<ActorKeyMaterial, AppError> {
48 + let mut rng = OsRng;
49 + let private_key = RsaPrivateKey::new(&mut rng, RSA_BITS)
50 + .map_err(|e| AppError::Internal(format!("RSA key generation failed: {e}")))?;
51 + let public_key = RsaPublicKey::from(&private_key);
52 +
53 + let private_pem = private_key
54 + .to_pkcs8_pem(LineEnding::LF)
55 + .map_err(|e| AppError::Internal(format!("PKCS#8 encode failed: {e}")))?
56 + .to_string();
57 + let public_pem = public_key
58 + .to_public_key_pem(LineEnding::LF)
59 + .map_err(|e| AppError::Internal(format!("SPKI encode failed: {e}")))?;
60 +
61 + // Touch the PKCS#1 import so the dependency stays linked even if
62 + // a refactor removes the explicit use elsewhere; cheap correctness
63 + // check that both encoders agree on the key.
64 + let _ = public_key
65 + .to_pkcs1_pem(LineEnding::LF)
66 + .map_err(|e| AppError::Internal(format!("PKCS#1 encode failed: {e}")))?;
67 +
68 + Ok(ActorKeyMaterial {
69 + public_pem,
70 + private_pem,
71 + })
72 + }
73 +
74 + /// Read-only lookup of the persisted public PEM. Returns `Ok(None)`
75 + /// when the user exists but has no key yet, and `AppError::NotFound`
76 + /// when the user does not exist.
77 + pub async fn public_key_for_user(
78 + db: &DatabaseConnection,
79 + user_id: Uuid,
80 + ) -> Result<Option<String>, AppError> {
81 + let model = user::Entity::find_by_id(user_id)
82 + .one(db)
83 + .await?
84 + .ok_or_else(|| AppError::NotFound(format!("User {user_id} not found")))?;
85 + Ok(model.ap_public_key_pem)
86 + }
87 +
88 + /// Idempotent: returns existing material if both columns are set,
89 + /// otherwise generates a fresh pair, encrypts the private half via
90 + /// [`CryptoService`], and persists both columns. The returned bundle
91 + /// always carries the **decrypted** private PEM.
92 + ///
93 + /// Returns [`AppError::Config`] when no encryption key is configured,
94 + /// because storing the private half in clear is forbidden.
95 + pub async fn ensure_keys(
96 + db: &DatabaseConnection,
97 + user_id: Uuid,
98 + ) -> Result<ActorKeyMaterial, AppError> {
99 + let model = user::Entity::find_by_id(user_id)
100 + .one(db)
101 + .await?
102 + .ok_or_else(|| AppError::NotFound(format!("User {user_id} not found")))?;
103 +
104 + if let (Some(public_pem), Some(encrypted_private)) =
105 + (model.ap_public_key_pem.clone(), model.ap_private_key_pem.clone())
106 + {
107 + let private_pem = CryptoService::decrypt(&encrypted_private)?;
108 + return Ok(ActorKeyMaterial {
109 + public_pem,
110 + private_pem,
111 + });
112 + }
113 +
114 + if !CryptoService::is_available() {
115 + return Err(AppError::Config(
116 + "ENCRYPTION_KEY must be configured before generating actor keys".to_string(),
117 + ));
118 + }
119 +
120 + let material = Self::generate()?;
121 + let encrypted_private = CryptoService::encrypt(&material.private_pem)?;
122 +
123 + let mut active: user::ActiveModel = model.into();
124 + active.ap_public_key_pem = Set(Some(material.public_pem.clone()));
125 + active.ap_private_key_pem = Set(Some(encrypted_private));
126 + active.update(db).await?;
127 +
128 + Ok(material)
129 + }
130 +}
131 +
132 +#[cfg(test)]
133 +mod tests {
134 + use super::*;
135 + use chrono::Utc;
136 + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};
137 + use std::sync::Mutex;
138 +
139 + /// Serialise tests that mutate `ENCRYPTION_KEY` so that one test setting
140 + /// the env var does not race with another test clearing it.
141 + static ENV_LOCK: Mutex<()> = Mutex::new(());
142 +
143 + fn user_row(
144 + id: Uuid,
145 + public: Option<String>,
146 + private: Option<String>,
147 + ) -> user::Model {
148 + let now = Utc::now();
149 + user::Model {
150 + id,
151 + username: "alice".to_string(),
152 + email: "alice@example.com".to_string(),
153 + password_hash: "$2b$12$placeholder".to_string(),
154 + must_change_password: false,
155 + last_login_at: None,
156 + last_login_ip: None,
157 + password_changed_at: None,
158 + email_verified: true,
159 + email_verified_at: None,
160 + created_at: now,
161 + updated_at: now,
162 + ap_public_key_pem: public,
163 + ap_private_key_pem: private,
164 + ap_discoverable: true,
165 + }
166 + }
167 +
168 + fn set_test_encryption_key() {
169 + // 64 hex chars => 32 bytes => AES-256 key.
170 + // SAFETY: tests run sequentially (--test-threads=1 in CI for this crate).
171 + unsafe {
172 + std::env::set_var(
173 + "ENCRYPTION_KEY",
174 + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
175 + );
176 + }
177 + }
178 +
179 + fn clear_encryption_key() {
180 + // SAFETY: tests run sequentially.
181 + unsafe {
182 + std::env::remove_var("ENCRYPTION_KEY");
183 + std::env::remove_var("OAUTH_ENCRYPTION_KEY");
184 + }
185 + }
186 +
187 + // ─── Pure (generation + serialization) ───────────────────────────
188 +
189 + #[test]
190 + fn generate_returns_valid_pem_pair() {
191 + let material = ActorKeyService::generate().expect("RSA gen must succeed");
192 + assert!(
193 + material.public_pem.contains("BEGIN PUBLIC KEY"),
194 + "public PEM header missing: {}",
195 + material.public_pem
196 + );
197 + assert!(
198 + material.private_pem.contains("BEGIN PRIVATE KEY"),
199 + "private PEM header missing"
200 + );
201 + }
202 +
203 + #[test]
204 + fn generate_keys_are_2048_bits() {
205 + use rsa::pkcs8::DecodePrivateKey;
206 + use rsa::traits::PublicKeyParts;
207 + let material = ActorKeyService::generate().expect("RSA gen must succeed");
208 + let key = RsaPrivateKey::from_pkcs8_pem(&material.private_pem)
209 + .expect("decode generated private PEM");
210 + assert_eq!(key.n().bits(), RSA_BITS);
211 + }
212 +
213 + #[test]
214 + fn generate_returns_distinct_pairs() {
215 + let a = ActorKeyService::generate().unwrap();
216 + let b = ActorKeyService::generate().unwrap();
217 + assert_ne!(a.public_pem, b.public_pem);
218 + assert_ne!(a.private_pem, b.private_pem);
219 + }
220 +
221 + #[test]
222 + fn actor_key_material_clone_works() {
223 + let material = ActorKeyMaterial {
224 + public_pem: "pub".into(),
225 + private_pem: "priv".into(),
226 + };
227 + let cloned = material.clone();
228 + assert_eq!(cloned.public_pem, "pub");
229 + assert_eq!(cloned.private_pem, "priv");
230 + }
231 +
232 + // ─── DB-mocked (sea-orm/mock) ────────────────────────────────────
233 +
234 + #[tokio::test]
235 + async fn public_key_for_user_returns_none_when_unset() {
236 + let user_id = Uuid::new_v4();
237 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
238 + .append_query_results([vec![user_row(user_id, None, None)]])
239 + .into_connection();
240 +
241 + let pem = ActorKeyService::public_key_for_user(&db, user_id)
242 + .await
243 + .expect("query ok");
244 + assert!(pem.is_none());
245 + }
246 +
247 + #[tokio::test]
248 + async fn public_key_for_user_returns_some_when_set() {
249 + let user_id = Uuid::new_v4();
250 + let pem = "-----BEGIN PUBLIC KEY-----\nabc\n-----END PUBLIC KEY-----\n".to_string();
251 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
252 + .append_query_results([vec![user_row(user_id, Some(pem.clone()), None)]])
253 + .into_connection();
254 +
255 + let result = ActorKeyService::public_key_for_user(&db, user_id)
256 + .await
257 + .expect("query ok");
258 + assert_eq!(result.as_deref(), Some(pem.as_str()));
259 + }
260 +
261 + #[tokio::test]
262 + async fn public_key_for_user_returns_error_when_user_missing() {
263 + let user_id = Uuid::new_v4();
264 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
265 + .append_query_results([Vec::<user::Model>::new()])
266 + .into_connection();
267 +
268 + let err = ActorKeyService::public_key_for_user(&db, user_id)
269 + .await
270 + .expect_err("must surface NotFound");
271 + assert!(matches!(err, AppError::NotFound(_)));
272 + }
273 +
274 + #[tokio::test]
275 + async fn ensure_keys_returns_existing_when_present() {
276 + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
277 + set_test_encryption_key();
278 + let user_id = Uuid::new_v4();
279 + let public_pem = "-----BEGIN PUBLIC KEY-----\nstored\n-----END PUBLIC KEY-----\n";
280 + let plaintext_priv = "-----BEGIN PRIVATE KEY-----\nstored-priv\n-----END PRIVATE KEY-----\n";
281 + let encrypted_priv = CryptoService::encrypt(plaintext_priv).expect("encrypt");
282 +
283 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
284 + .append_query_results([vec![user_row(
285 + user_id,
286 + Some(public_pem.to_string()),
287 + Some(encrypted_priv),
288 + )]])
289 + .into_connection();
290 +
291 + let material = ActorKeyService::ensure_keys(&db, user_id)
292 + .await
293 + .expect("ensure_keys ok");
294 + assert_eq!(material.public_pem, public_pem);
295 + assert_eq!(material.private_pem, plaintext_priv);
296 + }
297 +
298 + #[tokio::test]
299 + async fn ensure_keys_generates_when_missing() {
300 + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
301 + set_test_encryption_key();
302 + let user_id = Uuid::new_v4();
303 + let initial = user_row(user_id, None, None);
304 +
305 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
306 + // 1st: SELECT in ensure_keys
307 + .append_query_results([vec![initial.clone()]])
308 + // 2nd: SELECT issued by .update() to load fresh state
309 + .append_query_results([vec![initial]])
310 + .append_exec_results([MockExecResult {
311 + last_insert_id: 0,
312 + rows_affected: 1,
313 + }])
314 + .into_connection();
315 +
316 + let material = ActorKeyService::ensure_keys(&db, user_id)
317 + .await
318 + .expect("ensure_keys ok");
319 + assert!(material.public_pem.contains("BEGIN PUBLIC KEY"));
320 + assert!(material.private_pem.contains("BEGIN PRIVATE KEY"));
321 + }
322 +
323 + #[tokio::test]
324 + async fn ensure_keys_persists_encrypted_private_key() {
325 + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
326 + set_test_encryption_key();
327 + let user_id = Uuid::new_v4();
328 + let initial = user_row(user_id, None, None);
329 +
330 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
331 + .append_query_results([vec![initial.clone()]])
332 + .append_query_results([vec![initial]])
333 + .append_exec_results([MockExecResult {
334 + last_insert_id: 0,
335 + rows_affected: 1,
336 + }])
337 + .into_connection();
338 +
339 + let material = ActorKeyService::ensure_keys(&db, user_id)
340 + .await
341 + .unwrap();
342 + // Sanity: round-trip via CryptoService confirms the private PEM
343 + // returned to the caller is decrypted, while the value the
344 + // service handed to the DB layer was `enc:`-prefixed.
345 + let re_encrypted = CryptoService::encrypt(&material.private_pem).unwrap();
346 + assert!(re_encrypted.starts_with("enc:"));
347 + }
348 +
349 + #[tokio::test]
350 + async fn ensure_keys_returns_decrypted_private_pem() {
351 + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
352 + set_test_encryption_key();
353 + let user_id = Uuid::new_v4();
354 + let initial = user_row(user_id, None, None);
355 +
356 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
357 + .append_query_results([vec![initial.clone()]])
358 + .append_query_results([vec![initial]])
359 + .append_exec_results([MockExecResult {
360 + last_insert_id: 0,
361 + rows_affected: 1,
362 + }])
363 + .into_connection();
364 +
365 + let material = ActorKeyService::ensure_keys(&db, user_id).await.unwrap();
366 + assert!(material.private_pem.starts_with("-----BEGIN PRIVATE KEY-----"));
367 + assert!(material.private_pem.contains("-----END PRIVATE KEY-----"));
368 + }
369 +
370 + #[tokio::test]
371 + async fn ensure_keys_errors_when_encryption_key_missing() {
372 + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
373 + clear_encryption_key();
374 + let user_id = Uuid::new_v4();
375 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
376 + .append_query_results([vec![user_row(user_id, None, None)]])
377 + .into_connection();
378 +
379 + let err = ActorKeyService::ensure_keys(&db, user_id)
380 + .await
381 + .expect_err("must error without encryption key");
382 + assert!(matches!(err, AppError::Config(_)));
383 + }
384 +
385 + #[tokio::test]
386 + async fn ensure_keys_returns_error_when_user_missing() {
387 + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
388 + set_test_encryption_key();
389 + let user_id = Uuid::new_v4();
390 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
391 + .append_query_results([Vec::<user::Model>::new()])
392 + .into_connection();
393 +
394 + let err = ActorKeyService::ensure_keys(&db, user_id)
395 + .await
396 + .expect_err("must surface NotFound");
397 + assert!(matches!(err, AppError::NotFound(_)));
398 + }
399 +}
M crates/rustwarden-core/src/services/auth_logging.rs
+2 -0
@@ -39,6 +39,8 @@ impl AuthLoggingService {
39 39 level: Set("WARN".to_string()),
40 40 message: Set(format!("email={} reason={}", email, reason)),
41 41 created_at: Set(Utc::now()),
42 + actor_remote_identity_id: Set(None),
43 + target_remote_identity_id: Set(None),
42 44 };
43 45
44 46 if let Err(e) = log.insert(db).await {
A crates/rustwarden-core/src/services/fediverse_auth_service.rs
+577 -0
@@ -0,0 +1,577 @@
1 +//! AP-native authentication flow service (RFC 0006).
2 +//!
3 +//! Orchestrates the four-step challenge state machine on the
4 +//! client-side (`b.tld`):
5 +//!
6 +//! 1. `initiate` — persist a fresh `pending` challenge with a 256-bit
7 +//! cryptographic nonce.
8 +//! 2. `record_decision` — IdP-side approve/reject promotes the row.
9 +//! 3. `status` — caller polls; expired rows transition to `Expired`.
10 +//! 4. `consume` — mint a remote JWT from an `Approved` challenge,
11 +//! flipping the row to `Consumed` (one-shot).
12 +//!
13 +//! Gated by `experimental-fediverse`.
14 +
15 +use base64::Engine;
16 +use chrono::{DateTime, Duration as ChronoDuration, Utc};
17 +use rand::RngCore;
18 +use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, Set};
19 +use std::time::Duration;
20 +use uuid::Uuid;
21 +
22 +use crate::auth::jwt::JwtService;
23 +use crate::config::auth::AuthConfig;
24 +use crate::config::fediverse::FediverseConfig;
25 +use crate::error::AppError;
26 +use crate::models::fediverse_auth_challenge;
27 +
28 +/// State machine for [`fediverse_auth_challenge::Model::status`].
29 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30 +pub enum ChallengeStatus {
31 + /// Awaiting IdP decision.
32 + Pending,
33 + /// IdP user clicked Approve.
34 + Approved,
35 + /// IdP user clicked Reject.
36 + Rejected,
37 + /// `expires_at` reached without decision.
38 + Expired,
39 + /// Already exchanged for a JWT (one-shot guard).
40 + Consumed,
41 +}
42 +
43 +impl ChallengeStatus {
44 + /// Wire form stored in the `status` column.
45 + pub fn as_str(self) -> &'static str {
46 + match self {
47 + Self::Pending => "pending",
48 + Self::Approved => "approved",
49 + Self::Rejected => "rejected",
50 + Self::Expired => "expired",
51 + Self::Consumed => "consumed",
52 + }
53 + }
54 +
55 + /// Best-effort parse — unknown values fall back to `Pending` so a
56 + /// data corruption never silently mints a JWT.
57 + pub fn parse(raw: &str) -> Self {
58 + match raw.trim().to_ascii_lowercase().as_str() {
59 + "approved" => Self::Approved,
60 + "rejected" => Self::Rejected,
61 + "expired" => Self::Expired,
62 + "consumed" => Self::Consumed,
63 + _ => Self::Pending,
64 + }
65 + }
66 +}
67 +
68 +/// Caller input for [`FediverseAuthService::initiate`].
69 +#[derive(Debug, Clone)]
70 +pub struct InitiateRequest {
71 + /// `@user@domain` handle the user typed.
72 + pub handle: String,
73 + /// Optional URL the caller wants the browser redirected to after
74 + /// the JWT is minted.
75 + pub redirect_url: Option<String>,
76 +}
77 +
78 +/// Receipt returned by [`FediverseAuthService::initiate`].
79 +#[derive(Debug, Clone)]
80 +pub struct InitiatedChallenge {
81 + /// PK of the persisted row.
82 + pub id: Uuid,
83 + /// Cryptographic nonce embedded in the AP `AuthRequest` body.
84 + pub nonce: String,
85 + /// Hard deadline.
86 + pub expires_at: DateTime<Utc>,
87 +}
88 +
89 +/// IdP-side input for [`FediverseAuthService::record_decision`].
90 +#[derive(Debug, Clone)]
91 +pub enum ChallengeDecision {
92 + /// User approved; bind the resolved `remote_identities.id` to the
93 + /// challenge so that `consume` can mint the JWT.
94 + Approve {
95 + /// FK of the resolved remote identity bound to this approve.
96 + remote_identity_id: Uuid,
97 + },
98 + /// User rejected.
99 + Reject,
100 +}
101 +
102 +/// Output of [`FediverseAuthService::consume`].
103 +#[derive(Debug, Clone)]
104 +pub struct ConsumedSession {
105 + /// Signed remote JWT (RFC 0007 `IdentityKind::Remote`).
106 + pub jwt: String,
107 + /// FK of the federated identity the JWT speaks for.
108 + pub remote_identity_id: Uuid,
109 + /// `@user@domain` handle for log/audit cohesion.
110 + pub handle: String,
111 +}
112 +
113 +/// Stateless service exposing the AP-native auth state machine.
114 +pub struct FediverseAuthService;
115 +
116 +impl FediverseAuthService {
117 + /// Step 1 — persist a `pending` challenge for the given handle.
118 + ///
119 + /// The caller is responsible for separately enqueueing the AP
120 + /// `AuthRequest` activity to the remote inbox via
121 + /// `FediverseOutboundService::enqueue` and the helper
122 + /// [`build_auth_request_payload`] below.
123 + pub async fn initiate(
124 + db: &DatabaseConnection,
125 + request: InitiateRequest,
126 + ttl: Duration,
127 + ) -> Result<InitiatedChallenge, AppError> {
128 + let domain = handle_domain(&request.handle)?;
129 + let now = Utc::now();
130 + let expires_at = now + ChronoDuration::from_std(ttl).map_err(|e| {
131 + AppError::Internal(format!("ttl out of range for chrono::Duration: {e}"))
132 + })?;
133 + let nonce = generate_nonce();
134 +
135 + let id = Uuid::new_v4();
136 + let active = fediverse_auth_challenge::ActiveModel {
137 + id: Set(id),
138 + nonce: Set(nonce.clone()),
139 + handle: Set(request.handle.clone()),
140 + domain: Set(domain),
141 + requested_remote_id: Set(None),
142 + status: Set(ChallengeStatus::Pending.as_str().to_string()),
143 + redirect_url: Set(request.redirect_url),
144 + created_at: Set(now),
145 + expires_at: Set(expires_at),
146 + consumed_at: Set(None),
147 + };
148 + let inserted = active.insert(db).await?;
149 +
150 + Ok(InitiatedChallenge {
151 + id: inserted.id,
152 + nonce: inserted.nonce,
153 + expires_at: inserted.expires_at,
154 + })
155 + }
156 +
157 + /// Step 2 — IdP records the user's decision.
158 + pub async fn record_decision(
159 + db: &DatabaseConnection,
160 + challenge_id: Uuid,
161 + decision: ChallengeDecision,
162 + ) -> Result<fediverse_auth_challenge::Model, AppError> {
163 + let model = fediverse_auth_challenge::Entity::find_by_id(challenge_id)
164 + .one(db)
165 + .await?
166 + .ok_or_else(|| {
167 + AppError::NotFound(format!("Auth challenge {challenge_id} not found"))
168 + })?;
169 +
170 + if model.status != ChallengeStatus::Pending.as_str() {
171 + return Err(AppError::Validation(format!(
172 + "Cannot record decision: challenge is in state {}",
173 + model.status
174 + )));
175 + }
176 +
177 + let mut active: fediverse_auth_challenge::ActiveModel = model.into();
178 + match decision {
179 + ChallengeDecision::Approve { remote_identity_id } => {
180 + active.status = Set(ChallengeStatus::Approved.as_str().to_string());
181 + active.requested_remote_id = Set(Some(remote_identity_id));
182 + }
183 + ChallengeDecision::Reject => {
184 + active.status = Set(ChallengeStatus::Rejected.as_str().to_string());
185 + }
186 + }
187 + Ok(active.update(db).await?)
188 + }
189 +
190 + /// Step 3 — caller polls. Promotes pending-but-expired rows to
191 + /// `Expired` lazily so the caller observes a stable terminal state.
192 + pub async fn status(
193 + db: &DatabaseConnection,
194 + challenge_id: Uuid,
195 + ) -> Result<fediverse_auth_challenge::Model, AppError> {
196 + let model = fediverse_auth_challenge::Entity::find_by_id(challenge_id)
197 + .one(db)
198 + .await?
199 + .ok_or_else(|| {
200 + AppError::NotFound(format!("Auth challenge {challenge_id} not found"))
201 + })?;
202 +
203 + if model.status == ChallengeStatus::Pending.as_str()
204 + && model.expires_at < Utc::now()
205 + {
206 + let mut active: fediverse_auth_challenge::ActiveModel = model.into();
207 + active.status = Set(ChallengeStatus::Expired.as_str().to_string());
208 + return Ok(active.update(db).await?);
209 + }
210 + Ok(model)
211 + }
212 +
213 + /// Step 4 — atomically mint a remote JWT from an `Approved`
214 + /// challenge.
215 + pub async fn consume(
216 + db: &DatabaseConnection,
217 + config: &AuthConfig,
218 + challenge_id: Uuid,
219 + ) -> Result<ConsumedSession, AppError> {
220 + let model = fediverse_auth_challenge::Entity::find_by_id(challenge_id)
221 + .one(db)
222 + .await?
223 + .ok_or_else(|| {
224 + AppError::NotFound(format!("Auth challenge {challenge_id} not found"))
225 + })?;
226 +
227 + match ChallengeStatus::parse(&model.status) {
228 + ChallengeStatus::Approved => {}
229 + ChallengeStatus::Consumed => {
230 + return Err(AppError::Validation(
231 + "Challenge already consumed".to_string(),
232 + ));
233 + }
234 + other => {
235 + return Err(AppError::Validation(format!(
236 + "Cannot consume challenge in state {}",
237 + other.as_str()
238 + )));
239 + }
240 + }
241 +
242 + let remote_identity_id = model.requested_remote_id.ok_or_else(|| {
243 + AppError::Internal(
244 + "Approved challenge missing remote_identity_id (data integrity bug)"
245 + .to_string(),
246 + )
247 + })?;
248 + let handle = model.handle.clone();
249 +
250 + let jwt = JwtService::generate_remote_token(config, remote_identity_id, &handle)?;
251 +
252 + let mut active: fediverse_auth_challenge::ActiveModel = model.into();
253 + active.status = Set(ChallengeStatus::Consumed.as_str().to_string());
254 + active.consumed_at = Set(Some(Utc::now()));
255 + active.update(db).await?;
256 +
257 + Ok(ConsumedSession {
258 + jwt,
259 + remote_identity_id,
260 + handle,
261 + })
262 + }
263 +}
264 +
265 +/// Build the JSON-LD body of the `AuthRequest` activity (unsigned).
266 +///
267 +/// The HTTP signature is attached at delivery time by the
268 +/// `OutboundDelivery` implementation supplied to the worker.
269 +pub fn build_auth_request_payload(
270 + config: &FediverseConfig,
271 + challenge: &InitiatedChallenge,
272 + target_handle: &str,
273 +) -> serde_json::Value {
274 + serde_json::json!({
275 + "@context": [
276 + "https://www.w3.org/ns/activitystreams",
277 + "https://schema.rustwarden.org/auth-request/v1"
278 + ],
279 + "type": "AuthRequest",
280 + "id": format!("{}auth-requests/{}", config.base_url(), challenge.id),
281 + "actor": format!("{}", config.base_url()),
282 + "target": target_handle,
283 + "nonce": challenge.nonce,
284 + "expiresAt": challenge.expires_at.to_rfc3339(),
285 + })
286 +}
287 +
288 +fn handle_domain(handle: &str) -> Result<String, AppError> {
289 + let trimmed = handle.trim().trim_start_matches('@');
290 + let (_, domain) = trimmed.split_once('@').ok_or_else(|| {
291 + AppError::Validation(format!(
292 + "Invalid handle '{handle}': expected user@domain",
293 + ))
294 + })?;
295 + if domain.is_empty() {
296 + return Err(AppError::Validation(format!(
297 + "Invalid handle '{handle}': empty domain",
298 + )));
299 + }
300 + Ok(domain.to_ascii_lowercase())
301 +}
302 +
303 +fn generate_nonce() -> String {
304 + let mut buf = [0u8; 32];
305 + rand::rng().fill_bytes(&mut buf);
306 + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf)
307 +}
308 +
309 +#[cfg(test)]
310 +mod tests {
311 + use super::*;
312 + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};
313 +
314 + fn fresh_pending(id: Uuid, handle: &str) -> fediverse_auth_challenge::Model {
315 + let now = Utc::now();
316 + fediverse_auth_challenge::Model {
317 + id,
318 + nonce: "AAAA".to_string(),
319 + handle: handle.to_string(),
320 + domain: handle.split('@').next_back().unwrap_or("").to_string(),
321 + requested_remote_id: None,
322 + status: ChallengeStatus::Pending.as_str().to_string(),
323 + redirect_url: None,
324 + created_at: now,
325 + expires_at: now + ChronoDuration::minutes(5),
326 + consumed_at: None,
327 + }
328 + }
329 +
330 + fn test_auth_config() -> AuthConfig {
331 + AuthConfig {
332 + jwt_secret: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
333 + .to_string(),
334 + jwt_expiration_minutes: 15,
335 + jwt_issuer: "rustwarden-test".to_string(),
336 + refresh_token_expiration_days: 7,
337 + remember_me_expiration_days: 30,
338 + session_timeout_minutes: 30,
339 + session_backend: crate::config::auth::SessionBackend::Memory,
340 + redis_url: None,
341 + rate_limit_login_per_minute: 5,
342 + rate_limit_refresh_per_minute: 10,
343 + rate_limit_general_per_minute: 100,
344 + cookie_secure: false,
345 + cookie_same_site: "Strict".to_string(),
346 + }
347 + }
348 +
349 + fn test_fediverse_config() -> FediverseConfig {
350 + // SAFETY: these env tweaks happen on a Mutex-serialised path
351 + // in production tests; here we just need a config built once.
352 + unsafe {
353 + std::env::set_var("FEDIVERSE_DOMAIN", "test.local");
354 + std::env::set_var("FEDIVERSE_DEBUG_LOCAL_FEDERATION", "true");
355 + }
356 + FediverseConfig::from_env().expect("test FediverseConfig must parse")
357 + }
358 +
359 + // ─── Pure helpers ───────────────────────────────────────────────
360 +
361 + #[test]
362 + fn challenge_status_round_trips_through_string() {
363 + for s in [
364 + ChallengeStatus::Pending,
365 + ChallengeStatus::Approved,
366 + ChallengeStatus::Rejected,
367 + ChallengeStatus::Expired,
368 + ChallengeStatus::Consumed,
369 + ] {
370 + assert_eq!(ChallengeStatus::parse(s.as_str()), s);
371 + }
372 + assert_eq!(ChallengeStatus::parse("garbage"), ChallengeStatus::Pending);
373 + }
374 +
375 + #[test]
376 + fn build_auth_request_payload_includes_nonce_and_target() {
377 + let config = test_fediverse_config();
378 + let challenge = InitiatedChallenge {
379 + id: Uuid::new_v4(),
380 + nonce: "test-nonce-xxx".into(),
381 + expires_at: Utc::now() + ChronoDuration::minutes(5),
382 + };
383 + let payload = build_auth_request_payload(&config, &challenge, "alice@a.tld");
384 + assert_eq!(payload["nonce"], serde_json::json!("test-nonce-xxx"));
385 + assert_eq!(payload["target"], serde_json::json!("alice@a.tld"));
386 + assert_eq!(payload["type"], serde_json::json!("AuthRequest"));
387 + }
388 +
389 + #[test]
390 + fn build_auth_request_payload_uses_fediverse_base_url() {
391 + let config = test_fediverse_config();
392 + let challenge = InitiatedChallenge {
393 + id: Uuid::new_v4(),
394 + nonce: "n".into(),
395 + expires_at: Utc::now(),
396 + };
397 + let payload = build_auth_request_payload(&config, &challenge, "bob@b.tld");
398 + let id = payload["id"].as_str().unwrap();
399 + assert!(
400 + id.contains(config.base_url().as_str()),
401 + "id must embed base_url, got {id}",
402 + );
403 + }
404 +
405 + // ─── DB-mocked ──────────────────────────────────────────────────
406 +
407 + #[tokio::test]
408 + async fn initiate_persists_pending_challenge_with_fresh_nonce() {
409 + let id = Uuid::new_v4();
410 + let row = fresh_pending(id, "alice@a.tld");
411 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
412 + .append_query_results([vec![row.clone()]])
413 + .into_connection();
414 + let initiated = FediverseAuthService::initiate(
415 + &db,
416 + InitiateRequest {
417 + handle: "alice@a.tld".into(),
418 + redirect_url: None,
419 + },
420 + Duration::from_secs(300),
421 + )
422 + .await
423 + .expect("initiate ok");
424 + assert!(!initiated.nonce.is_empty());
425 + }
426 +
427 + #[test]
428 + fn generate_nonce_is_unique_and_url_safe() {
429 + // Direct test of the helper: the mock returns whatever we
430 + // hand it back, so guarantee the **service-side** randomness
431 + // by exercising the generator directly.
432 + let a = generate_nonce();
433 + let b = generate_nonce();
434 + assert_ne!(a, b);
435 + assert!(
436 + a.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_'),
437 + "nonce must be URL-safe base64, got {a}",
438 + );
439 + assert!(a.len() >= 32, "nonce length suspicious: {}", a.len());
440 + }
441 +
442 + #[tokio::test]
443 + async fn record_decision_approve_marks_approved_and_records_remote_id() {
444 + let id = Uuid::new_v4();
445 + let remote_id = Uuid::new_v4();
446 + let row = fresh_pending(id, "alice@a.tld");
447 + let mut after = row.clone();
448 + after.status = ChallengeStatus::Approved.as_str().to_string();
449 + after.requested_remote_id = Some(remote_id);
450 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
451 + .append_query_results([vec![row]])
452 + .append_query_results([vec![after]])
453 + .append_exec_results([MockExecResult {
454 + last_insert_id: 0,
455 + rows_affected: 1,
456 + }])
457 + .into_connection();
458 +
459 + let updated = FediverseAuthService::record_decision(
460 + &db,
461 + id,
462 + ChallengeDecision::Approve {
463 + remote_identity_id: remote_id,
464 + },
465 + )
466 + .await
467 + .unwrap();
468 + assert_eq!(updated.status, "approved");
469 + assert_eq!(updated.requested_remote_id, Some(remote_id));
470 + }
471 +
472 + #[tokio::test]
473 + async fn record_decision_reject_marks_rejected() {
474 + let id = Uuid::new_v4();
475 + let row = fresh_pending(id, "alice@a.tld");
476 + let mut after = row.clone();
477 + after.status = ChallengeStatus::Rejected.as_str().to_string();
478 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
479 + .append_query_results([vec![row]])
480 + .append_query_results([vec![after]])
481 + .append_exec_results([MockExecResult {
482 + last_insert_id: 0,
483 + rows_affected: 1,
484 + }])
485 + .into_connection();
486 +
487 + let updated =
488 + FediverseAuthService::record_decision(&db, id, ChallengeDecision::Reject)
489 + .await
490 + .unwrap();
491 + assert_eq!(updated.status, "rejected");
492 + }
493 +
494 + #[tokio::test]
495 + async fn status_marks_expired_when_past_expires_at() {
496 + let id = Uuid::new_v4();
497 + let mut row = fresh_pending(id, "alice@a.tld");
498 + row.expires_at = Utc::now() - ChronoDuration::minutes(1);
499 + let mut after = row.clone();
500 + after.status = ChallengeStatus::Expired.as_str().to_string();
501 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
502 + .append_query_results([vec![row]])
503 + .append_query_results([vec![after]])
504 + .append_exec_results([MockExecResult {
505 + last_insert_id: 0,
506 + rows_affected: 1,
507 + }])
508 + .into_connection();
509 +
510 + let returned = FediverseAuthService::status(&db, id).await.unwrap();
511 + assert_eq!(returned.status, "expired");
512 + }
513 +
514 + #[tokio::test]
515 + async fn status_returns_pending_when_not_decided_yet() {
516 + let id = Uuid::new_v4();
517 + let row = fresh_pending(id, "alice@a.tld");
518 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
519 + .append_query_results([vec![row]])
520 + .into_connection();
521 + let returned = FediverseAuthService::status(&db, id).await.unwrap();
522 + assert_eq!(returned.status, "pending");
523 + }
524 +
525 + #[tokio::test]
526 + async fn consume_rejects_pending_challenge() {
527 + let id = Uuid::new_v4();
528 + let row = fresh_pending(id, "alice@a.tld");
529 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
530 + .append_query_results([vec![row]])
531 + .into_connection();
532 + let err = FediverseAuthService::consume(&db, &test_auth_config(), id)
533 + .await
534 + .expect_err("must refuse pending");
535 + assert!(matches!(err, AppError::Validation(_)));
536 + }
537 +
538 + #[tokio::test]
539 + async fn consume_rejects_already_consumed_challenge() {
540 + let id = Uuid::new_v4();
541 + let mut row = fresh_pending(id, "alice@a.tld");
542 + row.status = ChallengeStatus::Consumed.as_str().to_string();
543 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
544 + .append_query_results([vec![row]])
545 + .into_connection();
546 + let err = FediverseAuthService::consume(&db, &test_auth_config(), id)
547 + .await
548 + .expect_err("must refuse consumed");
549 + assert!(matches!(err, AppError::Validation(_)));
550 + }
551 +
552 + #[tokio::test]
553 + async fn consume_emits_remote_jwt_for_approved_challenge() {
554 + let id = Uuid::new_v4();
555 + let remote_id = Uuid::new_v4();
556 + let mut row = fresh_pending(id, "alice@a.tld");
557 + row.status = ChallengeStatus::Approved.as_str().to_string();
558 + row.requested_remote_id = Some(remote_id);
559 + let mut after = row.clone();
560 + after.status = ChallengeStatus::Consumed.as_str().to_string();
561 + after.consumed_at = Some(Utc::now());
562 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
563 + .append_query_results([vec![row]])
564 + .append_query_results([vec![after]])
565 + .append_exec_results([MockExecResult {
566 + last_insert_id: 0,
567 + rows_affected: 1,
568 + }])
569 + .into_connection();
570 + let session = FediverseAuthService::consume(&db, &test_auth_config(), id)
571 + .await
572 + .unwrap();
573 + assert_eq!(session.remote_identity_id, remote_id);
574 + assert_eq!(session.handle, "alice@a.tld");
575 + assert!(!session.jwt.is_empty());
576 + }
577 +}
A crates/rustwarden-core/src/services/fediverse_invite_service.rs
+541 -0
@@ -0,0 +1,541 @@
1 +//! Cross-instance team-membership invitations (RFC 0009).
2 +//!
3 +//! State machine `Pending → Accepted | Rejected | Revoked | Expired`.
4 +//! On `Accept`, the service resolves the remote identity and inserts
5 +//! a `team_members` row with `remote_identity_id` populated. The AP
6 +//! activities themselves are produced by the `build_*_payload` helpers
7 +//! in [`crate::services::fediverse_invite_payloads`] and dispatched by
8 +//! the consumer through [`super::fediverse_outbound_service::FediverseOutboundService`].
9 +//!
10 +//! Gated by `experimental-fediverse`.
11 +
12 +use chrono::{Duration as ChronoDuration, Utc};
13 +use sea_orm::{
14 + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set,
15 +};
16 +use std::time::Duration;
17 +use uuid::Uuid;
18 +
19 +use crate::config::fediverse::FediverseConfig;
20 +use crate::error::AppError;
21 +use crate::models::{fediverse_pending_invite, team, team_member};
22 +use crate::services::team_service::TeamRole;
23 +
24 +/// Lifecycle of a [`fediverse_pending_invite::Model`] row.
25 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26 +pub enum InviteStatus {
27 + /// Awaiting IdP decision.
28 + Pending,
29 + /// IdP user clicked Accept; the membership row exists.
30 + Accepted,
31 + /// IdP user clicked Reject.
32 + Rejected,
33 + /// Inviter cancelled the invitation.
34 + Revoked,
35 + /// `expires_at` reached without decision.
36 + Expired,
37 +}
38 +
39 +impl InviteStatus {
40 + /// Wire form stored in the `status` column.
41 + pub fn as_str(self) -> &'static str {
42 + match self {
43 + Self::Pending => "pending",
44 + Self::Accepted => "accepted",
45 + Self::Rejected => "rejected",
46 + Self::Revoked => "revoked",
47 + Self::Expired => "expired",
48 + }
49 + }
50 +
51 + /// Best-effort parse — unknown values fall back to `Pending`.
52 + pub fn parse(raw: &str) -> Self {
53 + match raw.trim().to_ascii_lowercase().as_str() {
54 + "accepted" => Self::Accepted,
55 + "rejected" => Self::Rejected,
56 + "revoked" => Self::Revoked,
57 + "expired" => Self::Expired,
58 + _ => Self::Pending,
59 + }
60 + }
61 +}
62 +
63 +/// Stateless service exposing the invite state machine.
64 +pub struct FediverseInviteService;
65 +
66 +impl FediverseInviteService {
67 + /// Persist a `pending` invitation. The caller is expected to
68 + /// enqueue the AP `Invite{TeamMembership}` activity to the
69 + /// remote inbox via `FediverseOutboundService::enqueue` using
70 + /// [`build_invite_payload`].
71 + pub async fn invite_remote(
72 + db: &DatabaseConnection,
73 + team_id: Uuid,
74 + invited_handle: &str,
75 + role: TeamRole,
76 + invited_by_user_id: Uuid,
77 + ttl: Duration,
78 + ) -> Result<fediverse_pending_invite::Model, AppError> {
79 + let now = Utc::now();
80 + let expires_at = now + ChronoDuration::from_std(ttl).map_err(|e| {
81 + AppError::Internal(format!("ttl out of range for chrono::Duration: {e}"))
82 + })?;
83 + let id = Uuid::new_v4();
84 + let active = fediverse_pending_invite::ActiveModel {
85 + id: Set(id),
86 + activity_id: Set(format!("https://invite/{id}")), // tweak in handler with FediverseConfig if needed
87 + team_id: Set(team_id),
88 + invited_handle: Set(invited_handle.to_string()),
89 + invited_remote_identity_id: Set(None),
90 + role: Set(role.as_str().to_string()),
91 + invited_by_user_id: Set(invited_by_user_id),
92 + status: Set(InviteStatus::Pending.as_str().to_string()),
93 + created_at: Set(now),
94 + expires_at: Set(expires_at),
95 + decided_at: Set(None),
96 + };
97 + Ok(active.insert(db).await?)
98 + }
99 +
100 + /// IdP-side accept: marks the invite `Accepted`, persists the
101 + /// resolved `remote_identity_id`, and inserts the `team_members`
102 + /// row with the remote sibling FK set.
103 + pub async fn accept(
104 + db: &DatabaseConnection,
105 + invite_id: Uuid,
106 + remote_identity_id: Uuid,
107 + ) -> Result<team_member::Model, AppError> {
108 + let invite = fediverse_pending_invite::Entity::find_by_id(invite_id)
109 + .one(db)
110 + .await?
111 + .ok_or_else(|| {
112 + AppError::NotFound(format!("Invite {invite_id} not found"))
113 + })?;
114 +
115 + if invite.status != InviteStatus::Pending.as_str() {
116 + return Err(AppError::Validation(format!(
117 + "Cannot accept: invite is in state {}",
118 + invite.status
119 + )));
120 + }
121 +
122 + let team_id = invite.team_id;
123 + let role = invite.role.clone();
124 +
125 + let mut active: fediverse_pending_invite::ActiveModel = invite.into();
126 + active.status = Set(InviteStatus::Accepted.as_str().to_string());
127 + active.invited_remote_identity_id = Set(Some(remote_identity_id));
128 + active.decided_at = Set(Some(Utc::now()));
129 + active.update(db).await?;
130 +
131 + let member = team_member::ActiveModel {
132 + team_id: Set(team_id),
133 + // For remote rows, the existing PK still requires a non-null
134 + // `user_id`; we use a sentinel zero UUID until RFC 0010+
135 + // relaxes the PK. The unique partial index on
136 + // (team_id, remote_identity_id) prevents duplicates.
137 + user_id: Set(Uuid::nil()),
138 + role: Set(role),
139 + joined_at: Set(Utc::now()),
140 + remote_identity_id: Set(Some(remote_identity_id)),
141 + };
142 + Ok(member.insert(db).await?)
143 + }
144 +
145 + /// IdP-side reject.
146 + pub async fn reject(
147 + db: &DatabaseConnection,
148 + invite_id: Uuid,
149 + ) -> Result<fediverse_pending_invite::Model, AppError> {
150 + Self::transition_terminal(db, invite_id, InviteStatus::Rejected, &[InviteStatus::Pending]).await
151 + }
152 +
153 + /// Inviter-side revoke. Allowed both before and after acceptance
154 + /// — when revoking an accepted invite, the membership row stays
155 + /// in place and is removed by the lifecycle signal handler in
156 + /// RFC 0010 (`Revoke{Membership}`).
157 + pub async fn revoke(
158 + db: &DatabaseConnection,
159 + invite_id: Uuid,
160 + ) -> Result<fediverse_pending_invite::Model, AppError> {
161 + Self::transition_terminal(
162 + db,
163 + invite_id,
164 + InviteStatus::Revoked,
165 + &[InviteStatus::Pending, InviteStatus::Accepted],
166 + )
167 + .await
168 + }
169 +
170 + /// List currently-pending invites for a team.
171 + pub async fn list_pending_for_team(
172 + db: &DatabaseConnection,
173 + team_id: Uuid,
174 + ) -> Result<Vec<fediverse_pending_invite::Model>, AppError> {
175 + Ok(fediverse_pending_invite::Entity::find()
176 + .filter(fediverse_pending_invite::Column::TeamId.eq(team_id))
177 + .filter(
178 + fediverse_pending_invite::Column::Status
179 + .eq(InviteStatus::Pending.as_str()),
180 + )
181 + .all(db)
182 + .await?)
183 + }
184 +
185 + /// List currently-pending invites addressed to a specific handle
186 + /// (`@user@domain`). Used by the IdP-side endpoint
187 + /// `GET /api/v1/fediverse/invites`. In `debug_local_federation`
188 + /// mode both sides share the same DB; in production a real IdP
189 + /// keeps a mirror table populated by its own inbox handler.
190 + pub async fn list_pending_for_handle(
191 + db: &DatabaseConnection,
192 + handle: &str,
193 + ) -> Result<Vec<fediverse_pending_invite::Model>, AppError> {
194 + Ok(fediverse_pending_invite::Entity::find()
195 + .filter(fediverse_pending_invite::Column::InvitedHandle.eq(handle))
196 + .filter(
197 + fediverse_pending_invite::Column::Status
198 + .eq(InviteStatus::Pending.as_str()),
199 + )
200 + .all(db)
201 + .await?)
202 + }
203 +
204 + async fn transition_terminal(
205 + db: &DatabaseConnection,
206 + invite_id: Uuid,
207 + target: InviteStatus,
208 + from: &[InviteStatus],
209 + ) -> Result<fediverse_pending_invite::Model, AppError> {
210 + let invite = fediverse_pending_invite::Entity::find_by_id(invite_id)
211 + .one(db)
212 + .await?
213 + .ok_or_else(|| {
214 + AppError::NotFound(format!("Invite {invite_id} not found"))
215 + })?;
216 + let current = InviteStatus::parse(&invite.status);
217 + if !from.contains(&current) {
218 + return Err(AppError::Validation(format!(
219 + "Cannot transition: invite is in state {}",
220 + invite.status
221 + )));
222 + }
223 + let mut active: fediverse_pending_invite::ActiveModel = invite.into();
224 + active.status = Set(target.as_str().to_string());
225 + active.decided_at = Set(Some(Utc::now()));
226 + Ok(active.update(db).await?)
227 + }
228 +}
229 +
230 +/// Build the AP `Invite{TeamMembership}` JSON-LD payload.
231 +pub fn build_invite_payload(
232 + config: &FediverseConfig,
233 + invite: &fediverse_pending_invite::Model,
234 + team: &team::Model,
235 +) -> serde_json::Value {
236 + serde_json::json!({
237 + "@context": [
238 + "https://www.w3.org/ns/activitystreams",
239 + "https://schema.rustwarden.org/team-membership/v1"
240 + ],
241 + "type": "Invite",
242 + "id": invite.activity_id,
243 + "actor": format!("{}", config.base_url()),
244 + "object": {
245 + "type": "TeamMembership",
246 + "team": {
247 + "id": format!("{}teams/{}", config.base_url(), team.id),
248 + "name": team.name,
249 + },
250 + "role": invite.role,
251 + },
252 + "target": invite.invited_handle,
253 + "expiresAt": invite.expires_at.to_rfc3339(),
254 + })
255 +}
256 +
257 +/// Build the AP `Accept{Invite}` activity sent back from the IdP.
258 +pub fn build_accept_invite_payload(
259 + config: &FediverseConfig,
260 + invite_activity_id: &str,
261 +) -> serde_json::Value {
262 + serde_json::json!({
263 + "@context": "https://www.w3.org/ns/activitystreams",
264 + "type": "Accept",
265 + "actor": format!("{}", config.base_url()),
266 + "object": invite_activity_id,
267 + })
268 +}
269 +
270 +/// Build the AP `Reject{Invite}` activity sent back from the IdP.
271 +pub fn build_reject_invite_payload(
272 + config: &FediverseConfig,
273 + invite_activity_id: &str,
274 +) -> serde_json::Value {
275 + serde_json::json!({
276 + "@context": "https://www.w3.org/ns/activitystreams",
277 + "type": "Reject",
278 + "actor": format!("{}", config.base_url()),
279 + "object": invite_activity_id,
280 + })
281 +}
282 +
283 +/// Build the AP `Revoke{Membership}` activity for either side.
284 +pub fn build_revoke_membership_payload(
285 + config: &FediverseConfig,
286 + membership_id: Uuid,
287 + team_id: Uuid,
288 +) -> serde_json::Value {
289 + serde_json::json!({
290 + "@context": "https://www.w3.org/ns/activitystreams",
291 + "type": "Revoke",
292 + "actor": format!("{}", config.base_url()),
293 + "object": {
294 + "type": "TeamMembership",
295 + "id": membership_id,
296 + "team": format!("{}teams/{}", config.base_url(), team_id),
297 + },
298 + })
299 +}
300 +
301 +#[cfg(test)]
302 +mod tests {
303 + use super::*;
304 + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};
305 +
306 + fn fresh_pending(id: Uuid, team_id: Uuid) -> fediverse_pending_invite::Model {
307 + let now = Utc::now();
308 + fediverse_pending_invite::Model {
309 + id,
310 + activity_id: format!("https://b.tld/invites/{id}"),
311 + team_id,
312 + invited_handle: "alice@a.tld".into(),
313 + invited_remote_identity_id: None,
314 + role: "member".into(),
315 + invited_by_user_id: Uuid::new_v4(),
316 + status: "pending".into(),
317 + created_at: now,
318 + expires_at: now + ChronoDuration::days(7),
319 + decided_at: None,
320 + }
321 + }
322 +
323 + fn test_fediverse_config() -> FediverseConfig {
324 + unsafe {
325 + std::env::set_var("FEDIVERSE_DOMAIN", "test.local");
326 + std::env::set_var("FEDIVERSE_DEBUG_LOCAL_FEDERATION", "true");
327 + }
328 + FediverseConfig::from_env().expect("FediverseConfig parse")
329 + }
330 +
331 + fn test_team(id: Uuid) -> team::Model {
332 + team::Model {
333 + id,
334 + slug: "engineering".into(),
335 + name: "Engineering".into(),
336 + owner_id: Uuid::new_v4(),
337 + created_at: Utc::now(),
338 + }
339 + }
340 +
341 + #[test]
342 + fn invite_status_round_trips_through_string() {
343 + for s in [
344 + InviteStatus::Pending,
345 + InviteStatus::Accepted,
346 + InviteStatus::Rejected,
347 + InviteStatus::Revoked,
348 + InviteStatus::Expired,
349 + ] {
350 + assert_eq!(InviteStatus::parse(s.as_str()), s);
351 + }
352 + assert_eq!(InviteStatus::parse("garbage"), InviteStatus::Pending);
353 + }
354 +
355 + #[test]
356 + fn build_invite_payload_includes_team_and_handle() {
357 + let config = test_fediverse_config();
358 + let team_id = Uuid::new_v4();
359 + let invite = fresh_pending(Uuid::new_v4(), team_id);
360 + let team = test_team(team_id);
361 + let payload = build_invite_payload(&config, &invite, &team);
362 + assert_eq!(payload["type"], serde_json::json!("Invite"));
363 + assert_eq!(payload["target"], serde_json::json!("alice@a.tld"));
364 + assert_eq!(payload["object"]["role"], serde_json::json!("member"));
365 + assert_eq!(payload["object"]["team"]["name"], serde_json::json!("Engineering"));
366 + }
367 +
368 + #[test]
369 + fn build_accept_invite_payload_references_invite_id() {
370 + let config = test_fediverse_config();
371 + let payload = build_accept_invite_payload(&config, "https://b.tld/invites/x");
372 + assert_eq!(payload["type"], serde_json::json!("Accept"));
373 + assert_eq!(payload["object"], serde_json::json!("https://b.tld/invites/x"));
374 + }
375 +
376 + #[tokio::test]
377 + async fn invite_remote_persists_pending_row() {
378 + let row = fresh_pending(Uuid::new_v4(), Uuid::new_v4());
379 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
380 + .append_query_results([vec![row.clone()]])
381 + .into_connection();
382 + let inserted = FediverseInviteService::invite_remote(
383 + &db,
384 + row.team_id,
385 + "alice@a.tld",
386 + TeamRole::Member,
387 + row.invited_by_user_id,
388 + Duration::from_secs(7 * 86400),
389 + )
390 + .await
391 + .unwrap();
392 + assert_eq!(inserted.status, "pending");
393 + }
394 +
395 + #[tokio::test]
396 + async fn invite_remote_uses_provided_role() {
397 + let mut row = fresh_pending(Uuid::new_v4(), Uuid::new_v4());
398 + row.role = "admin".into();
399 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
400 + .append_query_results([vec![row.clone()]])
401 + .into_connection();
402 + let inserted = FediverseInviteService::invite_remote(
403 + &db,
404 + row.team_id,
405 + "alice@a.tld",
406 + TeamRole::Admin,
407 + row.invited_by_user_id,
408 + Duration::from_secs(86400),
409 + )
410 + .await
411 + .unwrap();
412 + assert_eq!(inserted.role, "admin");
413 + }
414 +
415 + #[tokio::test]
416 + async fn accept_marks_invite_accepted_and_inserts_team_member() {
417 + let invite_id = Uuid::new_v4();
418 + let team_id = Uuid::new_v4();
419 + let remote_id = Uuid::new_v4();
420 + let invite = fresh_pending(invite_id, team_id);
421 +
422 + let mut after_invite = invite.clone();
423 + after_invite.status = "accepted".into();
424 + after_invite.invited_remote_identity_id = Some(remote_id);
425 + after_invite.decided_at = Some(Utc::now());
426 +
427 + let now = Utc::now();
428 + let new_member = team_member::Model {
429 + team_id,
430 + user_id: Uuid::nil(),
431 + role: "member".into(),
432 + joined_at: now,
433 + remote_identity_id: Some(remote_id),
434 + };
435 +
436 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
437 + // 1. SELECT invite
438 + .append_query_results([vec![invite]])
439 + // 2. SELECT issued by .update() on invite
440 + .append_query_results([vec![after_invite]])
441 + // 3. INSERT into team_members → returned row
442 + .append_query_results([vec![new_member.clone()]])
443 + .append_exec_results([
444 + MockExecResult { last_insert_id: 0, rows_affected: 1 },
445 + MockExecResult { last_insert_id: 0, rows_affected: 1 },
446 + ])
447 + .into_connection();
448 +
449 + let member = FediverseInviteService::accept(&db, invite_id, remote_id)
450 + .await
451 + .unwrap();
452 + assert_eq!(member.remote_identity_id, Some(remote_id));
453 + assert_eq!(member.team_id, team_id);
454 + }
455 +
456 + #[tokio::test]
457 + async fn accept_refuses_non_pending_invite() {
458 + let invite_id = Uuid::new_v4();
459 + let mut invite = fresh_pending(invite_id, Uuid::new_v4());
460 + invite.status = "rejected".into();
461 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
462 + .append_query_results([vec![invite]])
463 + .into_connection();
464 + let err = FediverseInviteService::accept(&db, invite_id, Uuid::new_v4())
465 + .await
466 + .expect_err("must refuse non-pending");
467 + assert!(matches!(err, AppError::Validation(_)));
468 + }
469 +
470 + #[tokio::test]
471 + async fn reject_marks_rejected() {
472 + let invite_id = Uuid::new_v4();
473 + let invite = fresh_pending(invite_id, Uuid::new_v4());
474 + let mut after = invite.clone();
475 + after.status = "rejected".into();
476 + after.decided_at = Some(Utc::now());
477 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
478 + .append_query_results([vec![invite]])
479 + .append_query_results([vec![after]])
480 + .append_exec_results([MockExecResult {
481 + last_insert_id: 0,
482 + rows_affected: 1,
483 + }])
484 + .into_connection();
485 + let updated = FediverseInviteService::reject(&db, invite_id).await.unwrap();
486 + assert_eq!(updated.status, "rejected");
487 + }
488 +
489 + #[tokio::test]
490 + async fn revoke_marks_revoked_when_pending() {
491 + let invite_id = Uuid::new_v4();
492 + let invite = fresh_pending(invite_id, Uuid::new_v4());
493 + let mut after = invite.clone();
494 + after.status = "revoked".into();
495 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
496 + .append_query_results([vec![invite]])
497 + .append_query_results([vec![after]])
498 + .append_exec_results([MockExecResult {
499 + last_insert_id: 0,
500 + rows_affected: 1,
501 + }])
502 + .into_connection();
503 + let updated = FediverseInviteService::revoke(&db, invite_id).await.unwrap();
504 + assert_eq!(updated.status, "revoked");
505 + }
506 +
507 + #[tokio::test]
508 + async fn revoke_marks_revoked_when_already_accepted() {
509 + let invite_id = Uuid::new_v4();
510 + let mut invite = fresh_pending(invite_id, Uuid::new_v4());
511 + invite.status = "accepted".into();
512 + invite.invited_remote_identity_id = Some(Uuid::new_v4());
513 + let mut after = invite.clone();
514 + after.status = "revoked".into();
515 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
516 + .append_query_results([vec![invite]])
517 + .append_query_results([vec![after]])
518 + .append_exec_results([MockExecResult {
519 + last_insert_id: 0,
520 + rows_affected: 1,
521 + }])
522 + .into_connection();
523 + let updated = FediverseInviteService::revoke(&db, invite_id).await.unwrap();
524 + assert_eq!(updated.status, "revoked");
525 + }
526 +
527 + #[tokio::test]
528 + async fn list_pending_for_team_returns_only_pending() {
529 + let team_id = Uuid::new_v4();
530 + let p1 = fresh_pending(Uuid::new_v4(), team_id);
531 + let p2 = fresh_pending(Uuid::new_v4(), team_id);
532 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
533 + .append_query_results([vec![p1.clone(), p2.clone()]])
534 + .into_connection();
535 + let result = FediverseInviteService::list_pending_for_team(&db, team_id)
536 + .await
537 + .unwrap();
538 + assert_eq!(result.len(), 2);
539 + assert!(result.iter().all(|r| r.status == "pending"));
540 + }
541 +}
A crates/rustwarden-core/src/services/fediverse_outbound_service.rs
+524 -0
@@ -0,0 +1,524 @@
1 +//! Outbound ActivityPub delivery queue + worker (RFC 0011).
2 +//!
3 +//! Persistent journal of activities that must be delivered to remote
4 +//! inboxes (auth `Accept{AuthRequest}`, invitations, lifecycle signals).
5 +//! The transport layer is abstracted behind [`OutboundDelivery`] so
6 +//! that RFC 0010 can plug in HTTP-Sig signing without disturbing the
7 +//! queue/worker plumbing.
8 +//!
9 +//! Gated by `experimental-fediverse`.
10 +
11 +use async_trait::async_trait;
12 +use chrono::Utc;
13 +use sea_orm::{
14 + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, ExprTrait, QueryFilter,
15 + QueryOrder, Set,
16 +};
17 +use std::time::Duration;
18 +use uuid::Uuid;
19 +
20 +use crate::error::AppError;
21 +use crate::models::fediverse_outbound_queue;
22 +
23 +/// Lifecycle of an outbound queue row.
24 +///
25 +/// In the Phase-2 slice livered by RFC 0011 only `Pending → Delivered`
26 +/// and `Pending → Failed` transitions occur. RFC 0010 introduces the
27 +/// retry/back-off ladder that uses `Failed → Pending` and the terminal
28 +/// `Failed → Dead`.
29 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30 +pub enum OutboundStatus {
31 + /// Eligible for delivery.
32 + Pending,
33 + /// Successfully delivered.
34 + Delivered,
35 + /// Last attempt failed; not (yet) eligible for retry.
36 + Failed,
37 + /// Reached `max_attempts`, no further attempts will be made.
38 + Dead,
39 +}
40 +
41 +impl OutboundStatus {
42 + /// RFC 0010 retry ladder. The returned offset is added to `now`
43 + /// to compute `next_retry_at` after a failed delivery. Sequence:
44 + /// 5 min → 30 min → 2 h → 6 h → 24 h → 48 h, capped at 48 h.
45 + /// `attempts` is the **post-increment** value (number of failed
46 + /// attempts so far).
47 + pub fn next_retry_offset(attempts: i32) -> std::time::Duration {
48 + const LADDER_SECS: &[u64] = &[
49 + 5 * 60,
50 + 30 * 60,
51 + 2 * 3600,
52 + 6 * 3600,
53 + 24 * 3600,
54 + 48 * 3600,
55 + ];
56 + let bounded = std::cmp::Ord::max(attempts, 1) as usize - 1;
57 + let idx = bounded.min(LADDER_SECS.len() - 1);
58 + std::time::Duration::from_secs(LADDER_SECS[idx])
59 + }
60 +
61 + /// Wire form stored in the `status` column.
62 + pub fn as_str(self) -> &'static str {
63 + match self {
64 + Self::Pending => "pending",
65 + Self::Delivered => "delivered",
66 + Self::Failed => "failed",
67 + Self::Dead => "dead",
68 + }
69 + }
70 +
71 + /// Best-effort parse — unknown values fall back to `Pending` so a
72 + /// data corruption never silently drops a row.
73 + pub fn parse(raw: &str) -> Self {
74 + match raw.trim().to_ascii_lowercase().as_str() {
75 + "delivered" => Self::Delivered,
76 + "failed" => Self::Failed,
77 + "dead" => Self::Dead,
78 + _ => Self::Pending,
79 + }
80 + }
81 +}
82 +
83 +/// Caller-supplied metadata used to enqueue an activity for delivery.
84 +#[derive(Debug, Clone)]
85 +pub struct EnqueueRequest {
86 + /// Canonical AP `id` of the activity (e.g. `https://a.tld/activities/uuid`).
87 + pub activity_id: String,
88 + /// Pre-rendered JSON-LD body that will be POSTed to `target_inbox`.
89 + pub payload_json: String,
90 + /// Inbox URL.
91 + pub target_inbox: String,
92 + /// Domain owning the inbox (used by trust checks).
93 + pub target_domain: String,
94 + /// Local actor on whose behalf the activity is signed. Nullable
95 + /// while only `NoopDelivery` is wired; required by RFC 0010.
96 + pub actor_user_id: Option<Uuid>,
97 +}
98 +
99 +/// Transport layer abstraction.
100 +///
101 +/// The Phase-2 slice ships only [`NoopDelivery`]. RFC 0010 will swap in
102 +/// `HttpSignedDelivery` that uses RFC 0005 keys to sign requests.
103 +#[async_trait]
104 +pub trait OutboundDelivery: Send + Sync {
105 + /// Attempt to deliver `item` to its `target_inbox`. Returning `Ok`
106 + /// promotes the row to `Delivered`; returning `Err` flips it to
107 + /// `Failed` and increments `attempts`.
108 + async fn deliver(&self, item: &fediverse_outbound_queue::Model) -> Result<(), AppError>;
109 +}
110 +
111 +/// No-op transport for the slice livered by RFC 0011: pretends every
112 +/// delivery succeeded so the queue/worker plumbing can be exercised
113 +/// end-to-end without an HTTP stack. Production deployments must swap
114 +/// for `HttpSignedDelivery` once RFC 0010 lands.
115 +pub struct NoopDelivery;
116 +
117 +#[async_trait]
118 +impl OutboundDelivery for NoopDelivery {
119 + async fn deliver(&self, item: &fediverse_outbound_queue::Model) -> Result<(), AppError> {
120 + tracing::debug!(
121 + target = %item.target_inbox,
122 + activity = %item.activity_id,
123 + "NoopDelivery: pretending delivery succeeded",
124 + );
125 + Ok(())
126 + }
127 +}
128 +
129 +/// Stateless service exposing enqueue + drain operations on the
130 +/// outbound queue.
131 +pub struct FediverseOutboundService;
132 +
133 +impl FediverseOutboundService {
134 + /// Persist a new `pending` row.
135 + pub async fn enqueue(
136 + db: &DatabaseConnection,
137 + request: EnqueueRequest,
138 + ) -> Result<fediverse_outbound_queue::Model, AppError> {
139 + let now = Utc::now();
140 + let model = fediverse_outbound_queue::ActiveModel {
141 + id: Set(Uuid::new_v4()),
142 + activity_id: Set(request.activity_id),
143 + payload_json: Set(request.payload_json),
144 + target_inbox: Set(request.target_inbox),
145 + target_domain: Set(request.target_domain),
146 + status: Set(OutboundStatus::Pending.as_str().to_string()),
147 + attempts: Set(0),
148 + max_attempts: Set(8),
149 + last_error: Set(None),
150 + next_retry_at: Set(None),
151 + created_at: Set(now),
152 + delivered_at: Set(None),
153 + actor_user_id: Set(request.actor_user_id),
154 + };
155 + Ok(model.insert(db).await?)
156 + }
157 +
158 + /// Pick one eligible row, hand it to `transport`, and persist the
159 + /// outcome. Returns the updated row, or `None` if the queue is
160 + /// empty.
161 + pub async fn drain_one<T: OutboundDelivery>(
162 + db: &DatabaseConnection,
163 + transport: &T,
164 + ) -> Result<Option<fediverse_outbound_queue::Model>, AppError> {
165 + let now = Utc::now();
166 + let candidate = fediverse_outbound_queue::Entity::find()
167 + .filter(
168 + fediverse_outbound_queue::Column::Status.eq(OutboundStatus::Pending.as_str()),
169 + )
170 + .filter(
171 + fediverse_outbound_queue::Column::NextRetryAt
172 + .is_null()
173 + .or(fediverse_outbound_queue::Column::NextRetryAt.lte(now)),
174 + )
175 + .order_by_asc(fediverse_outbound_queue::Column::CreatedAt)
176 + .one(db)
177 + .await?;
178 +
179 + let Some(model) = candidate else {
180 + return Ok(None);
181 + };
182 +
183 + let delivery = transport.deliver(&model).await;
184 + let next_attempts = model.attempts + 1;
185 + let max_attempts = model.max_attempts;
186 + let model_for_update = model.clone();
187 + let mut active: fediverse_outbound_queue::ActiveModel = model_for_update.into();
188 + active.attempts = Set(next_attempts);
189 + match delivery {
190 + Ok(()) => {
191 + active.status = Set(OutboundStatus::Delivered.as_str().to_string());
192 + active.delivered_at = Set(Some(Utc::now()));
193 + active.last_error = Set(None);
194 + active.next_retry_at = Set(None);
195 + }
196 + Err(err) => {
197 + active.last_error = Set(Some(err.to_string()));
198 + if next_attempts >= max_attempts {
199 + // RFC 0010: terminal — no further retry.
200 + active.status = Set(OutboundStatus::Dead.as_str().to_string());
201 + active.next_retry_at = Set(None);
202 + } else {
203 + // RFC 0010: stay `pending` and gate on `next_retry_at`
204 + // so the partial-index scan ignores the row until the
205 + // backoff has elapsed.
206 + let offset = OutboundStatus::next_retry_offset(next_attempts);
207 + let next = Utc::now()
208 + + chrono::Duration::from_std(offset).map_err(|e| {
209 + AppError::Internal(format!(
210 + "retry offset out of range for chrono::Duration: {e}",
211 + ))
212 + })?;
213 + active.status = Set(OutboundStatus::Pending.as_str().to_string());
214 + active.next_retry_at = Set(Some(next));
215 + }
216 + }
217 + }
218 + let updated = active.update(db).await?;
219 + Ok(Some(updated))
220 + }
221 +}
222 +
223 +/// Background loop that periodically drains the queue.
224 +///
225 +/// Spawned by `RustwardenBuilder::with_fediverse()` (RFC 0011 hooks
226 +/// the spawn into `with_fediverse` in a follow-up patch — RFC 0006
227 +/// will be the first consumer and triggers the spawn explicitly).
228 +pub struct OutboundWorker<T: OutboundDelivery> {
229 + db: DatabaseConnection,
230 + transport: T,
231 + poll_interval: Duration,
232 +}
233 +
234 +impl<T: OutboundDelivery> OutboundWorker<T> {
235 + /// Build a worker that polls every `poll_interval`.
236 + pub fn new(db: DatabaseConnection, transport: T, poll_interval: Duration) -> Self {
237 + Self {
238 + db,
239 + transport,
240 + poll_interval,
241 + }
242 + }
243 +
244 + /// Run the polling loop forever. Errors during a single delivery
245 + /// are logged but do not stop the loop. Cancellation is achieved
246 + /// by cancelling the surrounding task.
247 + pub async fn run(self) {
248 + loop {
249 + match FediverseOutboundService::drain_one(&self.db, &self.transport).await {
250 + Ok(Some(_)) => continue, // drain greedily while items exist
251 + Ok(None) => tokio::time::sleep(self.poll_interval).await,
252 + Err(err) => {
253 + tracing::error!(error = %err, "outbound worker drain failed");
254 + tokio::time::sleep(self.poll_interval).await;
255 + }
256 + }
257 + }
258 + }
259 +}
260 +
261 +#[cfg(test)]
262 +mod tests {
263 + use super::*;
264 + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};
265 +
266 + fn pending_row(id: Uuid) -> fediverse_outbound_queue::Model {
267 + let now = Utc::now();
268 + fediverse_outbound_queue::Model {
269 + id,
270 + activity_id: format!("https://a.tld/activities/{id}"),
271 + payload_json: "{}".to_string(),
272 + target_inbox: "https://b.tld/inbox".to_string(),
273 + target_domain: "b.tld".to_string(),
274 + status: "pending".to_string(),
275 + attempts: 0,
276 + max_attempts: 8,
277 + last_error: None,
278 + next_retry_at: None,
279 + created_at: now,
280 + delivered_at: None,
281 + actor_user_id: None,
282 + }
283 + }
284 +
285 + struct ErringDelivery;
286 +
287 + #[async_trait]
288 + impl OutboundDelivery for ErringDelivery {
289 + async fn deliver(
290 + &self,
291 + _item: &fediverse_outbound_queue::Model,
292 + ) -> Result<(), AppError> {
293 + Err(AppError::Internal("simulated transport failure".into()))
294 + }
295 + }
296 +
297 + #[test]
298 + fn outbound_status_round_trips_through_string() {
299 + for s in [
300 + OutboundStatus::Pending,
301 + OutboundStatus::Delivered,
302 + OutboundStatus::Failed,
303 + OutboundStatus::Dead,
304 + ] {
305 + assert_eq!(OutboundStatus::parse(s.as_str()), s);
306 + }
307 + // Unknown wire value falls back safely.
308 + assert_eq!(OutboundStatus::parse("garbage"), OutboundStatus::Pending);
309 + }
310 +
311 + #[test]
312 + fn enqueue_request_clone_works() {
313 + let r = EnqueueRequest {
314 + activity_id: "act".into(),
315 + payload_json: "{}".into(),
316 + target_inbox: "ib".into(),
317 + target_domain: "d".into(),
318 + actor_user_id: None,
319 + };
320 + let c = r.clone();
321 + assert_eq!(c.activity_id, "act");
322 + }
323 +
324 + #[tokio::test]
325 + async fn enqueue_persists_pending_row() {
326 + let id = Uuid::new_v4();
327 + let row = pending_row(id);
328 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
329 + .append_query_results([vec![row.clone()]])
330 + .into_connection();
331 +
332 + let inserted = FediverseOutboundService::enqueue(
333 + &db,
334 + EnqueueRequest {
335 + activity_id: row.activity_id.clone(),
336 + payload_json: row.payload_json.clone(),
337 + target_inbox: row.target_inbox.clone(),
338 + target_domain: row.target_domain.clone(),
339 + actor_user_id: None,
340 + },
341 + )
342 + .await
343 + .expect("enqueue ok");
344 + assert_eq!(inserted.status, "pending");
345 + assert_eq!(inserted.attempts, 0);
346 + }
347 +
348 + #[tokio::test]
349 + async fn enqueue_uses_provided_activity_id() {
350 + let id = Uuid::new_v4();
351 + let mut row = pending_row(id);
352 + row.activity_id = "https://example.test/specific-id".into();
353 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
354 + .append_query_results([vec![row.clone()]])
355 + .into_connection();
356 +
357 + let inserted = FediverseOutboundService::enqueue(
358 + &db,
359 + EnqueueRequest {
360 + activity_id: row.activity_id.clone(),
361 + payload_json: "{}".into(),
362 + target_inbox: row.target_inbox.clone(),
363 + target_domain: row.target_domain.clone(),
364 + actor_user_id: None,
365 + },
366 + )
367 + .await
368 + .unwrap();
369 + assert_eq!(inserted.activity_id, "https://example.test/specific-id");
370 + }
371 +
372 + #[tokio::test]
373 + async fn drain_one_returns_none_when_empty() {
374 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
375 + .append_query_results([Vec::<fediverse_outbound_queue::Model>::new()])
376 + .into_connection();
377 + let result = FediverseOutboundService::drain_one(&db, &NoopDelivery)
378 + .await
379 + .unwrap();
380 + assert!(result.is_none());
381 + }
382 +
383 + #[tokio::test]
384 + async fn drain_one_marks_delivered_on_success() {
385 + let id = Uuid::new_v4();
386 + let row = pending_row(id);
387 + let mut after = row.clone();
388 + after.status = "delivered".into();
389 + after.attempts = 1;
390 + after.delivered_at = Some(Utc::now());
391 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
392 + // SELECT for candidate
393 + .append_query_results([vec![row.clone()]])
394 + // Row returned by .update()
395 + .append_query_results([vec![after]])
396 + .append_exec_results([MockExecResult {
397 + last_insert_id: 0,
398 + rows_affected: 1,
399 + }])
400 + .into_connection();
401 +
402 + let updated = FediverseOutboundService::drain_one(&db, &NoopDelivery)
403 + .await
404 + .unwrap()
405 + .expect("row processed");
406 + assert_eq!(updated.status, "delivered");
407 + assert_eq!(updated.attempts, 1);
408 + assert!(updated.last_error.is_none());
409 + }
410 +
411 + #[tokio::test]
412 + async fn drain_one_marks_failed_pending_with_next_retry_at_when_below_max_attempts() {
413 + // RFC 0010: a single failure on a row with attempts < max
414 + // keeps the row `pending` but gated on `next_retry_at`.
415 + let id = Uuid::new_v4();
416 + let row = pending_row(id);
417 + let mut after = row.clone();
418 + after.status = "pending".into();
419 + after.attempts = 1;
420 + after.last_error = Some("simulated transport failure".into());
421 + after.next_retry_at = Some(Utc::now() + chrono::Duration::minutes(5));
422 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
423 + .append_query_results([vec![row.clone()]])
424 + .append_query_results([vec![after]])
425 + .append_exec_results([MockExecResult {
426 + last_insert_id: 0,
427 + rows_affected: 1,
428 + }])
429 + .into_connection();
430 +
431 + let updated = FediverseOutboundService::drain_one(&db, &ErringDelivery)
432 + .await
433 + .unwrap()
434 + .expect("row processed");
435 + assert_eq!(updated.status, "pending");
436 + assert!(updated.last_error.is_some());
437 + assert!(updated.next_retry_at.is_some());
438 + }
439 +
440 + #[tokio::test]
441 + async fn drain_one_marks_dead_when_attempts_reach_max() {
442 + let id = Uuid::new_v4();
443 + let mut row = pending_row(id);
444 + row.attempts = 7;
445 + row.max_attempts = 8;
446 + let mut after = row.clone();
447 + after.status = "dead".into();
448 + after.attempts = 8;
449 + after.last_error = Some("simulated transport failure".into());
450 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
451 + .append_query_results([vec![row]])
452 + .append_query_results([vec![after]])
453 + .append_exec_results([MockExecResult {
454 + last_insert_id: 0,
455 + rows_affected: 1,
456 + }])
457 + .into_connection();
458 +
459 + let updated = FediverseOutboundService::drain_one(&db, &ErringDelivery)
460 + .await
461 + .unwrap()
462 + .unwrap();
463 + assert_eq!(updated.status, "dead");
464 + assert!(updated.next_retry_at.is_none());
465 + }
466 +
467 + #[test]
468 + fn next_retry_offset_grows_then_caps() {
469 + let secs: Vec<u64> = (1..=8)
470 + .map(|i| OutboundStatus::next_retry_offset(i).as_secs())
471 + .collect();
472 + // Strictly increasing for the first 6 entries.
473 + assert!(secs[0] < secs[1]);
474 + assert!(secs[1] < secs[2]);
475 + assert!(secs[2] < secs[3]);
476 + assert!(secs[3] < secs[4]);
477 + assert!(secs[4] < secs[5]);
478 + // Cap at 48h afterwards.
479 + assert_eq!(secs[5], 48 * 3600);
480 + assert_eq!(secs[6], 48 * 3600);
481 + assert_eq!(secs[7], 48 * 3600);
482 + }
483 +
484 + #[tokio::test]
485 + async fn drain_one_increments_attempts() {
486 + let id = Uuid::new_v4();
487 + let mut row = pending_row(id);
488 + row.attempts = 3;
489 + let mut after = row.clone();
490 + after.status = "delivered".into();
491 + after.attempts = 4;
492 + after.delivered_at = Some(Utc::now());
493 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
494 + .append_query_results([vec![row.clone()]])
495 + .append_query_results([vec![after]])
496 + .append_exec_results([MockExecResult {
497 + last_insert_id: 0,
498 + rows_affected: 1,
499 + }])
500 + .into_connection();
501 +
502 + let updated = FediverseOutboundService::drain_one(&db, &NoopDelivery)
503 + .await
504 + .unwrap()
505 + .unwrap();
506 + assert_eq!(updated.attempts, 4);
507 + }
508 +
509 + #[tokio::test]
510 + async fn noop_delivery_returns_ok() {
511 + let row = pending_row(Uuid::new_v4());
512 + assert!(NoopDelivery.deliver(&row).await.is_ok());
513 + }
514 +
515 + #[tokio::test]
516 + async fn worker_can_be_built_and_dropped() {
517 + // Smoke test : building the struct and dropping it before the
518 + // loop runs must not panic.
519 + let db: DatabaseConnection =
520 + MockDatabase::new(DatabaseBackend::Postgres).into_connection();
521 + let _worker = OutboundWorker::new(db, NoopDelivery, Duration::from_millis(10));
522 + // Drop happens at end of scope.
523 + }
524 +}
A crates/rustwarden-core/src/services/fediverse_signal_service.rs
+422 -0
@@ -0,0 +1,422 @@
1 +//! Fediverse lifecycle signal handlers (RFC 0010).
2 +//!
3 +//! Applies incoming AP `Update{Actor}` / `Delete{Actor}` /
4 +//! `Revoke{Membership}` signals to the local state. The HTTP-Sig
5 +//! verification of the request that carried the signal is delegated to
6 +//! [`SignatureVerifier`] — the implementation that ships with this RFC
7 +//! is `AlwaysAcceptVerifier` (test-only); a follow-up RFC will plug
8 +//! the real RFC 9421 verifier.
9 +//!
10 +//! Gated by `experimental-fediverse`.
11 +
12 +use async_trait::async_trait;
13 +use chrono::Utc;
14 +use sea_orm::{
15 + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set,
16 +};
17 +use uuid::Uuid;
18 +
19 +use crate::error::AppError;
20 +use crate::models::{remote_identity, team_member};
21 +
22 +/// Carries the actor metadata extracted from an inbound `Update{Actor}`
23 +/// activity.
24 +#[derive(Debug, Clone)]
25 +pub struct UpdatedActor {
26 + /// Canonical AP `id`. Match key against `remote_identities.ap_id`.
27 + pub ap_id: String,
28 + /// Optional display name (`name` field on the AP `Person`).
29 + pub display_name: Option<String>,
30 + /// Optional bio (`summary` field).
31 + pub summary_html: Option<String>,
32 + /// Optional avatar URL (`icon.url`).
33 + pub icon_url: Option<String>,
34 + /// Optional refreshed public key.
35 + pub public_key_pem: Option<String>,
36 + /// Optional raw activity body for audit / re-processing.
37 + pub raw_json: Option<serde_json::Value>,
38 +}
39 +
40 +/// Counters returned by [`FediverseSignalService::apply_delete_actor`].
41 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
42 +pub struct DeleteOutcome {
43 + /// Number of `remote_identities` rows soft-deleted.
44 + pub rows_disabled: u64,
45 + /// Number of JWTs blacklisted (always `0` for the RFC 0010 slice;
46 + /// a complementary `JwtBlacklistService::blacklist_by_remote_identity_id`
47 + /// API will close that gap in a follow-up).
48 + pub jwts_blacklisted: u64,
49 +}
50 +
51 +/// Stateless service that applies AP lifecycle signals.
52 +pub struct FediverseSignalService;
53 +
54 +impl FediverseSignalService {
55 + /// Apply an inbound `Update{Actor}`: upsert the row keyed by
56 + /// `ap_id`. The local fields not carried by `actor` are preserved
57 + /// (no destructive overwrite).
58 + pub async fn apply_update_actor(
59 + db: &DatabaseConnection,
60 + actor: UpdatedActor,
61 + ) -> Result<remote_identity::Model, AppError> {
62 + let existing = remote_identity::Entity::find()
63 + .filter(remote_identity::Column::ApId.eq(actor.ap_id.clone()))
64 + .one(db)
65 + .await?;
66 +
67 + let now = Utc::now();
68 + if let Some(model) = existing {
69 + let mut active: remote_identity::ActiveModel = model.into();
70 + if let Some(name) = actor.display_name {
71 + active.display_name = Set(Some(name));
72 + }
73 + if let Some(summary) = actor.summary_html {
74 + active.summary_html = Set(Some(summary));
75 + }
76 + if let Some(icon) = actor.icon_url {
77 + active.icon_url = Set(Some(icon));
78 + }
79 + if let Some(pem) = actor.public_key_pem {
80 + active.public_key_pem = Set(pem);
81 + }
82 + if let Some(raw) = actor.raw_json {
83 + active.raw_json = Set(Some(raw));
84 + }
85 + active.last_fetched_at = Set(now);
86 + Ok(active.update(db).await?)
87 + } else {
88 + // Minimal insert. The caller (inbox handler) is responsible
89 + // for filling in the rest via webfinger lookup before
90 + // calling apply_update_actor — but if the row truly is new
91 + // we still want to persist *something* rather than 404.
92 + let username_domain = parse_handle_from_ap_id(&actor.ap_id).unwrap_or_else(|| {
93 + ("unknown".to_string(), "unknown".to_string())
94 + });
95 + let active = remote_identity::ActiveModel {
96 + id: Set(Uuid::new_v4()),
97 + ap_id: Set(actor.ap_id.clone()),
98 + username: Set(username_domain.0),
99 + domain: Set(username_domain.1),
100 + display_name: Set(actor.display_name),
101 + summary_html: Set(actor.summary_html),
102 + inbox_url: Set(format!("{}/inbox", actor.ap_id)),
103 + shared_inbox_url: Set(None),
104 + public_key_pem: Set(actor.public_key_pem.unwrap_or_default()),
105 + icon_url: Set(actor.icon_url),
106 + actor_type: Set("Person".to_string()),
107 + raw_json: Set(actor.raw_json),
108 + last_fetched_at: Set(now),
109 + disabled_at: Set(None),
110 + created_at: Set(now),
111 + updated_at: Set(now),
112 + };
113 + Ok(active.insert(db).await?)
114 + }
115 + }
116 +
117 + /// Apply an inbound `Delete{Actor}`: flip `disabled_at` to NOW for
118 + /// the matching `remote_identities` row. JWT blacklisting is
119 + /// returned as `0` — see [`DeleteOutcome::jwts_blacklisted`].
120 + pub async fn apply_delete_actor(
121 + db: &DatabaseConnection,
122 + ap_id: &str,
123 + ) -> Result<DeleteOutcome, AppError> {
124 + let model = remote_identity::Entity::find()
125 + .filter(remote_identity::Column::ApId.eq(ap_id))
126 + .one(db)
127 + .await?;
128 +
129 + let Some(row) = model else {
130 + return Ok(DeleteOutcome::default());
131 + };
132 +
133 + if row.disabled_at.is_some() {
134 + return Ok(DeleteOutcome {
135 + rows_disabled: 0,
136 + jwts_blacklisted: 0,
137 + });
138 + }
139 +
140 + let mut active: remote_identity::ActiveModel = row.into();
141 + active.disabled_at = Set(Some(Utc::now()));
142 + active.update(db).await?;
143 +
144 + Ok(DeleteOutcome {
145 + rows_disabled: 1,
146 + jwts_blacklisted: 0,
147 + })
148 + }
149 +
150 + /// Apply an inbound `Revoke{Membership}`: drop the matching
151 + /// `team_members` row. Returns `true` if a row was deleted.
152 + pub async fn apply_revoke_membership(
153 + db: &DatabaseConnection,
154 + team_id: Uuid,
155 + remote_identity_id: Uuid,
156 + ) -> Result<bool, AppError> {
157 + let result = team_member::Entity::delete_many()
158 + .filter(team_member::Column::TeamId.eq(team_id))
159 + .filter(
160 + team_member::Column::RemoteIdentityId
161 + .eq(Some(remote_identity_id)),
162 + )
163 + .exec(db)
164 + .await?;
165 + Ok(result.rows_affected > 0)
166 + }
167 +}
168 +
169 +/// HTTP-Sig verifier for inbound AP activities.
170 +///
171 +/// Implementations decide whether to accept a given signature. The
172 +/// production RFC 9421 implementation is shipped by a dedicated crypto
173 +/// RFC; for tests and dev `AlwaysAcceptVerifier` is provided.
174 +#[async_trait]
175 +pub trait SignatureVerifier: Send + Sync {
176 + /// Verify a signature.
177 + async fn verify(
178 + &self,
179 + actor_ap_id: &str,
180 + signature_header: &str,
181 + body: &[u8],
182 + public_key_pem: &str,
183 + ) -> Result<(), AppError>;
184 +}
185 +
186 +/// Test/dev verifier that accepts any signature.
187 +///
188 +/// **Never use in production.** A logged warning emphasises the
189 +/// hazard at instantiation in production builds.
190 +pub struct AlwaysAcceptVerifier;
191 +
192 +#[async_trait]
193 +impl SignatureVerifier for AlwaysAcceptVerifier {
194 + async fn verify(
195 + &self,
196 + actor_ap_id: &str,
197 + _signature_header: &str,
198 + _body: &[u8],
199 + _public_key_pem: &str,
200 + ) -> Result<(), AppError> {
201 + tracing::warn!(
202 + actor = %actor_ap_id,
203 + "AlwaysAcceptVerifier in use — DO NOT enable in production",
204 + );
205 + Ok(())
206 + }
207 +}
208 +
209 +fn parse_handle_from_ap_id(ap_id: &str) -> Option<(String, String)> {
210 + let url = url::Url::parse(ap_id).ok()?;
211 + let host = url.host_str()?.to_string();
212 + let user = url.path_segments()?.next_back()?.to_string();
213 + Some((user, host))
214 +}
215 +
216 +#[cfg(test)]
217 +mod tests {
218 + use super::*;
219 + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};
220 +
221 + fn fresh_remote(id: Uuid, ap_id: &str) -> remote_identity::Model {
222 + let now = Utc::now();
223 + remote_identity::Model {
224 + id,
225 + ap_id: ap_id.to_string(),
226 + username: "alice".into(),
227 + domain: "a.tld".into(),
228 + display_name: Some("Alice".into()),
229 + summary_html: None,
230 + inbox_url: format!("{ap_id}/inbox"),
231 + shared_inbox_url: None,
232 + public_key_pem: "-----BEGIN PUBLIC KEY-----\n".into(),
233 + icon_url: None,
234 + actor_type: "Person".into(),
235 + raw_json: None,
236 + last_fetched_at: now,
237 + disabled_at: None,
238 + created_at: now,
239 + updated_at: now,
240 + }
241 + }
242 +
243 + // ─── Pure ────────────────────────────────────────────────────────
244 +
245 + #[test]
246 + fn delete_outcome_default_is_zero() {
247 + assert_eq!(DeleteOutcome::default().rows_disabled, 0);
248 + assert_eq!(DeleteOutcome::default().jwts_blacklisted, 0);
249 + }
250 +
251 + // ─── apply_update_actor ─────────────────────────────────────────
252 +
253 + #[tokio::test]
254 + async fn apply_update_actor_upserts_when_existing() {
255 + let id = Uuid::new_v4();
256 + let row = fresh_remote(id, "https://a.tld/users/alice");
257 + let mut after = row.clone();
258 + after.display_name = Some("Alice McUpdated".into());
259 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
260 + .append_query_results([vec![row]])
261 + .append_query_results([vec![after]])
262 + .append_exec_results([MockExecResult {
263 + last_insert_id: 0,
264 + rows_affected: 1,
265 + }])
266 + .into_connection();
267 +
268 + let updated = FediverseSignalService::apply_update_actor(
269 + &db,
270 + UpdatedActor {
271 + ap_id: "https://a.tld/users/alice".into(),
272 + display_name: Some("Alice McUpdated".into()),
273 + summary_html: None,
274 + icon_url: None,
275 + public_key_pem: None,
276 + raw_json: None,
277 + },
278 + )
279 + .await
280 + .unwrap();
281 + assert_eq!(updated.display_name.as_deref(), Some("Alice McUpdated"));
282 + }
283 +
284 + #[tokio::test]
285 + async fn apply_update_actor_inserts_when_missing() {
286 + let id = Uuid::new_v4();
287 + let new_row = fresh_remote(id, "https://a.tld/users/bob");
288 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
289 + // 1. SELECT existing → empty
290 + .append_query_results([Vec::<remote_identity::Model>::new()])
291 + // 2. INSERT returns the row
292 + .append_query_results([vec![new_row.clone()]])
293 + .append_exec_results([MockExecResult {
294 + last_insert_id: 0,
295 + rows_affected: 1,
296 + }])
297 + .into_connection();
298 +
299 + let inserted = FediverseSignalService::apply_update_actor(
300 + &db,
301 + UpdatedActor {
302 + ap_id: "https://a.tld/users/bob".into(),
303 + display_name: None,
304 + summary_html: None,
305 + icon_url: None,
306 + public_key_pem: None,
307 + raw_json: None,
308 + },
309 + )
310 + .await
311 + .unwrap();
312 + assert_eq!(inserted.ap_id, new_row.ap_id);
313 + }
314 +
315 + // ─── apply_delete_actor ─────────────────────────────────────────
316 +
317 + #[tokio::test]
318 + async fn apply_delete_actor_sets_disabled_at_and_returns_one() {
319 + let id = Uuid::new_v4();
320 + let row = fresh_remote(id, "https://a.tld/users/alice");
321 + let mut after = row.clone();
322 + after.disabled_at = Some(Utc::now());
323 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
324 + .append_query_results([vec![row]])
325 + .append_query_results([vec![after]])
326 + .append_exec_results([MockExecResult {
327 + last_insert_id: 0,
328 + rows_affected: 1,
329 + }])
330 + .into_connection();
331 + let outcome =
332 + FediverseSignalService::apply_delete_actor(&db, "https://a.tld/users/alice")
333 + .await
334 + .unwrap();
335 + assert_eq!(outcome.rows_disabled, 1);
336 + assert_eq!(outcome.jwts_blacklisted, 0);
337 + }
338 +
339 + #[tokio::test]
340 + async fn apply_delete_actor_returns_zero_when_no_row() {
341 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
342 + .append_query_results([Vec::<remote_identity::Model>::new()])
343 + .into_connection();
344 + let outcome =
345 + FediverseSignalService::apply_delete_actor(&db, "https://a.tld/users/ghost")
346 + .await
347 + .unwrap();
348 + assert_eq!(outcome, DeleteOutcome::default());
349 + }
350 +
351 + #[tokio::test]
352 + async fn apply_delete_actor_returns_zero_when_already_disabled() {
353 + let id = Uuid::new_v4();
354 + let mut row = fresh_remote(id, "https://a.tld/users/alice");
355 + row.disabled_at = Some(Utc::now());
356 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
357 + .append_query_results([vec![row]])
358 + .into_connection();
359 + let outcome =
360 + FediverseSignalService::apply_delete_actor(&db, "https://a.tld/users/alice")
361 + .await
362 + .unwrap();
363 + assert_eq!(outcome.rows_disabled, 0);
364 + }
365 +
366 + // ─── apply_revoke_membership ────────────────────────────────────
367 +
368 + #[tokio::test]
369 + async fn apply_revoke_membership_returns_false_when_no_row() {
370 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
371 + .append_exec_results([MockExecResult {
372 + last_insert_id: 0,
373 + rows_affected: 0,
374 + }])
375 + .into_connection();
376 + let revoked = FediverseSignalService::apply_revoke_membership(
377 + &db,
378 + Uuid::new_v4(),
379 + Uuid::new_v4(),
380 + )
381 + .await
382 + .unwrap();
383 + assert!(!revoked);
384 + }
385 +
386 + #[tokio::test]
387 + async fn apply_revoke_membership_returns_true_when_row_dropped() {
388 + let db: DatabaseConnection = MockDatabase::new(DatabaseBackend::Postgres)
389 + .append_exec_results([MockExecResult {
390 + last_insert_id: 0,
391 + rows_affected: 1,
392 + }])
393 + .into_connection();
394 + let revoked = FediverseSignalService::apply_revoke_membership(
395 + &db,
396 + Uuid::new_v4(),
397 + Uuid::new_v4(),
398 + )
399 + .await
400 + .unwrap();
401 + assert!(revoked);
402 + }
403 +
404 + // ─── SignatureVerifier ──────────────────────────────────────────
405 +
406 + #[tokio::test]
407 + async fn always_accept_verifier_accepts_any_input() {
408 + AlwaysAcceptVerifier
409 + .verify("https://a.tld/users/alice", "sig=...", b"body", "pem")
410 + .await
411 + .expect("AlwaysAcceptVerifier never rejects");
412 + }
413 +
414 + #[tokio::test]
415 + async fn signature_verifier_trait_object_works() {
416 + let verifier: Box<dyn SignatureVerifier> = Box::new(AlwaysAcceptVerifier);
417 + verifier
418 + .verify("a", "b", b"c", "d")
419 + .await
420 + .expect("trait object dispatches");
421 + }
422 +}
A crates/rustwarden-core/src/services/fediverse_trust_service.rs
+430 -0
@@ -0,0 +1,430 @@
1 +//! Federation trust model service (RFC 0004).
2 +//!
3 +//! Implements the runtime-configurable trust check applied **before** any
4 +//! signature verification or fetch towards a remote Fediverse domain. The
5 +//! mode (allowlist / open + blocklist), the global enabled flag, and the
6 +//! interop scope are stored in `app_settings`; the lists themselves live
7 +//! in `fediverse_trusted_domains` and `fediverse_blocked_domains`.
8 +//!
9 +//! Gated by `experimental-fediverse`.
10 +
11 +use chrono::Utc;
12 +use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
13 +use uuid::Uuid;
14 +
15 +use crate::error::AppError;
16 +use crate::models::{fediverse_blocked_domain, fediverse_trusted_domain};
17 +use crate::services::app_settings_service::AppSettingsService;
18 +
19 +/// Available federation trust modes (`app_settings.fediverse_trust_mode`).
20 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21 +pub enum TrustMode {
22 + /// Zero-trust default: only domains explicitly added to the allowlist
23 + /// are accepted.
24 + Allowlist,
25 + /// Mastodon-style: any domain is accepted unless present in the
26 + /// blocklist.
27 + OpenWithBlocklist,
28 +}
29 +
30 +impl TrustMode {
31 + /// Parse the wire form stored in `app_settings`.
32 + pub fn parse(raw: &str) -> Self {
33 + match raw.trim().to_ascii_lowercase().as_str() {
34 + "open_with_blocklist" | "open" | "blocklist" => Self::OpenWithBlocklist,
35 + _ => Self::Allowlist,
36 + }
37 + }
38 +}
39 +
40 +/// Available interop scopes (`app_settings.fediverse_interop_scope`).
41 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42 +pub enum InteropScope {
43 + /// Authentication and invitations restricted to Rustwarden instances.
44 + RustwardenOnly,
45 + /// Auth restricted to Rustwarden, but profile reads accepted from any
46 + /// AP-compatible instance (Mastodon discovery use-case).
47 + RustwardenPlusUniversalRead,
48 + /// Anything goes (advanced).
49 + Open,
50 +}
51 +
52 +impl InteropScope {
53 + /// Parse the wire form stored in `app_settings`.
54 + pub fn parse(raw: &str) -> Self {
55 + match raw.trim().to_ascii_lowercase().as_str() {
56 + "rustwarden_plus_universal_read" | "universal_read" => {
57 + Self::RustwardenPlusUniversalRead
58 + }
59 + "open" => Self::Open,
60 + _ => Self::RustwardenOnly,
61 + }
62 + }
63 +}
64 +
65 +/// Severity level of a [`fediverse_blocked_domain`] entry.
66 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67 +pub enum BlockSeverity {
68 + /// Reject all activities and auth requests from the domain.
69 + Suspend,
70 + /// Accept but flag activities silently.
71 + Silence,
72 + /// Reject auth specifically (allow read-only profile lookups).
73 + RejectAuth,
74 +}
75 +
76 +impl BlockSeverity {
77 + /// Parse the wire form. Falls back to [`BlockSeverity::Suspend`] for
78 + /// unknown values (conservative default).
79 + pub fn parse(raw: &str) -> Self {
80 + match raw.trim().to_ascii_lowercase().as_str() {
81 + "silence" => Self::Silence,
82 + "reject_auth" => Self::RejectAuth,
83 + _ => Self::Suspend,
84 + }
85 + }
86 +}
87 +
88 +/// Normalise a domain to its canonical comparison form: trim whitespace,
89 +/// lowercase, strip a single leading `www.` segment.
90 +pub fn normalize_domain(raw: &str) -> String {
91 + let trimmed = raw.trim().to_ascii_lowercase();
92 + trimmed
93 + .strip_prefix("www.")
94 + .map(|s| s.to_string())
95 + .unwrap_or(trimmed)
96 +}
97 +
98 +/// Service exposing the central `is_domain_allowed` gate plus admin CRUD
99 +/// over the trust/block lists.
100 +#[derive(Clone)]
101 +pub struct FediverseTrustService {
102 + db: DatabaseConnection,
103 +}
104 +
105 +impl FediverseTrustService {
106 + /// Build a service bound to the given DB connection.
107 + pub fn new(db: DatabaseConnection) -> Self {
108 + Self { db }
109 + }
110 +
111 + /// Read `app_settings.fediverse_enabled` (default `false`).
112 + pub async fn is_enabled(&self) -> Result<bool, AppError> {
113 + AppSettingsService::get_bool(&self.db, "fediverse_enabled", false).await
114 + }
115 +
116 + /// Read `app_settings.fediverse_trust_mode` (default
117 + /// [`TrustMode::Allowlist`]).
118 + pub async fn trust_mode(&self) -> Result<TrustMode, AppError> {
119 + let raw = AppSettingsService::get_setting(&self.db, "fediverse_trust_mode").await?;
120 + Ok(raw.map(|m| TrustMode::parse(&m.value)).unwrap_or(TrustMode::Allowlist))
121 + }
122 +
123 + /// Read `app_settings.fediverse_interop_scope` (default
124 + /// [`InteropScope::RustwardenOnly`]).
125 + pub async fn interop_scope(&self) -> Result<InteropScope, AppError> {
126 + let raw = AppSettingsService::get_setting(&self.db, "fediverse_interop_scope").await?;
127 + Ok(raw
128 + .map(|m| InteropScope::parse(&m.value))
129 + .unwrap_or(InteropScope::RustwardenOnly))
130 + }
131 +
132 + /// Central trust check applied **before** any signature verification or
133 + /// fetch towards a remote domain.
134 + ///
135 + /// Returns `false` (deny) when:
136 + /// - `app_settings.fediverse_enabled = false`
137 + /// - mode = [`TrustMode::Allowlist`] and `domain` is **not** in
138 + /// `fediverse_trusted_domains`
139 + /// - mode = [`TrustMode::OpenWithBlocklist`] and `domain` is in
140 + /// `fediverse_blocked_domains`
141 + ///
142 + /// `domain` is normalised via [`normalize_domain`] before lookup.
143 + pub async fn is_domain_allowed(&self, domain: &str) -> Result<bool, AppError> {
144 + if !self.is_enabled().await? {
145 + return Ok(false);
146 + }
147 + let normalized = normalize_domain(domain);
148 + match self.trust_mode().await? {
149 + TrustMode::Allowlist => self.is_in_trusted(&normalized).await,
150 + TrustMode::OpenWithBlocklist => Ok(!self.is_in_blocked(&normalized).await?),
151 + }
152 + }
153 +
154 + async fn is_in_trusted(&self, domain: &str) -> Result<bool, AppError> {
155 + Ok(fediverse_trusted_domain::Entity::find()
156 + .filter(fediverse_trusted_domain::Column::Domain.eq(domain))
157 + .one(&self.db)
158 + .await?
159 + .is_some())
160 + }
161 +
162 + async fn is_in_blocked(&self, domain: &str) -> Result<bool, AppError> {
163 + Ok(fediverse_blocked_domain::Entity::find()
164 + .filter(fediverse_blocked_domain::Column::Domain.eq(domain))
165 + .one(&self.db)
166 + .await?
167 + .is_some())
168 + }
169 +
170 + /// Insert a new entry in the trusted-domains allowlist.
171 + pub async fn add_trusted_domain(
172 + &self,
173 + domain: &str,
174 + note: Option<&str>,
175 + added_by: Option<Uuid>,
176 + ) -> Result<fediverse_trusted_domain::Model, AppError> {
177 + let now = Utc::now();
178 + let model = fediverse_trusted_domain::ActiveModel {
179 + id: Set(Uuid::new_v4()),
180 + domain: Set(normalize_domain(domain)),
181 + note: Set(note.map(str::to_string)),
182 + added_by_user_id: Set(added_by),
183 + created_at: Set(now),
184 + };
185 + Ok(model.insert(&self.db).await?)
186 + }
187 +
188 + /// Remove an entry from the trusted-domains allowlist.
189 + /// Returns `true` if a row was deleted, `false` if no match was found.
190 + pub async fn remove_trusted_domain(&self, domain: &str) -> Result<bool, AppError> {
191 + let normalized = normalize_domain(domain);
192 + let result = fediverse_trusted_domain::Entity::delete_many()
193 + .filter(fediverse_trusted_domain::Column::Domain.eq(normalized))
194 + .exec(&self.db)
195 + .await?;
196 + Ok(result.rows_affected > 0)
197 + }
198 +
199 + /// List all entries in the trusted-domains allowlist.
200 + pub async fn list_trusted_domains(
201 + &self,
202 + ) -> Result<Vec<fediverse_trusted_domain::Model>, AppError> {
203 + Ok(fediverse_trusted_domain::Entity::find()
204 + .all(&self.db)
205 + .await?)
206 + }
207 +
208 + /// Insert a new entry in the blocked-domains blocklist.
209 + pub async fn add_blocked_domain(
210 + &self,
211 + domain: &str,
212 + severity: BlockSeverity,
213 + reason: Option<&str>,
214 + added_by: Option<Uuid>,
215 + ) -> Result<fediverse_blocked_domain::Model, AppError> {
216 + let now = Utc::now();
217 + let severity_str = match severity {
218 + BlockSeverity::Suspend => "suspend",
219 + BlockSeverity::Silence => "silence",
220 + BlockSeverity::RejectAuth => "reject_auth",
221 + };
222 + let model = fediverse_blocked_domain::ActiveModel {
223 + id: Set(Uuid::new_v4()),
224 + domain: Set(normalize_domain(domain)),
225 + severity: Set(severity_str.to_string()),
226 + reason: Set(reason.map(str::to_string)),
227 + added_by_user_id: Set(added_by),
228 + created_at: Set(now),
229 + };
230 + Ok(model.insert(&self.db).await?)
231 + }
232 +
233 + /// Remove an entry from the blocked-domains blocklist.
234 + /// Returns `true` if a row was deleted.
235 + pub async fn remove_blocked_domain(&self, domain: &str) -> Result<bool, AppError> {
236 + let normalized = normalize_domain(domain);
237 + let result = fediverse_blocked_domain::Entity::delete_many()
238 + .filter(fediverse_blocked_domain::Column::Domain.eq(normalized))
239 + .exec(&self.db)
240 + .await?;
241 + Ok(result.rows_affected > 0)
242 + }
243 +
244 + /// List all entries in the blocked-domains blocklist.
245 + pub async fn list_blocked_domains(
246 + &self,
247 + ) -> Result<Vec<fediverse_blocked_domain::Model>, AppError> {
248 + Ok(fediverse_blocked_domain::Entity::find()
249 + .all(&self.db)
250 + .await?)
251 + }
252 +}
253 +
254 +#[cfg(test)]
255 +mod tests {
256 + use super::*;
257 + use crate::models::app_settings;
258 + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};
259 +
260 + fn enabled_setting(value: &str) -> app_settings::Model {
261 + let now = Utc::now();
262 + app_settings::Model {
263 + id: Uuid::new_v4(),
264 + key: "fediverse_enabled".to_string(),
265 + value: value.to_string(),
266 + description: None,
267 + updated_by: None,
268 + updated_at: now,
269 + }
270 + }
271 +
272 + fn mode_setting(value: &str) -> app_settings::Model {
273 + let now = Utc::now();
274 + app_settings::Model {
275 + id: Uuid::new_v4(),
276 + key: "fediverse_trust_mode".to_string(),
277 + value: value.to_string(),
278 + description: None,
279 + updated_by: None,
280 + updated_at: now,
281 + }
282 + }
283 +
284 + fn trusted_row(domain: &str) -> fediverse_trusted_domain::Model {
285 + let now = Utc::now();
286 + fediverse_trusted_domain::Model {
287 + id: Uuid::new_v4(),
288 + domain: domain.to_string(),
289 + note: None,
290 + added_by_user_id: None,
291 + created_at: now,
292 + }
293 + }
294 +
295 + fn blocked_row(domain: &str) -> fediverse_blocked_domain::Model {
296 + let now = Utc::now();
297 + fediverse_blocked_domain::Model {
298 + id: Uuid::new_v4(),
299 + domain: domain.to_string(),
300 + severity: "suspend".to_string(),
301 + reason: None,
302 + added_by_user_id: None,
303 + created_at: now,
304 + }
305 + }
306 +
307 + // ─── Pure helpers ────────────────────────────────────────────────
308 +
309 + #[test]
310 + fn trust_mode_parses_known_values() {
311 + assert_eq!(TrustMode::parse("allowlist"), TrustMode::Allowlist);
312 + assert_eq!(
313 + TrustMode::parse("open_with_blocklist"),
314 + TrustMode::OpenWithBlocklist
315 + );
316 + assert_eq!(TrustMode::parse("open"), TrustMode::OpenWithBlocklist);
317 + }
318 +
319 + #[test]
320 + fn trust_mode_falls_back_to_allowlist_for_unknown() {
321 + assert_eq!(TrustMode::parse("garbage"), TrustMode::Allowlist);
322 + assert_eq!(TrustMode::parse(""), TrustMode::Allowlist);
323 + }
324 +
325 + #[test]
326 + fn interop_scope_parses_known_values() {
327 + assert_eq!(
328 + InteropScope::parse("rustwarden_only"),
329 + InteropScope::RustwardenOnly
330 + );
331 + assert_eq!(
332 + InteropScope::parse("rustwarden_plus_universal_read"),
333 + InteropScope::RustwardenPlusUniversalRead
334 + );
335 + assert_eq!(InteropScope::parse("open"), InteropScope::Open);
336 + }
337 +
338 + #[test]
339 + fn block_severity_parses_known_values() {
340 + assert_eq!(BlockSeverity::parse("suspend"), BlockSeverity::Suspend);
341 + assert_eq!(BlockSeverity::parse("silence"), BlockSeverity::Silence);
342 + assert_eq!(BlockSeverity::parse("reject_auth"), BlockSeverity::RejectAuth);
343 + }
344 +
345 + #[test]
346 + fn block_severity_falls_back_to_suspend_for_unknown() {
347 + assert_eq!(BlockSeverity::parse("nope"), BlockSeverity::Suspend);
348 + }
349 +
350 + #[test]
351 + fn normalize_domain_lowercases_and_strips_www() {
352 + assert_eq!(normalize_domain("Example.ORG"), "example.org");
353 + assert_eq!(normalize_domain("www.example.org"), "example.org");
354 + assert_eq!(normalize_domain(" www.Mastodon.SOCIAL "), "mastodon.social");
355 + assert_eq!(normalize_domain("example.org"), "example.org");
356 + }
357 +
358 + // ─── DB-mocked tests ────────────────────────────────────────────
359 +
360 + #[tokio::test]
361 + async fn is_domain_allowed_returns_false_when_disabled() {
362 + let db = MockDatabase::new(DatabaseBackend::Postgres)
363 + .append_query_results([vec![enabled_setting("false")]])
364 + .into_connection();
365 + let svc = FediverseTrustService::new(db);
366 + assert!(!svc.is_domain_allowed("trusted.tld").await.unwrap());
367 + }
368 +
369 + #[tokio::test]
370 + async fn is_domain_allowed_returns_true_when_in_allowlist() {
371 + let db = MockDatabase::new(DatabaseBackend::Postgres)
372 + .append_query_results([vec![enabled_setting("true")]])
373 + .append_query_results([vec![mode_setting("allowlist")]])
374 + .append_query_results([vec![trusted_row("partner.tld")]])
375 + .into_connection();
376 + let svc = FediverseTrustService::new(db);
377 + assert!(svc.is_domain_allowed("Partner.TLD").await.unwrap());
378 + }
379 +
380 + #[tokio::test]
381 + async fn is_domain_allowed_returns_false_when_not_in_allowlist() {
382 + let db = MockDatabase::new(DatabaseBackend::Postgres)
383 + .append_query_results([vec![enabled_setting("true")]])
384 + .append_query_results([vec![mode_setting("allowlist")]])
385 + .append_query_results([Vec::<fediverse_trusted_domain::Model>::new()])
386 + .into_connection();
387 + let svc = FediverseTrustService::new(db);
388 + assert!(!svc.is_domain_allowed("stranger.tld").await.unwrap());
389 + }
390 +
391 + #[tokio::test]
392 + async fn is_domain_allowed_returns_true_in_open_mode_when_not_blocked() {
393 + let db = MockDatabase::new(DatabaseBackend::Postgres)
394 + .append_query_results([vec![enabled_setting("true")]])
395 + .append_query_results([vec![mode_setting("open_with_blocklist")]])
396 + .append_query_results([Vec::<fediverse_blocked_domain::Model>::new()])
397 + .into_connection();
398 + let svc = FediverseTrustService::new(db);
399 + assert!(svc.is_domain_allowed("anybody.tld").await.unwrap());
400 + }
401 +
402 + #[tokio::test]
403 + async fn is_domain_allowed_returns_false_in_open_mode_when_blocked() {
404 + let db = MockDatabase::new(DatabaseBackend::Postgres)
405 + .append_query_results([vec![enabled_setting("true")]])
406 + .append_query_results([vec![mode_setting("open_with_blocklist")]])
407 + .append_query_results([vec![blocked_row("evil.tld")]])
408 + .into_connection();
409 + let svc = FediverseTrustService::new(db);
410 + assert!(!svc.is_domain_allowed("evil.tld").await.unwrap());
411 + }
412 +
413 + #[tokio::test]
414 + async fn add_trusted_domain_inserts_record() {
415 + let row = trusted_row("partner.tld");
416 + let db = MockDatabase::new(DatabaseBackend::Postgres)
417 + .append_query_results([vec![row.clone()]])
418 + .append_exec_results([MockExecResult {
419 + last_insert_id: 0,
420 + rows_affected: 1,
421 + }])
422 + .into_connection();
423 + let svc = FediverseTrustService::new(db);
424 + let inserted = svc
425 + .add_trusted_domain("Partner.TLD", Some("partnership"), None)
426 + .await
427 + .unwrap();
428 + assert_eq!(inserted.domain, "partner.tld");
429 + }
430 +}
M crates/rustwarden-core/src/services/mod.rs
+27 -0
@@ -41,6 +41,33 @@ pub mod personal_token_service;
41 41 pub mod rate_limit_service;
42 42 /// Refresh token rotation and revocation service.
43 43 pub mod refresh_token_service;
44 +/// Remote ActivityPub identity store + resolver (RFC 0003,
45 +/// gated by `experimental-fediverse`).
46 +#[cfg(feature = "experimental-fediverse")]
47 +pub mod remote_identity_service;
48 +/// Federation trust model (RFC 0004, gated by `experimental-fediverse`).
49 +#[cfg(feature = "experimental-fediverse")]
50 +pub mod fediverse_trust_service;
51 +/// Local actor RSA key generation and encrypted storage (RFC 0005,
52 +/// gated by `experimental-fediverse`).
53 +#[cfg(feature = "experimental-fediverse")]
54 +pub mod actor_key_service;
55 +/// Outbound ActivityPub delivery queue + worker (RFC 0011,
56 +/// gated by `experimental-fediverse`).
57 +#[cfg(feature = "experimental-fediverse")]
58 +pub mod fediverse_outbound_service;
59 +/// AP-native authentication flow service (RFC 0006,
60 +/// gated by `experimental-fediverse`).
61 +#[cfg(feature = "experimental-fediverse")]
62 +pub mod fediverse_auth_service;
63 +/// Cross-instance team-membership invitations (RFC 0009,
64 +/// gated by `experimental-fediverse`).
65 +#[cfg(feature = "experimental-fediverse")]
66 +pub mod fediverse_invite_service;
67 +/// Fediverse lifecycle signal handlers (RFC 0010,
68 +/// gated by `experimental-fediverse`).
69 +#[cfg(feature = "experimental-fediverse")]
70 +pub mod fediverse_signal_service;
44 71 /// Generic resource registration and sharing service.
45 72 pub mod resource_service;
46 73 /// Role assignment and lookup service.
A crates/rustwarden-core/src/services/remote_identity_service.rs
+435 -0
@@ -0,0 +1,435 @@
1 +//! Remote ActivityPub identity service (RFC 0003).
2 +//!
3 +//! Caches remote actors in the `remote_identities` table and exposes
4 +//! `resolve_handle` which performs WebFinger + actor fetch when the cache
5 +//! is stale or empty.
6 +//!
7 +//! Gated by `experimental-fediverse`.
8 +
9 +use std::sync::Arc;
10 +use std::time::Duration;
11 +
12 +use chrono::{DateTime, Utc};
13 +use sea_orm::DatabaseConnection;
14 +use uuid::Uuid;
15 +
16 +use crate::config::FediverseConfig;
17 +use crate::error::AppError;
18 +use crate::fediverse::error::FediverseError;
19 +use crate::fediverse::fetcher::RemoteFetcher;
20 +use crate::fediverse::types::PersonJson;
21 +use crate::models::remote_identity;
22 +
23 +/// Service orchestrating remote actor resolution and caching.
24 +///
25 +/// Constructed by consumers (RFCs 0008+ for invitations / Identity
26 +/// abstraction will inject one). Cheap to clone — the DB handle is
27 +/// `Arc`-backed and the fetcher is behind `Arc<dyn RemoteFetcher>`.
28 +#[derive(Clone)]
29 +pub struct RemoteIdentityService {
30 + db: DatabaseConnection,
31 + fetcher: Arc<dyn RemoteFetcher>,
32 + cache_ttl: Duration,
33 +}
34 +
35 +impl RemoteIdentityService {
36 + /// Build a new service with the given storage backend, fetcher, and
37 + /// federation config.
38 + pub fn new(
39 + db: DatabaseConnection,
40 + fetcher: Arc<dyn RemoteFetcher>,
41 + config: &FediverseConfig,
42 + ) -> Self {
43 + Self {
44 + db,
45 + fetcher,
46 + cache_ttl: config.fetch_actor_cache_ttl(),
47 + }
48 + }
49 +
50 + /// Return `true` when the cache row is older than the configured TTL
51 + /// and should be refreshed.
52 + pub fn is_stale(&self, last_fetched_at: DateTime<Utc>) -> bool {
53 + let elapsed = Utc::now().signed_duration_since(last_fetched_at);
54 + match elapsed.to_std() {
55 + Ok(d) => d >= self.cache_ttl,
56 + // Negative durations: clock skew, treat as fresh.
57 + Err(_) => false,
58 + }
59 + }
60 +
61 + /// Resolve a `@user@domain` handle. Returns the cached row if fresh;
62 + /// otherwise calls the fetcher (WebFinger + actor JSON) and upserts.
63 + pub async fn resolve_handle(
64 + &self,
65 + handle: &str,
66 + ) -> Result<remote_identity::Model, FediverseError> {
67 + let (username, domain) = parse_handle(handle)?;
68 +
69 + // Fast path: cache hit and fresh.
70 + if let Some(cached) = self.find_by_handle(&username, &domain).await? {
71 + if !self.is_stale(cached.last_fetched_at) {
72 + return Ok(cached);
73 + }
74 + }
75 +
76 + // Slow path: fetch via WebFinger then by actor URL, then upsert.
77 + let webfinger = self.fetcher.webfinger(handle).await?;
78 + let actor_url = webfinger
79 + .links
80 + .iter()
81 + .find(|l| {
82 + l.rel == "self"
83 + && l.media_type
84 + .as_deref()
85 + .is_some_and(|t| t.contains("activity+json") || t.contains("ld+json"))
86 + })
87 + .and_then(|l| l.href.as_ref())
88 + .ok_or_else(|| {
89 + FediverseError::InvalidResource(format!(
90 + "WebFinger response for {handle} has no self/AP link"
91 + ))
92 + })?;
93 +
94 + let actor_url = url::Url::parse(actor_url).map_err(|e| {
95 + FediverseError::InvalidResource(format!("invalid self link in WebFinger: {e}"))
96 + })?;
97 + let person = self.fetcher.fetch_actor(&actor_url).await?;
98 + self.upsert_from_person(person, &domain).await
99 + }
100 +
101 + /// Direct DB lookup by AP id. Returns `None` if no row matches.
102 + pub async fn find_by_uri(
103 + &self,
104 + ap_id: &str,
105 + ) -> Result<Option<remote_identity::Model>, AppError> {
106 + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
107 + Ok(remote_identity::Entity::find()
108 + .filter(remote_identity::Column::ApId.eq(ap_id))
109 + .one(&self.db)
110 + .await?)
111 + }
112 +
113 + /// Direct DB lookup by handle. Returns `None` if no row matches.
114 + pub async fn find_by_handle(
115 + &self,
116 + username: &str,
117 + domain: &str,
118 + ) -> Result<Option<remote_identity::Model>, AppError> {
119 + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
120 + Ok(remote_identity::Entity::find()
121 + .filter(remote_identity::Column::Username.eq(username))
122 + .filter(remote_identity::Column::Domain.eq(domain))
123 + .one(&self.db)
124 + .await?)
125 + }
126 +
127 + /// Insert or update a remote identity from a freshly fetched PersonJson.
128 + ///
129 + /// `expected_domain` MUST match the host of `person.id`. Mismatches yield
130 + /// [`FediverseError::DomainMismatch`] without touching the database, to
131 + /// prevent trivial spoofing.
132 + pub async fn upsert_from_person(
133 + &self,
134 + person: PersonJson,
135 + expected_domain: &str,
136 + ) -> Result<remote_identity::Model, FediverseError> {
137 + use sea_orm::{ActiveModelTrait, Set};
138 +
139 + let actor_host = person
140 + .id
141 + .host_str()
142 + .ok_or_else(|| FediverseError::InvalidResource("actor id has no host".into()))?;
143 + if !actor_host.eq_ignore_ascii_case(expected_domain) {
144 + return Err(FediverseError::DomainMismatch);
145 + }
146 +
147 + let raw_json = serde_json::to_value(&person).ok();
148 + let now = Utc::now();
149 + let public_key_pem = person
150 + .public_key
151 + .as_ref()
152 + .map(|k| k.public_key_pem.clone())
153 + .unwrap_or_default();
154 +
155 + if let Some(existing) = self
156 + .find_by_uri(person.id.as_str())
157 + .await
158 + .map_err(FediverseError::App)?
159 + {
160 + let mut active: remote_identity::ActiveModel = existing.into();
161 + active.username = Set(person.preferred_username.clone());
162 + active.domain = Set(actor_host.to_string());
163 + active.display_name = Set(person.name.clone());
164 + active.summary_html = Set(person.summary.clone());
165 + active.inbox_url = Set(person.inbox.to_string());
166 + active.shared_inbox_url = Set(person
167 + .endpoints
168 + .as_ref()
169 + .map(|e| e.shared_inbox.to_string()));
170 + active.public_key_pem = Set(public_key_pem);
171 + active.actor_type = Set(person.kind.clone());
172 + active.raw_json = Set(raw_json);
173 + active.last_fetched_at = Set(now);
174 + active.updated_at = Set(now);
175 + Ok(active.update(&self.db).await.map_err(AppError::from)?)
176 + } else {
177 + let active = remote_identity::ActiveModel {
178 + id: Set(Uuid::new_v4()),
179 + ap_id: Set(person.id.to_string()),
180 + username: Set(person.preferred_username.clone()),
181 + domain: Set(actor_host.to_string()),
182 + display_name: Set(person.name.clone()),
183 + summary_html: Set(person.summary.clone()),
184 + inbox_url: Set(person.inbox.to_string()),
185 + shared_inbox_url: Set(person
186 + .endpoints
187 + .as_ref()
188 + .map(|e| e.shared_inbox.to_string())),
189 + public_key_pem: Set(public_key_pem),
190 + icon_url: Set(None),
191 + actor_type: Set(person.kind.clone()),
192 + raw_json: Set(raw_json),
193 + last_fetched_at: Set(now),
194 + disabled_at: Set(None),
195 + created_at: Set(now),
196 + updated_at: Set(now),
197 + };
198 + Ok(active.insert(&self.db).await.map_err(AppError::from)?)
199 + }
200 + }
201 +
202 + /// Mark an identity as disabled (used by `Delete{Actor}` in RFC 0010).
203 + pub async fn mark_disabled(&self, id: Uuid) -> Result<(), AppError> {
204 + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set, sea_query::Expr};
205 +
206 + let now = Utc::now();
207 + remote_identity::Entity::update_many()
208 + .col_expr(
209 + remote_identity::Column::DisabledAt,
210 + Expr::value(now),
211 + )
212 + .col_expr(
213 + remote_identity::Column::UpdatedAt,
214 + Expr::value(now),
215 + )
216 + .filter(remote_identity::Column::Id.eq(id))
217 + .exec(&self.db)
218 + .await?;
219 + // Touch unused-import lint
220 + let _ = Set::<Option<DateTime<Utc>>>;
221 + Ok(())
222 + }
223 +}
224 +
225 +/// Parse a `@user@domain` handle into `(username, domain)`.
226 +///
227 +/// Accepts both `@user@domain` (Mastodon convention) and bare `user@domain`.
228 +/// Returns [`FediverseError::InvalidResource`] if the format is malformed.
229 +pub(crate) fn parse_handle(handle: &str) -> Result<(String, String), FediverseError> {
230 + let trimmed = handle.trim();
231 + let stripped = trimmed.strip_prefix('@').unwrap_or(trimmed);
232 + let (user, domain) = stripped.split_once('@').ok_or_else(|| {
233 + FediverseError::InvalidResource(format!("missing @ separator in handle {handle}"))
234 + })?;
235 + if user.is_empty() || domain.is_empty() {
236 + return Err(FediverseError::InvalidResource(format!(
237 + "empty user or domain in handle {handle}"
238 + )));
239 + }
240 + Ok((user.to_string(), domain.to_string()))
241 +}
242 +
243 +#[cfg(test)]
244 +mod tests {
245 + use super::*;
246 + use crate::fediverse::fetcher::UnconfiguredFetcher;
247 + use chrono::Duration as ChronoDuration;
248 + use sea_orm::{DatabaseBackend, MockDatabase, MockExecResult};
249 +
250 + fn config_for_tests() -> FediverseConfig {
251 + FediverseConfig::for_tests("example.org")
252 + }
253 +
254 + fn make_service(db: DatabaseConnection) -> RemoteIdentityService {
255 + RemoteIdentityService::new(db, Arc::new(UnconfiguredFetcher), &config_for_tests())
256 + }
257 +
258 + fn sample_remote(domain: &str, username: &str) -> remote_identity::Model {
259 + let now = Utc::now();
260 + let id = Uuid::new_v4();
261 + remote_identity::Model {
262 + id,
263 + ap_id: format!("https://{domain}/users/{username}"),
264 + username: username.to_string(),
265 + domain: domain.to_string(),
266 + display_name: Some(username.to_string()),
267 + summary_html: None,
268 + inbox_url: format!("https://{domain}/users/{username}/inbox"),
269 + shared_inbox_url: Some(format!("https://{domain}/inbox")),
270 + public_key_pem: "-----BEGIN PUBLIC KEY-----\nFAKE\n-----END PUBLIC KEY-----"
271 + .to_string(),
272 + icon_url: None,
273 + actor_type: "Person".to_string(),
274 + raw_json: None,
275 + last_fetched_at: now,
276 + disabled_at: None,
277 + created_at: now,
278 + updated_at: now,
279 + }
280 + }
281 +
282 + // ─── Test 1 — is_stale fresh ─────────────────────────────────────
283 +
284 + #[test]
285 + fn is_stale_returns_false_within_ttl() {
286 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
287 + let svc = make_service(db);
288 + let recent = Utc::now() - ChronoDuration::seconds(60);
289 + assert!(!svc.is_stale(recent));
290 + }
291 +
292 + // ─── Test 2 — is_stale stale ─────────────────────────────────────
293 +
294 + #[test]
295 + fn is_stale_returns_true_past_ttl() {
296 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
297 + let svc = make_service(db);
298 + // Default TTL is 86 400 s. Use 2 days to be safely past.
299 + let old = Utc::now() - ChronoDuration::days(2);
300 + assert!(svc.is_stale(old));
301 + }
302 +
303 + // ─── Test 3 — parse_handle ───────────────────────────────────────
304 +
305 + #[test]
306 + fn parse_handle_accepts_mastodon_form() {
307 + let (user, domain) = parse_handle("@alice@example.org").expect("valid");
308 + assert_eq!(user, "alice");
309 + assert_eq!(domain, "example.org");
310 + }
311 +
312 + #[test]
313 + fn parse_handle_accepts_bare_form() {
314 + let (user, domain) = parse_handle("alice@example.org").expect("valid");
315 + assert_eq!(user, "alice");
316 + assert_eq!(domain, "example.org");
317 + }
318 +
319 + #[test]
320 + fn parse_handle_rejects_missing_at() {
321 + let err = parse_handle("alice").expect_err("must fail");
322 + assert!(matches!(err, FediverseError::InvalidResource(_)));
323 + }
324 +
325 + // ─── Test 4 — find_by_uri none ───────────────────────────────────
326 +
327 + #[tokio::test]
328 + async fn find_by_uri_returns_none_when_absent() {
329 + let db = MockDatabase::new(DatabaseBackend::Postgres)
330 + .append_query_results([Vec::<remote_identity::Model>::new()])
331 + .into_connection();
332 + let svc = make_service(db);
333 + let result = svc.find_by_uri("https://other.tld/users/ghost").await.unwrap();
334 + assert!(result.is_none());
335 + }
336 +
337 + // ─── Test 5 — find_by_uri some ───────────────────────────────────
338 +
339 + #[tokio::test]
340 + async fn find_by_uri_returns_record_when_present() {
341 + let row = sample_remote("other.tld", "alice");
342 + let expected_id = row.id;
343 + let db = MockDatabase::new(DatabaseBackend::Postgres)
344 + .append_query_results([vec![row]])
345 + .into_connection();
346 + let svc = make_service(db);
347 + let result = svc
348 + .find_by_uri("https://other.tld/users/alice")
349 + .await
350 + .unwrap()
351 + .expect("must be present");
352 + assert_eq!(result.id, expected_id);
353 + }
354 +
355 + // ─── Test 6 — find_by_handle none ────────────────────────────────
356 +
357 + #[tokio::test]
358 + async fn find_by_handle_returns_none_when_absent() {
359 + let db = MockDatabase::new(DatabaseBackend::Postgres)
360 + .append_query_results([Vec::<remote_identity::Model>::new()])
361 + .into_connection();
362 + let svc = make_service(db);
363 + let result = svc.find_by_handle("ghost", "other.tld").await.unwrap();
364 + assert!(result.is_none());
365 + }
366 +
367 + // ─── Test 7 — upsert rejects domain mismatch ─────────────────────
368 +
369 + #[tokio::test]
370 + async fn upsert_rejects_domain_mismatch() {
371 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
372 + let svc = make_service(db);
373 +
374 + let person = PersonJson {
375 + context: crate::fediverse::types::actor_context(),
376 + kind: "Person".into(),
377 + id: url::Url::parse("https://attacker.tld/users/alice").unwrap(),
378 + preferred_username: "alice".into(),
379 + name: None,
380 + summary: None,
381 + url: None,
382 + inbox: url::Url::parse("https://attacker.tld/users/alice/inbox").unwrap(),
383 + outbox: url::Url::parse("https://attacker.tld/users/alice/outbox").unwrap(),
384 + followers: url::Url::parse("https://attacker.tld/users/alice/followers").unwrap(),
385 + following: url::Url::parse("https://attacker.tld/users/alice/following").unwrap(),
386 + endpoints: None,
387 + public_key: None,
388 + manually_approves_followers: false,
389 + discoverable: true,
390 + };
391 +
392 + let err = svc
393 + .upsert_from_person(person, "expected.tld")
394 + .await
395 + .expect_err("must fail");
396 + assert!(
397 + matches!(err, FediverseError::DomainMismatch),
398 + "got {err:?}"
399 + );
400 + }
401 +
402 + // ─── Test 8 — mark_disabled ──────────────────────────────────────
403 +
404 + #[tokio::test]
405 + async fn mark_disabled_sets_disabled_at() {
406 + let id = Uuid::new_v4();
407 + let db = MockDatabase::new(DatabaseBackend::Postgres)
408 + .append_exec_results([MockExecResult {
409 + last_insert_id: 0,
410 + rows_affected: 1,
411 + }])
412 + .into_connection();
413 + let svc = make_service(db);
414 + svc.mark_disabled(id).await.unwrap();
415 + }
416 +
417 + // ─── Test 9 — resolve_handle returns cached when fresh ───────────
418 +
419 + #[tokio::test]
420 + async fn resolve_handle_returns_cached_when_fresh() {
421 + let row = sample_remote("other.tld", "alice");
422 + let expected_id = row.id;
423 + let db = MockDatabase::new(DatabaseBackend::Postgres)
424 + .append_query_results([vec![row]])
425 + .into_connection();
426 + // UnconfiguredFetcher would return Err if invoked — fresh cache must
427 + // not invoke it.
428 + let svc = RemoteIdentityService::new(db, Arc::new(UnconfiguredFetcher), &config_for_tests());
429 + let resolved = svc
430 + .resolve_handle("@alice@other.tld")
431 + .await
432 + .expect("cached row");
433 + assert_eq!(resolved.id, expected_id);
434 + }
435 +}
M crates/rustwarden-core/src/services/resource_service.rs
+2 -0
@@ -167,6 +167,7 @@ impl ResourceService {
167 167 resource_id: Set(entry.id),
168 168 shared_with_user_id: Set(Some(shared_with_user_id)),
169 169 shared_with_team_id: Set(None),
170 + shared_with_remote_identity_id: Set(None),
170 171 permission_level: Set(permission_level.to_string()),
171 172 shared_by_user_id: Set(shared_by_user_id),
172 173 created_at: Set(Utc::now()),
@@ -230,6 +231,7 @@ impl ResourceService {
230 231 resource_id: Set(entry.id),
231 232 shared_with_user_id: Set(user_filter),
232 233 shared_with_team_id: Set(team_filter),
234 + shared_with_remote_identity_id: Set(None),
233 235 permission_level: Set(permission_level.to_string()),
234 236 shared_by_user_id: Set(shared_by_user_id),
235 237 created_at: Set(Utc::now()),
M crates/rustwarden-core/src/services/team_service.rs
+108 -0
@@ -96,6 +96,7 @@ impl TeamService {
96 96 user_id: Set(owner_id),
97 97 role: Set(TeamRole::Owner.as_str().to_string()),
98 98 joined_at: Set(Utc::now()),
99 + remote_identity_id: Set(None),
99 100 }
100 101 .insert(db)
101 102 .await?;
@@ -153,6 +154,7 @@ impl TeamService {
153 154 user_id: Set(user_id),
154 155 role: Set(role.as_str().to_string()),
155 156 joined_at: Set(Utc::now()),
157 + remote_identity_id: Set(None),
156 158 }
157 159 .insert(db)
158 160 .await?
@@ -167,6 +169,59 @@ impl TeamService {
167 169 Ok(record)
168 170 }
169 171
172 + /// RFC 0009: add a member by [`Identity`]. Local identities take the
173 + /// existing `(team_id, user_id)` happy path; remote identities are
174 + /// inserted with `user_id = nil` (PK constraint) and the
175 + /// `remote_identity_id` sibling column populated. The unique
176 + /// partial index `team_members_remote_unique` (added by migration
177 + /// `m20260510_000015`) prevents duplicate remote rows for the same
178 + /// (team, identity) pair.
179 + #[cfg(feature = "experimental-fediverse")]
180 + pub async fn add_member_identity(
181 + db: &DatabaseConnection,
182 + team_id: Uuid,
183 + identity: crate::identity::Identity,
184 + role: TeamRole,
185 + hooks: Option<&Arc<dyn RustwardenHooks>>,
186 + ) -> Result<team_member::Model, AppError> {
187 + match identity {
188 + crate::identity::Identity::Local(user_id) => {
189 + Self::add_member(db, team_id, user_id, role, hooks).await
190 + }
191 + crate::identity::Identity::Remote(remote_id) => {
192 + let active = team_member::ActiveModel {
193 + team_id: Set(team_id),
194 + user_id: Set(Uuid::nil()),
195 + role: Set(role.as_str().to_string()),
196 + joined_at: Set(Utc::now()),
197 + remote_identity_id: Set(Some(remote_id)),
198 + };
199 + Ok(active.insert(db).await?)
200 + }
201 + }
202 + }
203 +
204 + /// RFC 0009: every team the [`Identity`] belongs to. Local
205 + /// identities re-use [`Self::teams_of_user`]; remote identities
206 + /// are looked up via `team_members.remote_identity_id`.
207 + #[cfg(feature = "experimental-fediverse")]
208 + pub async fn teams_of_identity(
209 + db: &DatabaseConnection,
210 + identity: crate::identity::Identity,
211 + ) -> Result<Vec<team::Model>, AppError> {
212 + use sea_orm::{JoinType, QuerySelect, RelationTrait};
213 + match identity {
214 + crate::identity::Identity::Local(user_id) => {
215 + Self::teams_of_user(db, user_id).await
216 + }
217 + crate::identity::Identity::Remote(remote_id) => Ok(team::Entity::find()
218 + .join_rev(JoinType::InnerJoin, team_member::Relation::Team.def())
219 + .filter(team_member::Column::RemoteIdentityId.eq(Some(remote_id)))
220 + .all(db)
221 + .await?),
222 + }
223 + }
224 +
170 225 /// Remove a member from a team. Refuses to remove the team owner — use
171 226 /// `delete` to drop the whole team instead.
172 227 pub async fn remove_member(
@@ -519,4 +574,57 @@ mod tests {
519 574 assert!(events[2].starts_with("removed:"));
520 575 assert!(events[3].starts_with("deleted:"));
521 576 }
577 +
578 + // ─── RFC 0009 — add_member_identity ─────────────────────────────
579 +
580 + #[cfg(feature = "experimental-fediverse")]
581 + #[tokio::test]
582 + async fn rfc0009_add_member_identity_local_writes_user_id_column() {
583 + use crate::identity::Identity;
584 + let db = test_db().await;
585 + let owner = fresh_user(&db).await;
586 + let team = TeamService::create(&db, owner, fresh_slug(), "rfc9-local", None)
587 + .await
588 + .unwrap();
589 + let bob = fresh_user(&db).await;
590 + let row = TeamService::add_member_identity(
591 + &db,
592 + team.id,
593 + Identity::Local(bob),
594 + TeamRole::Member,
595 + None,
596 + )
597 + .await
598 + .unwrap();
599 + assert_eq!(row.user_id, bob);
600 + assert!(row.remote_identity_id.is_none());
601 + }
602 +
603 + #[cfg(feature = "experimental-fediverse")]
604 + #[tokio::test]
605 + #[ignore = "needs PK relax on team_members(user_id) — deferred to a follow-up RFC"]
606 + async fn rfc0009_add_member_identity_remote_writes_remote_identity_id_column() {
607 + use crate::identity::Identity;
608 + let db = test_db().await;
609 + let owner = fresh_user(&db).await;
610 + let team = TeamService::create(&db, owner, fresh_slug(), "rfc9-remote", None)
611 + .await
612 + .unwrap();
613 + // The `remote_identities` row needs to exist for any future
614 + // cross-table join, but the partial unique index alone is what
615 + // we exercise here. We pick a fresh UUID; real RFC 0010 flow
616 + // creates the row before adding the membership.
617 + let remote_id = Uuid::new_v4();
618 + let row = TeamService::add_member_identity(
619 + &db,
620 + team.id,
621 + Identity::Remote(remote_id),
622 + TeamRole::Member,
623 + None,
624 + )
625 + .await
626 + .unwrap();
627 + assert_eq!(row.remote_identity_id, Some(remote_id));
628 + assert_eq!(row.user_id, Uuid::nil());
629 + }
522 630 }
M crates/rustwarden-core/src/services/user_service.rs
+3 -0
@@ -51,6 +51,9 @@ impl UserService {
51 51 email_verified_at: NotSet,
52 52 created_at: Set(now),
53 53 updated_at: Set(now),
54 + ap_public_key_pem: NotSet,
55 + ap_private_key_pem: NotSet,
56 + ap_discoverable: NotSet,
54 57 };
55 58
56 59 let result = user.insert(db).await?;
M crates/rustwarden-ui/Cargo.toml
+6 -0
@@ -36,5 +36,11 @@ num-format = "0.4.4"
36 36 # Time
37 37 time = { version = "0.3", features = ["macros", "serde"] }
38 38
39 +[features]
40 +default = []
41 +# RFC 0001: pass-through to enable federation primitives in core. UI templates
42 +# and handlers gated by this feature land in subsequent RFCs (0002+).
43 +experimental-fediverse = ["rustwarden-core/experimental-fediverse"]
44 +
39 45 [lints]
40 46 workspace = true
A docs/plans/2026-05-09-fediverse-identity-federation.md
+894 -0
@@ -0,0 +1,894 @@
1 +# Plan — Fédération d'identité Rustwarden via ActivityPub
2 +
3 +> Date : 2026-05-09 (créé) · MAJ : 2026-05-09 (Phase 0 terminée)
4 +> Auteur : EBO + Claude
5 +> Sources : `Recherche_fediverse/{README.md, docs/guide-developpeur.md, docs/guide-rust.md}`
6 +> Le plan préliminaire `docs/plans/2026-05-08-fediverse-integration.md` est **abandonné** (cadrage produit fédération sociale obsolète) ; sa partie technique est intégrée ici.
7 +
8 +## État d'avancement
9 +
10 +| Phase | Statut | Date |
11 +|---|---|---|
12 +| **0 — Pré-requis** (edition 2024) | ✅ **Terminé** | 2026-05-09 |
13 +| 1 — Découverte (WebFinger + Acteur + NodeInfo + UI admin trust) | ⏳ À faire | — |
14 +| 2 — Auth AP-native | ⏳ | — |
15 +| 3 — Identity abstraction & invitations | ⏳ | — |
16 +| 4 — Signaux & lifecycle | ⏳ | — |
17 +| 5 — Production | ⏳ | — |
18 +
19 +**Phase 0 — résultats** :
20 +- `cargo build --workspace` ✅ (19,5 s)
21 +- `cargo test --workspace -- --test-threads=1` ✅ **164 tests OK**
22 +- `cargo clippy --workspace --all-targets -- -D warnings` ✅ **0 warning**
23 +
24 +---
25 +
26 +## 1. Context
27 +
28 +Rustwarden est un **framework backend de gestion utilisateurs** (auth, RBAC, équipes, ressources, panneau admin). L'objectif réel de la fédération **n'est pas** de publier des contenus sociaux, mais de :
29 +
30 +1. **Permettre à un utilisateur d'une instance Rustwarden A de s'authentifier sur une instance B** sans recréer de compte. L'identité fédérée (`@user@domain-a.tld`) sert de **SSO entre les apps qui héritent du framework**.
31 +2. **Permettre l'invitation cross-instance dans les équipes et le partage de ressources** : un admin de B peut ajouter `@user@domain-a.tld` à une équipe sans que cet utilisateur ait jamais ouvert de compte sur B.
32 +
33 +ActivityPub est ici un **protocole d'identité fédérée** (acteur Person + WebFinger + signatures HTTP + activités de signalisation). Pas de Notes, pas de timeline, pas de feed.
34 +
35 +## 2. Décisions structurantes
36 +
37 +### 2.1 Décisions validées par l'utilisateur
38 +
39 +| Décision | Choix |
40 +|---|---|
41 +| Mécanisme d'auth cross-instance | **AP-native, aligné FEP "proxy authentication"** (slug exact à confirmer dans le repo FEP — placeholder `FEP-fffd`). Pas d'OIDC. |
42 +| Périmètre interop | **Configurable runtime par admin** : `rustwarden_only` / `rustwarden_plus_universal_read` / `open` |
43 +| Modèle de confiance | **Configurable runtime par admin** : `allowlist` ou `open_with_blocklist` |
44 +| Représentation utilisateur distant | **Table dédiée `remote_identities`** + abstraction Rust `Identity { Local(Uuid) \| Remote(Uuid) }`. Au niveau DB : FK sibling `remote_identity_id NULL` à côté de `user_id` sur les tables affectées. |
45 +| UX login | **Champ "Connect with Fediverse identity"** sur la page login, saisie manuelle `@user@domain` |
46 +| Portée droits identités distantes | **Team-scoped + `resource_share` uniquement.** Jamais de `user_role` global, jamais d'admin distant. |
47 +| **Edition Rust** | **2024** sur tous les crates Rustwarden (workspace) |
48 +| **Lib AP** | **`activitypub_federation = "0.7.0-beta.11"`** (epinglé strict, beta = lock major+minor+pre) |
49 +
50 +### 2.2 Décisions techniques implicites
51 +
52 +| Décision | Choix | Justification |
53 +|---|---|---|
54 +| Activation compile-time | Feature Cargo `fediverse` (off par défaut) sur `rustwarden-core` et `rustwarden-ui` | Cohérent avec features existantes (`oauth`, `redis-session`). Aucun coût pour les apps sans la feature. |
55 +| Activation runtime | Flag `app_settings.fediverse_enabled` BOOL | Permet à l'admin de couper sans redéployer. |
56 +| ORM | SeaORM (constitution Rustwarden — pas de raw SQL) | Tous les modèles AP en SeaORM, pas en `sqlx::query!`. |
57 +| Worker outbound | Module dans `rustwarden-core/src/tasks/`, table `fediverse_outbound_queue` SeaORM | Réutilise l'infra `tasks::*` existante (cf. `personal_token_cleanup`). Pas d'`apalis`. |
58 +| Crypto clés | `activitypub_federation::http_signatures::generate_actor_keypair` | Évite d'ajouter `rsa`/`pkcs8` directement. |
59 +| Stockage clé privée | AES-256-GCM via `CryptoService` existant | Homogène avec stockage TOTP/OAuth secrets. |
60 +| Sanitisation HTML | `ammonia 4.1` whitelist stricte | Constitution sécurité ANSSI. |
61 +| TLS reqwest | `rustls-tls` (imposé par activitypub_federation 0.7) | `reqwest 0.13` exige migration ; aussi cohérent avec direction écosystème. |
62 +| Domaine fédéré | Variable env `FEDIVERSE_DOMAIN`. HTTPS obligatoire en prod, validé au démarrage. | Spec impose des IDs HTTPS. |
63 +
64 +---
65 +
66 +## 3. Comparaison des dépendances (versions vérifiées 2026-05-09)
67 +
68 +### 3.1 Versions cibles vs Rustwarden actuel
69 +
70 +| Crate | Latest dispo | Rustwarden actuel | `activitypub_federation 0.7.0-beta.11` exige | Cible plan | Statut |
71 +|---|---|---|---|---|---|
72 +| `activitypub_federation` | `0.7.0-beta.11` | — | self | **`= "0.7.0-beta.11"`** | À AJOUTER (pin strict) |
73 +| `axum` | `0.8.9` | `0.8` | `0.8.8` (compat) | **`0.8`** | OK ✅ (bump mineur possible) |
74 +| `tower` | `0.5.x` | `0.5` | `0.5.2` (compat) | **`0.5`** | OK ✅ |
75 +| `tower-http` | `0.6.10` | `0.6` | non-utilisé directement | `0.6` | OK ✅ |
76 +| `tokio` | `1.x` | `1 full` | `1.49.0` (compat) | `1 full` | OK ✅ |
77 +| `reqwest` | `0.13.x` | **`0.12`** sous feature `oauth` | **`0.13.1`** (transitif via crate AP) | **`0.12`** statu quo + 0.13 transitive | **Différé** : `oauth2 5.0.0` exige `reqwest ^0.12`. Deux versions coexistent quand `oauth` ET `fediverse` actifs. Ré-évaluer à `oauth2 6`. |
78 +| `rsa` (transitif) | `0.9.x` | — | `0.9.10` | (transitif) | OK |
79 +| `sha2` | `0.10.x` | `0.10` | `0.10.9` | `0.10` | OK ✅ |
80 +| `base64` | `0.22.x` | `0.22` | `0.22.1` | `0.22` | OK ✅ |
81 +| `rand` | `0.9.x` | **`0.9`** | `0.8.5` (transitif) | `0.9` | OK ✅ (deux versions transitivement) |
82 +| `thiserror` | `2.0.18` | `2.0` | `2.0.17` | `2.0` | OK ✅ |
83 +| `anyhow` | `1.x` | `1.0` | `1.0.100` (dev-dep) | `1.0` | OK ✅ |
84 +| `chrono` | `0.4.x` | `0.4 serde` | `0.4.42 serde` | `0.4 serde` | OK ✅ |
85 +| `serde` / `serde_json` | `1.x` | `1.0` | `1.0.228` / `1.0.149` | `1.0` | OK ✅ |
86 +| `tracing` | `0.1.x` | `0.1` | `0.1.44` | `0.1` | OK ✅ |
87 +| `url` | `2.5.8` | implicite | `2.5.8 serde` | **`2 features=["serde"]`** | À AJOUTER (explicite) |
88 +| `ammonia` | `4.1.2` | — | non-utilisé | **`4`** | À AJOUTER |
89 +| `sea-orm` | `2.0.0-rc.38` | `2.0.0-rc.20` | non-utilisé | `2.0.0-rc.20` (ou bump) | À évaluer (bump indépendant) |
90 +| `sqlx` | — | **interdit constitution** | non-utilisé | — | EXCLU ✅ |
91 +| `apalis` | — | non-utilisé | — | — | EXCLU ✅ |
92 +
93 +### 3.2 Compatibilité conflits — résolus
94 +
95 +| # | Conflit du plan v1 | Résolution dans plan v2 |
96 +|---|---|---|
97 +| C1 | axum 0.8 (Rustwarden) vs 0.7 (guide) | **Résolu** : `activitypub_federation 0.7.0-beta.11` supporte `axum 0.8.8` ✅ |
98 +| C2 | tower 0.5 (Rustwarden) vs 0.4 (guide) | **Résolu** : la crate AP supporte `tower 0.5.2` ✅ |
99 +| C3 | reqwest TLS backend | **Décision Phase 0** : statu quo `reqwest 0.12` (native-tls) sous feature `oauth`. `activitypub_federation 0.7` apporte sa propre `reqwest 0.13` (rustls-tls) transitivement quand `fediverse` est active. Pas de bump direct (`oauth2 5.0.0` ne supporte pas 0.13). `lettre`/`imap` restent inchangés. |
100 +| C4 | rand 0.9 vs 0.8 transitif | Cargo résout deux versions, pas d'impact fonctionnel |
101 +| C5 | tower-sessions | Sans impact (inbox AP n'utilise pas les sessions) |
102 +
103 +### 3.3 Bumps recommandés indépendants du plan
104 +
105 +À considérer en parallèle (pas bloquants) :
106 +- `axum 0.8` → `0.8.9` (correctifs)
107 +- `tower-http 0.6` → `0.6.10`
108 +- `thiserror 2.0` → `2.0.18`
109 +- `sea-orm 2.0.0-rc.20` → `2.0.0-rc.38` (rc series, à valider)
110 +
111 +### 3.4 Ressources des dépendances clés
112 +
113 +| Crate | Crates.io | Doc | Dépôt | Issues / PR | Releases |
114 +|---|---|---|---|---|---|
115 +| `activitypub_federation` | https://crates.io/crates/activitypub_federation | https://docs.rs/activitypub_federation/0.7.0-beta.11 | https://github.com/LemmyNet/activitypub-federation-rust | [issues](https://github.com/LemmyNet/activitypub-federation-rust/issues) / [PRs](https://github.com/LemmyNet/activitypub-federation-rust/pulls) | https://github.com/LemmyNet/activitypub-federation-rust/releases |
116 +| `ammonia` | https://crates.io/crates/ammonia | https://docs.rs/ammonia | https://github.com/rust-ammonia/ammonia | [issues](https://github.com/rust-ammonia/ammonia/issues) | tags |
117 +| `url` | https://crates.io/crates/url | https://docs.rs/url | https://github.com/servo/rust-url | — | — |
118 +| `axum` | https://crates.io/crates/axum | https://docs.rs/axum | https://github.com/tokio-rs/axum | [issues](https://github.com/tokio-rs/axum/issues) | [changelog](https://github.com/tokio-rs/axum/blob/main/axum/CHANGELOG.md) |
119 +| `reqwest` | https://crates.io/crates/reqwest | https://docs.rs/reqwest | https://github.com/seanmonstar/reqwest | [issues TLS](https://github.com/seanmonstar/reqwest/issues?q=is%3Aissue+rustls) | tags |
120 +| `sea-orm` | https://crates.io/crates/sea-orm | https://docs.rs/sea-orm | https://github.com/SeaQL/sea-orm | [issues 2.0](https://github.com/SeaQL/sea-orm/issues?q=is%3Aissue+2.0) | tags |
121 +
122 +**Avis de sécurité à surveiller** :
123 +- https://rustsec.org/packages/activitypub_federation
124 +- https://rustsec.org/packages/rsa (Marvin RSA, RUSTSEC-2023-0071 — déjà tracé Rustwarden)
125 +- https://rustsec.org/packages/ammonia
126 +- https://rustsec.org/packages/reqwest
127 +
128 +**Spécifications et impl. de référence** : W3C ActivityPub https://www.w3.org/TR/activitypub/, RFC 7033 WebFinger, Cavage HTTP-Sigs draft-12, NodeInfo 2.1, FEP repo https://codeberg.org/fediverse/fep, Lemmy `crates/apub/`, Hatsu, Mastodon AP docs.
129 +
130 +---
131 +
132 +## 4. Architecture cible
133 +
134 +```
135 +┌──────────────────────────────────────────────────────────────┐
136 +│ COUCHE 1 — DÉCOUVERTE │
137 +│ /.well-known/webfinger, /users/{u} (négociation Accept), │
138 +│ /.well-known/nodeinfo, /nodeinfo/2.1 │
139 +├──────────────────────────────────────────────────────────────┤
140 +│ COUCHE 2 — AUTH AP-NATIVE (FEP-style proxy-auth) │
141 +│ IdP-side : POST /fediverse/auth/inbox (AuthRequest) │
142 +│ RP-side : POST /login/fediverse → AuthRequest signé │
143 +│ Échange par activités signées + nonce one-shot. │
144 +├──────────────────────────────────────────────────────────────┤
145 +│ COUCHE 3 — INVITATION & SIGNAUX │
146 +│ Activités custom : Invite{TeamMembership}, Accept{Invite}, │
147 +│ Revoke{Membership}, Update{Actor}, Delete{Actor}. │
148 +│ Reçues dans /fediverse/inbox. │
149 +└──────────────────────────────────────────────────────────────┘
150 +```
151 +
152 +Activation : feature `fediverse` (compile) + `app_settings.fediverse_enabled` (runtime) + `fediverse_trust_mode` + `fediverse_interop_scope`.
153 +
154 +### 4.1 Périmètre `rustwarden-core` (backend) vs `rustwarden-ui`
155 +
156 +La fédération est livrée sur **deux niveaux** distincts, chacun isolé derrière sa propre feature Cargo. Cette séparation est critique : `rustwarden-core` doit rester **utilisable headless** (apps qui n'utilisent pas `rustwarden-ui` — SPA, API-only, mobile, etc.) tandis que `rustwarden-ui` apporte les écrans HTML pour les apps SSR.
157 +
158 +#### Ce qui appartient à `rustwarden-core` (backend, replicable, exploitable headless)
159 +
160 +- **Tous les protocoles AP** : WebFinger, NodeInfo, acteur Person, Inbox, signatures HTTP, dispatch d'activités, fan-out, worker outbound.
161 +- **Tous les services métier** : `RemoteIdentityService`, `FediverseAuthService`, `FediverseInviteService`, `FediverseTrustService`, `FediverseOutboundService`.
162 +- **Tous les modèles SeaORM** + migrations.
163 +- **L'abstraction `Identity`** + `IdentityResolver` + extensions de `TeamService`/`ResourceService`.
164 +- **API JSON** pour les flows fédérés (`POST /api/v1/auth/login/fediverse/initiate`, `/callback`, `POST /api/v1/admin/fediverse/trust/...`, etc.) — utilisables par toute SPA/mobile/CLI.
165 +- **Hooks `FediverseHooks`** pour permettre à une app non-UI d'injecter sa propre logique de présentation.
166 +- **Pages HTML minimales auto-portées** : aucune. Le core ne sert **jamais** de HTML pour ces flows. La négociation `Accept` sur `/users/{u}` retourne 406 si pas d'UI montée et que `Accept != application/activity+json`.
167 +
168 +**Contrat** : un projet consommateur de `rustwarden-core` seul doit pouvoir piloter toute la fédération via API JSON (login, invitation, admin trust, lifecycle). Cette partie doit être **complète, documentée, testable indépendamment**.
169 +
170 +#### Ce qui appartient à `rustwarden-ui` (UI HTMX/Askama optionnelle)
171 +
172 +`rustwarden-ui` ajoute les écrans HTML pour les projets qui choisissent l'UI fournie par défaut. **Aucune logique métier** ici — les handlers UI consomment les services de `rustwarden-core`. Concrètement :
173 +
174 +- **Login fédéré** : champ "Connect with Fediverse identity" sur la page de login + page d'attente (polling/SSE) pendant l'aller-retour AP.
175 +- **Consent screen IdP-side** : page que l'utilisateur voit quand son IdP reçoit un `AuthRequest` (avec affichage clair du domaine demandeur, boutons Approve/Reject, CSRF).
176 +- **Acceptation d'invitation IdP-side** : page que l'utilisateur voit quand son IdP reçoit un `Invite{TeamMembership}` (nom équipe, rôle proposé, domaine de l'app hôte, boutons).
177 +- **Profil utilisateur** : section "Mes apps connectées" (liste des hosts où l'utilisateur a une session distante active) + bouton "Révoquer" (déclenche `Revoke`).
178 +- **Profil utilisateur** : balises `<link rel="alternate" type="application/activity+json">` + `<link rel="webfinger">` pour la découverte cross-instance.
179 +- **Admin** : toggle `fediverse_enabled`, sélecteur trust_mode/interop_scope, gestion allowlist/blocklist, liste invitations en cours, dashboard file outbound, vue NodeInfo humanisée, kill switch domaine.
180 +- **i18n** : `locales/{fr,en,de,es}/fediverse.yaml` pour tous les libellés ci-dessus.
181 +
182 +**Contrat** : `rustwarden-ui` avec feature `fediverse` doit fournir un parcours utilisateur **complet et accessible** pour : se connecter via Fediverse, accepter une invitation, gérer ses apps connectées, et — pour un admin — administrer le trust et observer le système.
183 +
184 +#### Règle d'or
185 +
186 +Aucun handler UI ne doit dupliquer de logique métier. Tout passe par les services `rustwarden-core`. Si `rustwarden-ui` a besoin d'une donnée non exposée, on **étend le service core** (avec RFC), on n'ajoute pas de SQL ou de logique parallèle dans la couche UI.
187 +
188 +---
189 +
190 +## 5. Schéma de base de données (migrations SeaORM)
191 +
192 +### `m20260510_000009_remote_identities`
193 +- `id UUID PK`
194 +- `ap_id TEXT UNIQUE NOT NULL` (`https://other.tld/users/alice`)
195 +- `username TEXT`, `domain TEXT`, **UNIQUE(`username`, `domain`)**
196 +- `display_name TEXT NULL`, `summary_html TEXT NULL`
197 +- `inbox_url TEXT NOT NULL`, `shared_inbox_url TEXT NULL`
198 +- `public_key_pem TEXT NOT NULL`
199 +- `icon_url TEXT NULL`, `actor_type TEXT DEFAULT 'Person'`
200 +- `raw_json JSONB`, `last_fetched_at`, `disabled_at TIMESTAMPTZ NULL`
201 +- `created_at`, `updated_at`
202 +
203 +### `m20260510_000010_extend_users_with_actor_keys`
204 +Étend `users` (acteurs AP exposables) :
205 +- `ap_public_key_pem TEXT NULL`
206 +- `ap_private_key_pem TEXT NULL` — chiffré via `CryptoService` (AES-256-GCM)
207 +- `ap_discoverable BOOLEAN DEFAULT true`
208 +
209 +Backfill idempotent au premier démarrage avec feature activée (job différé via `tokio::task::spawn_blocking`).
210 +
211 +### `m20260510_000011_identity_sibling_fks`
212 +Colonnes `remote_identity_id UUID NULL` à côté de `user_id` sur :
213 +- `team_members` : PK devient `(team_id, user_id, remote_identity_id)` avec CHECK XOR + UNIQUE adapté
214 +- `resource_shares.shared_with_remote_identity_id`
215 +- `audit_logs.actor_remote_identity_id` + `target_remote_identity_id`
216 +
217 +Pas de modification de `user_roles` (pas de rôle global pour les distants par décision).
218 +
219 +### `m20260510_000012_fediverse_trust_and_blocks`
220 +- `fediverse_trusted_domains` (mode `allowlist`) : `domain UNIQUE`, `added_by_user_id`, `note`, `created_at`
221 +- `fediverse_blocked_domains` (mode `open_with_blocklist`) : `domain UNIQUE`, `severity` (`suspend|silence|reject_auth`), `reason`, `added_by_user_id`, `created_at`
222 +- Insertions `app_settings` :
223 + - `fediverse_enabled = false`
224 + - `fediverse_trust_mode = 'allowlist'`
225 + - `fediverse_interop_scope = 'rustwarden_only'`
226 + - `fediverse_authorized_fetch = false`
227 + - `fediverse_node_name`, `fediverse_node_description`
228 +
229 +### `m20260510_000013_fediverse_pending_invites`
230 +- `id`, `team_id`, `inviter_user_id`, `invited_handle TEXT` (`@user@domain`)
231 +- `invited_remote_identity_id NULL` (rempli après WebFinger lookup réussi)
232 +- `activity_id TEXT UNIQUE` (URI AP de l'`Invite`)
233 +- `status` (`pending|accepted|rejected|expired|revoked`)
234 +- `expires_at`, `created_at`
235 +
236 +### `m20260510_000014_fediverse_auth_challenges`
237 +- `id`, `nonce TEXT UNIQUE`, `target_remote_identity_id`, `redirect_url TEXT`
238 +- `expires_at` (5 min), `consumed_at NULL`, `created_at`
239 +- `initiator_ip INET`, `initiator_user_agent TEXT` (anti-CSRF inter-onglet)
240 +
241 +### `m20260510_000015_fediverse_outbound_queue`
242 +- `id`, `activity_id TEXT`, `target_inbox`, `target_domain`
243 +- `attempts INT DEFAULT 0`, `max_attempts DEFAULT 8`
244 +- `status` (`pending|delivered|failed|dead`)
245 +- `last_error`, `next_retry_at`, `created_at`, `delivered_at`
246 +- Index partiel `(status, next_retry_at) WHERE status='pending'`
247 +
248 +---
249 +
250 +## 6. Configuration
251 +
252 +`crates/rustwarden-core/src/config/fediverse.rs` :
253 +
254 +```rust
255 +pub struct FediverseConfig {
256 + pub domain: String, // FEDIVERSE_DOMAIN
257 + pub base_url: Url, // https://{domain}
258 + pub user_agent: String, // "Rustwarden/0.x (+https://...)"
259 + pub authorized_fetch: bool,
260 + pub outbound_workers: usize, // def 4
261 + pub outbound_max_attempts: u32, // def 8
262 + pub outbound_request_timeout_secs: u64, // def 30
263 + pub fetch_actor_cache_ttl_secs: u64, // def 86400
264 + pub auth_challenge_ttl_secs: u64, // def 300
265 + pub debug_local_federation: bool, // tests à 2 instances
266 +}
267 +```
268 +
269 +Validation au démarrage : domaine non vide, pas de placeholder, `base_url` HTTPS sauf `debug_local_federation`.
270 +
271 +---
272 +
273 +## 7. Code à créer / modifier
274 +
275 +### 7.1 `rustwarden-core` (backend headless) — création
276 +
277 +| Fichier | Rôle |
278 +|---|---|
279 +| `crates/rustwarden-core/src/fediverse/mod.rs` | Module racine `#[cfg(feature="fediverse")]` |
280 +| `crates/rustwarden-core/src/fediverse/config.rs` | `FediverseConfig::from_env()` + validation |
281 +| `crates/rustwarden-core/src/fediverse/identity.rs` | **Cœur** : `enum Identity { Local(Uuid), Remote(Uuid) }`, trait `IdentityResolver` |
282 +| `crates/rustwarden-core/src/fediverse/state.rs` | `FediverseState` (Data<T> pour la crate AP) |
283 +| `crates/rustwarden-core/src/fediverse/keys.rs` | Génération RSA via crate AP, déchiffrement via `CryptoService` |
284 +| `crates/rustwarden-core/src/fediverse/webfinger.rs` | Handler `/.well-known/webfinger` + résolveur client |
285 +| `crates/rustwarden-core/src/fediverse/nodeinfo.rs` | Handlers NodeInfo discovery + 2.1 |
286 +| `crates/rustwarden-core/src/fediverse/actor.rs` | Handler `GET /users/{u}` AP négociation Accept ; `PersonJson`. **406 si Accept ≠ AP et UI absente** |
287 +| `crates/rustwarden-core/src/fediverse/types/` | `PersonJson`, `PublicKeyJson`, types activités custom |
288 +| `crates/rustwarden-core/src/fediverse/activities/auth_request.rs` | Couche 2 — `AuthRequest`, `Accept{AuthRequest}`, `Reject` |
289 +| `crates/rustwarden-core/src/fediverse/activities/invite.rs` | Couche 3 — `Invite`, `Accept{Invite}`, `Reject{Invite}`, `Revoke{Membership}` |
290 +| `crates/rustwarden-core/src/fediverse/activities/signal.rs` | `Update{Actor}`, `Delete{Actor}` |
291 +| `crates/rustwarden-core/src/fediverse/dispatch.rs` | Enum `RustwardenAcceptedActivities` (untagged), impl `ActivityHandler` |
292 +| `crates/rustwarden-core/src/fediverse/inbox.rs` | Routeur inbox AP (vérif sig, blocage domaine, dispatch) |
293 +| `crates/rustwarden-core/src/fediverse/trust.rs` | Vérif allowlist/blocklist appliquée **avant** sig |
294 +| `crates/rustwarden-core/src/fediverse/error.rs` | `FediverseError` → `AppError` |
295 +| `crates/rustwarden-core/src/fediverse/routes.rs` | `Router<DatabaseConnection>` |
296 +| `crates/rustwarden-core/src/models/remote_identity.rs` | Entité SeaORM |
297 +| `crates/rustwarden-core/src/models/fediverse_trusted_domain.rs` | |
298 +| `crates/rustwarden-core/src/models/fediverse_blocked_domain.rs` | |
299 +| `crates/rustwarden-core/src/models/fediverse_pending_invite.rs` | |
300 +| `crates/rustwarden-core/src/models/fediverse_auth_challenge.rs` | |
301 +| `crates/rustwarden-core/src/models/fediverse_outbound_queue.rs` | |
302 +| `crates/rustwarden-core/src/services/remote_identity_service.rs` | CRUD + WebFinger lookup + cache TTL |
303 +| `crates/rustwarden-core/src/services/fediverse_trust_service.rs` | API admin allowlist/blocklist |
304 +| `crates/rustwarden-core/src/services/fediverse_auth_service.rs` | Orchestre couche 2 |
305 +| `crates/rustwarden-core/src/services/fediverse_invite_service.rs` | Orchestre couche 3 |
306 +| `crates/rustwarden-core/src/services/fediverse_outbound_service.rs` | Fan-out enqueue |
307 +| `crates/rustwarden-core/src/tasks/fediverse_outbound.rs` | Worker délivrance signée + retry/backoff |
308 +| `crates/rustwarden-core/migrations/src/m20260510_*` | 7 nouvelles migrations SeaORM |
309 +
310 +### 7.2 `rustwarden-core` — modifications
311 +
312 +| Fichier | Modification |
313 +|---|---|
314 +| `crates/rustwarden-core/src/lib.rs` | `#[cfg(feature="fediverse")] pub mod fediverse;` ; réexport prelude (`Identity`, `IdentityResolver`) |
315 +| `crates/rustwarden-core/src/app.rs` | Méthode `with_fediverse()` au builder ; `FederationMiddleware` ; lancement worker |
316 +| `crates/rustwarden-core/src/auth/extractors.rs` | Étendre vers `AuthIdentity` (porte `Identity`) ; conserver `AuthUser` pour rétrocompat locaux |
317 +| `crates/rustwarden-core/src/auth/jwt.rs` | Claim `identity_kind` (`local`/`remote`) + `remote_id` optionnel |
318 +| `crates/rustwarden-core/src/auth/handlers.rs` | API JSON : `POST /api/v1/auth/login/fediverse/initiate` + `/callback` (utilisable par SPA/mobile/CLI) |
319 +| `crates/rustwarden-core/src/services/team_service.rs` | `add_member(team, identity: Identity, role)` ; conserver `add_member(user_id)` qui délègue |
320 +| `crates/rustwarden-core/src/services/resource_service.rs` | `Principal::Identity(...)` étendu ; `effective_permission_for_identity(...)` |
321 +| `crates/rustwarden-core/src/services/auth_logging.rs` | Logger `Identity` (populate `*_remote_identity_id`) |
322 +| `crates/rustwarden-core/src/services/auth_service.rs` | Méthode dédiée `establish_session_for_remote(...)` (séparée du login password) |
323 +| `crates/rustwarden-core/src/middleware/auth_required.rs` | Accepter sessions issues d'identités distantes |
324 +| `crates/rustwarden-core/src/hooks.rs` | Sub-trait `FediverseHooks: RustwardenHooks` (`on_remote_identity_created`, `on_remote_team_invite_received`, `on_remote_login_succeeded`, `on_remote_actor_disabled`, `customize_actor_json`) |
325 +| `crates/rustwarden-core/src/config/mod.rs` | Réexport `FediverseConfig` |
326 +
327 +### 7.3 `rustwarden-ui` (HTML/Askama/HTMX/Alpine) — création
328 +
329 +| Fichier | Rôle | Phase |
330 +|---|---|---|
331 +| `crates/rustwarden-ui/src/handlers/fediverse_login.rs` | Page `/login/fediverse` (form handle + page d'attente SSE/polling) | 2 |
332 +| `crates/rustwarden-ui/src/handlers/fediverse_consent.rs` | Page consent IdP-side (Approve/Reject `AuthRequest`) | 2 |
333 +| `crates/rustwarden-ui/src/handlers/fediverse_invite_accept.rs` | Page IdP-side d'acceptation d'invitation team reçue | 3 |
334 +| `crates/rustwarden-ui/src/handlers/fediverse_profile.rs` | Section "Mes apps connectées" sur le profil utilisateur | 4 |
335 +| `crates/rustwarden-ui/src/handlers/admin/fediverse_admin.rs` | UI admin : toggle, trust mode, allowlist/blocklist, invitations en cours, file outbound, NodeInfo lisible, kill switch | 1 / 3 / 5 |
336 +| `crates/rustwarden-ui/src/templates/fediverse/login_form.html` | Champ "Connect with Fediverse identity" (input `@user@domain` + bouton) | 2 |
337 +| `crates/rustwarden-ui/src/templates/fediverse/login_waiting.html` | Page d'attente avec spinner + polling/SSE pour le retour `Accept` | 2 |
338 +| `crates/rustwarden-ui/src/templates/fediverse/consent_screen.html` | Affichage clair du domaine demandeur + scope demandé + CSRF | 2 |
339 +| `crates/rustwarden-ui/src/templates/fediverse/invite_accept.html` | "Vous êtes invité dans l'équipe `X` sur `Y` au rôle `Z`" + boutons Accept/Reject | 3 |
340 +| `crates/rustwarden-ui/src/templates/fediverse/profile_connected_apps.html` | Liste des hosts où l'utilisateur a une session distante + bouton Révoquer | 4 |
341 +| `crates/rustwarden-ui/src/templates/fediverse/admin/settings.html` | Toggle `fediverse_enabled` + sélecteurs `trust_mode` / `interop_scope` | 1 |
342 +| `crates/rustwarden-ui/src/templates/fediverse/admin/trust_list.html` | Allowlist/blocklist domaines : table + form add + form remove (CSRF) | 1 |
343 +| `crates/rustwarden-ui/src/templates/fediverse/admin/invites.html` | Form "Inviter par handle" + table pending/accepted/rejected/revoked + bouton révoquer | 3 |
344 +| `crates/rustwarden-ui/src/templates/fediverse/admin/queue.html` | Dashboard file outbound : statut, retries, dernier échec, instances mortes | 5 |
345 +| `crates/rustwarden-ui/src/templates/fediverse/admin/nodeinfo.html` | Stats NodeInfo (users, posts, version) lisibles humainement | 5 |
346 +| `crates/rustwarden-ui/src/templates/fediverse/admin/sessions_remote.html` | Sessions distantes actives + kill switch par domaine | 4 |
347 +| `crates/rustwarden-ui/src/templates/fediverse/error_unauthorized.html` | Erreur d'auth (rejet, timeout, domaine non trusté) | 2 |
348 +| `locales/{fr,en,de,es}/fediverse.yaml` | i18n (libellés UI) | 1 → 5 |
349 +
350 +### 7.4 `rustwarden-ui` — modifications
351 +
352 +| Fichier | Modification | Phase |
353 +|---|---|---|
354 +| `crates/rustwarden-ui/src/routes.rs` | Câblage conditionnel `cfg(feature="fediverse")` des routes login/consent/invite/admin | 1 → 5 |
355 +| `crates/rustwarden-ui/src/handlers/login_handler.rs` | Insère le partial `login_fediverse_widget` dans la page login si `fediverse_enabled=true` | 2 |
356 +| `crates/rustwarden-ui/src/handlers/user_handler.rs` (ou équivalent profil) | Insère la section "Apps connectées" + balises `<link rel="alternate">` AP / `<link rel="webfinger">` dans `<head>` | 1 / 4 |
357 +| `crates/rustwarden-ui/src/handlers/admin_handler.rs` (menu admin) | Ajoute entrées de menu "Fédération" (settings, trust, invites, queue, sessions remote) | 1 → 5 |
358 +| `crates/rustwarden-ui/src/template_helpers.rs` | Helper Askama pour rendre un handle `@user@domain` ou un `Identity` (Local vs Remote) avec icône | 3 |
359 +
360 +### 7.5 Fichiers transverses — modifications
361 +
362 +| Fichier | Modification |
363 +|---|---|
364 +| `Cargo.toml` (workspace) | `[workspace.package] edition = "2024"` ; `rust-version = "1.85"` ✅ Phase 0 |
365 +| `crates/rustwarden-core/Cargo.toml` | `edition.workspace = true` ✅ Phase 0 ; ajouter deps `activitypub_federation`/`ammonia`/`url` optional ; feature `fediverse` |
366 +| `crates/rustwarden-ui/Cargo.toml` | `edition.workspace = true` ✅ Phase 0 ; feature `fediverse = ["rustwarden-core/fediverse"]` |
367 +| `app/Cargo.toml`, `tools/rustwarden-cli/Cargo.toml`, `template/Cargo.toml` | `edition.workspace = true` (ou `2024` direct pour template) ✅ Phase 0 |
368 +| `tools/rustwarden-cli/src/main.rs` | Wizard : questions activation fédération + génération `.env.example` (`FEDIVERSE_DOMAIN`, etc.) |
369 +
370 +### 7.6 Cargo.toml — bloc `fediverse` à ajouter
371 +
372 +`crates/rustwarden-core/Cargo.toml` :
373 +
374 +```toml
375 +[package]
376 +edition = "2024"
377 +rust-version = "1.85"
378 +
379 +[dependencies]
380 +# Bump reqwest pour cohérence avec activitypub_federation 0.7
381 +reqwest = { version = "0.13", features = ["json", "rustls-tls"], optional = true }
382 +
383 +# Fédération ActivityPub (feature `fediverse`)
384 +activitypub_federation = { version = "=0.7.0-beta.11", features = ["axum"], default-features = false, optional = true }
385 +ammonia = { version = "4", optional = true }
386 +url = { version = "2", features = ["serde"], optional = true }
387 +
388 +[features]
389 +default = []
390 +redis-session = ["tower-sessions-redis-store"]
391 +oauth = ["openidconnect", "reqwest"]
392 +fediverse = ["dep:activitypub_federation", "dep:ammonia", "dep:url", "dep:reqwest"]
393 +```
394 +
395 +`crates/rustwarden-ui/Cargo.toml` :
396 +
397 +```toml
398 +[package]
399 +edition = "2024"
400 +
401 +[features]
402 +default = []
403 +fediverse = ["rustwarden-core/fediverse"]
404 +```
405 +
406 +> **Note edition 2024** : impacte la sémantique de `let-else`, `if let` chains, capture des closures. À auditer rapidement après bump (probable : 0 changement nécessaire dans Rustwarden).
407 +> **Note `default-features = false`** sur `activitypub_federation` : le défaut est `["actix-web", "axum"]`. On veut Axum uniquement.
408 +
409 +---
410 +
411 +## 8. Réutilisation de l'existant
412 +
413 +| Concept | Composant existant | Emplacement |
414 +|---|---|---|
415 +| Chiffrement secrets | `CryptoService` AES-256-GCM (déjà utilisé TOTP/OAuth) | `crates/rustwarden-core/src/services/crypto_service.rs` |
416 +| Rate limiting inbox | `RateLimitService` (300 req / 5 min / IP) | `crates/rustwarden-core/src/services/rate_limit_service.rs` |
417 +| Hooks framework | Pattern `RustwardenHooks` | `crates/rustwarden-core/src/hooks.rs` |
418 +| Sessions | `tower-sessions 0.14` | `crates/rustwarden-core/src/auth/session.rs` |
419 +| JWT | `JwtService` (étendre Claims) | `crates/rustwarden-core/src/auth/jwt.rs` |
420 +| App settings runtime | `AppSettingsService` | `crates/rustwarden-core/src/services/app_settings_service.rs` |
421 +| Background tasks | Pattern `tasks::*` (cf. `personal_token_cleanup`) | `crates/rustwarden-core/src/tasks/` |
422 +| CSRF | Middleware existant | `crates/rustwarden-core/src/middleware/csrf.rs` |
423 +| MockDatabase tests | Pattern inline tests SeaORM (obs S18) | tests inline dans services |
424 +| Audit logging | `AuthLoggingService` à étendre pour `Identity` | `crates/rustwarden-core/src/services/auth_logging.rs` |
425 +| JWT blacklist | `JwtBlacklistService` (utilisé pour `Delete{Actor}`) | `crates/rustwarden-core/src/services/jwt_blacklist_service.rs` |
426 +
427 +**Crates non-réutilisées** :
428 +- Feature `oauth` (openidconnect) **n'est pas** mise à contribution pour la SSO fédérée — choix utilisateur AP-natif. La feature reste pour Google/GitHub login classique.
429 +- `apalis` exclu (worker maison via `tasks::*`).
430 +- `sqlx` exclu (constitution).
431 +
432 +---
433 +
434 +## 9. Sécurité — points spécifiques
435 +
436 +| Risque | Atténuation |
437 +|---|---|
438 +| Spoofing identité distante | Vérification stricte signature HTTP de `Accept{AuthRequest}` avec clé publique fetched **après** vérification trust du domaine. Cache clé 24h max. |
439 +| Replay du `Accept{AuthRequest}` | `fediverse_auth_challenges.consumed_at` (one-shot). Expire 5 min. |
440 +| Phishing du consent screen | Servi seulement si user déjà loggué chez son IdP. CSRF token. Affichage clair du domaine demandeur. |
441 +| Escalation par admin distant compromis | Identités distantes incapables de tenir un rôle global (`user_role`) par construction. |
442 +| Open redirect dans `redirect_url` post-auth | Whitelist : `redirect_url` doit pointer vers le domaine de l'instance hôte. Validation scheme + host stricte. |
443 +| Domaine non trusté | Middleware `trust::check_domain_allowed` **avant** vérification de signature. |
444 +| Compromise IdP → toutes les sessions liées | `Delete{Actor}` reçu → `remote_identities.disabled_at` → invalidation via `JwtBlacklistService`. Endpoint admin pour kill switch domaine. |
445 +| Authorized Fetch en prod | Toggle `app_settings.fediverse_authorized_fetch` recommandé `true`. |
446 +| Pas d'`unwrap`/`expect` (constitution ANSSI) | Toutes les conversions `Url::parse` dans `Result` + `?`. |
447 +| HTTPS obligatoire | `FediverseConfig::validate()` rejette HTTP hors `debug_local_federation`. |
448 +| `missing_docs` warn | rustdoc anglais sur tout `pub` (constitution). |
449 +| Marvin RSA (RUSTSEC-2023-0071) | Hérité via `activitypub_federation 0.7` (rsa 0.9.10). À tracer ; documenter dans `SECURITY_AUDIT_2026_05.md`. |
450 +
451 +---
452 +
453 +## 10. Phasage
454 +
455 +### Phase 0 — Pré-requis ✅ **TERMINÉE 2026-05-09**
456 +
457 +Réalisé :
458 +
459 +- 6 `Cargo.toml` édités : root + 4 crates members (héritage `edition.workspace`/`rust-version.workspace`) + `template/` (autonome `edition = "2024"`).
460 +- 4 `set_var` wrappés dans `unsafe { ... }` avec `// SAFETY:` (1 prod `app.rs:264`, 3 tests dans `crypto_service.rs:104`, `totp_service.rs:479,507`).
461 +- 9 lints clippy fixés (7× `explicit_auto_deref` sur `&*auth_config` dans middlewares ; 2× `let_and_return` dans `error_handler.rs` et `oauth/service.rs`).
462 +- 1 `#[allow(clippy::too_many_arguments)]` local sur `oauth_callback` (handler Axum 8 extractors, refacto sans valeur).
463 +- Bump `reqwest 0.13` **différé** : `oauth2 5.0.0` ne le supporte pas. `activitypub_federation 0.7` apportera `reqwest 0.13` transitivement quand `fediverse` sera active.
464 +
465 +Résultats :
466 +
467 +| Gate | Résultat |
468 +|---|---|
469 +| `cargo build --workspace` | ✅ 19,5 s |
470 +| `cargo test --workspace -- --test-threads=1` | ✅ **164 tests OK** |
471 +| `cargo clippy --workspace --all-targets -- -D warnings` | ✅ **0 warning** 15,6 s |
472 +
473 +Tests d'intégration nécessitent une DB Docker démarrée (cf. constitution v0.5.2 §85).
474 +
475 +---
476 +
477 +### Méthodologie pour toutes les phases ≥ 1
478 +
479 +Toutes les phases suivantes appliquent strictement les pratiques framework documentées.
480 +
481 +#### A. TDD obligatoire (Constitution §V + Testing Strategy §138-143)
482 +
483 +Boucle Red → Green → Refactor pour chaque user story :
484 +
485 +1. **Red** : écrire le test d'abord — test unitaire (happy + 3 cas d'erreur min) **et** un doctest dans la rustdoc de l'API publique cible.
486 +2. Vérifier que le test échoue **pour la bonne raison** (sur l'absence de l'API, pas un typo).
487 +3. **Green** : implémenter le minimum pour passer le test.
488 +4. **Refactor** : nettoyer (clippy 0 warning, doc complète, pas d'`unwrap`).
489 +5. Tests d'intégration ajoutés une fois la couche stabilisée.
490 +
491 +#### B. RFC obligatoire ([RUSTWARDEN_EVOLUTION_PROCESS](RUSTWARDEN_EVOLUTION_PROCESS.md) §2)
492 +
493 +Tout ajout d'item `pub` dans `rustwarden-core`, nouveau middleware/service, changement de schéma DB, modification de format sérialisé, ou nouvelle dépendance Cargo **DOIT** être précédé d'une RFC dans `docs/rfcs/NNNN-slug.md`.
494 +
495 +Template strict (cf. §3 du processus) : Summary, Motivation, Non-goals, API proposée, Analyse compatibilité, **Alternatives (≥ 3 dont "ne rien faire")**, Testing plan, Migration guide, Open questions, Tracking issue. Score min **25/35** sur 7 critères (Généralité, Stabilité API, Performance, Sécurité, Complexité, Maintenance, Documentation).
496 +
497 +**Granularité** : 1 RFC par chantier (cf. §17 — 11 RFCs prévues, décision utilisateur 2026-05-09).
498 +
499 +#### C. Quality gates (Constitution §125-136)
500 +
501 +À chaque PR / fin d'étape :
502 +
503 +```bash
504 +cargo build --workspace # 0 erreur
505 +cargo test --workspace -- --test-threads=1 # tous verts
506 +cargo clippy --workspace --all-targets -- -D warnings # 0 warning
507 +cargo fmt --check # formatting OK
508 +cargo doc --no-deps # 0 warning rustdoc
509 +# avant release :
510 +cargo audit # pas de vulnérabilité
511 +cargo outdated --root-deps-only # vérifié
512 +```
513 +
514 +**Coverage** : ≥ 80 % par phase ; **≥ 90 %** pour `fediverse_auth_service`, `trust`, `keys`, `signatures` (composants sécu/auth).
515 +
516 +#### D. Conformité ANSSI Rust (Constitution §IV)
517 +
518 +- Pas d'`unwrap()`/`expect()` en code prod (ok dans `#[cfg(test)]`).
519 +- Tout `unsafe` avec `// SAFETY:` documenté.
520 +- `cargo audit` propre avant release.
521 +- Workspace lints : `missing_docs = warn` actif (déjà en place).
522 +
523 +#### E. SemVer + deprecation (Evolution Process §2-§5)
524 +
525 +- Backwards-compat par défaut. Nouvelle API vit à côté de l'ancienne ; l'ancienne devient `#[deprecated]` pour ≥ 3 minor releases avant suppression.
526 +- Pas de breaking en patch/minor.
527 +- Feature flag initial `experimental-fediverse` ; promotion en `fediverse` standard après stabilisation (≥ 2 apps adoptantes **ou** 30 j sans bug critique).
528 +
529 +#### F. Patterns Rustwarden réutilisés (cf. §8)
530 +
531 +`CryptoService`, `RateLimitService`, `AppSettingsService`, `JwtService`, `JwtBlacklistService`, `RustwardenHooks`, `tasks::*` (pattern `personal_token_cleanup`), CSRF middleware, MockDatabase. **Ne pas réimplémenter** ce qui existe.
532 +
533 +#### G. Documentation (Constitution §I + Evolution §8)
534 +
535 +- Rustdoc EN sur tout `pub` (`missing_docs = warn`).
536 +- Doc humaine `/docs/` en FR.
537 +- CHANGELOG.md mis à jour à chaque PR (catégories `Added`/`Changed`/`Deprecated`/`Removed`/`Fixed`/`Security`).
538 +- Doctest exécuté pour chaque nouvel item public (≥ 1 exemple copie/collable).
539 +- `examples/` si pattern non-trivial (middleware, service composé, flow async).
540 +
541 +---
542 +
543 +### Phase 1 — Découverte (être trouvable)
544 +
545 +**Livrables `rustwarden-core` (backend headless)**
546 +
547 +1. Migrations 9 + 10 + 12 (remote_identities, étend `users` avec clés AP, trust/blocks).
548 +2. `FediverseConfig::from_env()`, `with_fediverse()` builder.
549 +3. Génération clés au signup (hook `on_user_created` + `spawn_blocking`) + backfill idempotent au boot.
550 +4. Routes WebFinger (`/.well-known/webfinger`), NodeInfo (`/.well-known/nodeinfo`, `/nodeinfo/2.1`), Actor (`GET /users/{u}` négociation Accept).
551 +5. Service `remote_identity_service::resolve_handle(@user@domain)` + cache TTL.
552 +6. Service `fediverse_trust_service` : CRUD allowlist/blocklist + `check_domain_allowed`.
553 +7. **API JSON admin** (consommable headless) : `POST /api/v1/admin/fediverse/settings`, `/trust/allow`, `/trust/block`, `GET /api/v1/admin/fediverse/status`.
554 +
555 +**Livrables `rustwarden-ui`**
556 +
557 +- Page admin `/admin/fediverse` (template `admin/settings.html`) : toggle `fediverse_enabled`, sélecteurs `trust_mode` / `interop_scope`.
558 +- Page admin `/admin/fediverse/trust` (template `admin/trust_list.html`) : table domaines, form add (CSRF), form remove (CSRF).
559 +- Entrée de menu admin "Fédération" (modif `admin_handler.rs`).
560 +- Profil utilisateur : balises `<link rel="alternate" type="application/activity+json">` + `<link rel="webfinger">` dans `<head>` (modif `user_handler.rs`).
561 +- i18n FR/EN/DE/ES initial : `locales/*/fediverse.yaml` avec libellés admin.
562 +
563 +**Done quand** :
564 +
565 +- `curl -H 'Accept: application/activity+json' https://b.tld/users/alice` retourne un Person JSON valide.
566 +- Mastodon résout le handle (sans pouvoir authentifier).
567 +- Un admin (UI ou via API JSON) peut activer/désactiver, choisir le mode de confiance, et gérer la liste de domaines.
568 +- RFC 0001-0005 acceptées (≥ 25/35).
569 +- Coverage ≥ 80 % sur `fediverse_*` ; ≥ 90 % sur `keys`, `trust`.
570 +
571 +### Phase 2 — Auth AP-native (couche 2)
572 +
573 +**Livrables `rustwarden-core`**
574 +
575 +1. Migration 14 (auth challenges) + 15 (outbound queue, partie auth).
576 +2. `fediverse_auth_service` : challenge → `AuthRequest` signé → attente → `Accept` → session.
577 +3. **API JSON** : `POST /api/v1/auth/login/fediverse/initiate` (renvoie un `challenge_id` + état) ; `POST /api/v1/auth/login/fediverse/callback` ; `GET /api/v1/auth/login/fediverse/status/{challenge_id}` (polling/SSE pour SPA).
578 +4. Handler IdP-side `POST /fediverse/auth/inbox` (reçoit `AuthRequest`).
579 +5. **API JSON IdP-side** : `POST /api/v1/fediverse/auth/consent` (Approve/Reject) — utilisable par UI ou app IdP custom.
580 +6. Worker outbound minimal (signe + POST `Accept{AuthRequest}`).
581 +7. `JwtService::Claims` étendu (`identity_kind`, `remote_id`).
582 +
583 +**Livrables `rustwarden-ui`**
584 +
585 +- Page `/login/fediverse` (template `login_form.html`) : champ saisie `@user@domain` + bouton initiate.
586 +- Modif `login_handler.rs` : afficher le widget "Connect with Fediverse identity" sur la page login si `fediverse_enabled=true`.
587 +- Page `/login/fediverse/waiting?challenge=...` (template `login_waiting.html`) : spinner + polling/SSE → redirige vers `redirect_url` quand session établie.
588 +- Page `/fediverse/consent?request_id=...` IdP-side (template `consent_screen.html`) : affichage clair du domaine demandeur, scope, boutons Approve / Reject avec CSRF.
589 +- Page d'erreur (template `error_unauthorized.html`) : rejet, timeout 30 s, domaine bloqué — codes traduits en messages user-friendly.
590 +- i18n : ajout libellés login + consent + erreurs.
591 +
592 +**Done quand** :
593 +
594 +- 2 instances Rustwarden locales (`debug_local_federation=true`) — `bob@a.tld` se connecte avec succès sur `b.tld`.
595 +- Session sur `b.tld` rattachée à `remote_identities`, **pas** à `users`.
596 +- Tentative de login avec domaine non trusté → page erreur explicite + log audit.
597 +- Test contrat API JSON : SPA peut piloter le flow sans toucher l'UI HTML.
598 +- RFC 0006-0007 acceptées.
599 +
600 +### Phase 3 — Identity abstraction & invitations
601 +
602 +**Livrables `rustwarden-core`**
603 +
604 +1. Migration 11 (sibling FKs `team_members.remote_identity_id`, `resource_shares.shared_with_remote_identity_id`, `audit_logs`) + 13 (pending invites).
605 +2. Enum `Identity` + `IdentityResolver` + adaptation services (`team_service`, `resource_service`, `auth_logging`).
606 +3. Service `fediverse_invite_service` + activités `Invite{TeamMembership}`, `Accept{Invite}`, `Reject{Invite}`, `Revoke{Membership}`.
607 +4. **API JSON équipes** : `POST /api/v1/teams/{id}/members/invite-fediverse` (body `{ handle, role }`), `GET /api/v1/teams/{id}/invites` (liste pending), `POST /api/v1/teams/{id}/invites/{invite_id}/revoke`.
608 +5. **API JSON IdP-side** : `GET /api/v1/fediverse/invites` (mes invitations reçues), `POST /api/v1/fediverse/invites/{id}/accept`, `/reject`.
609 +
610 +**Livrables `rustwarden-ui`**
611 +
612 +- Page admin `/admin/teams/{id}` enrichie : champ "Inviter par handle Fediverse" (input + sélecteur rôle), liste pending/accepted/rejected (template `admin/invites.html`).
613 +- Page IdP-side `/profile/invites` (template `invite_accept.html`) : "Vous êtes invité dans `<team>` sur `<host>` au rôle `<role>`" + boutons Accept / Reject avec CSRF.
614 +- Helper Askama dans `template_helpers.rs` : `render_identity(identity)` — affiche `@user@domain` avec icône Fediverse pour les distants, ou nom utilisateur local sinon.
615 +- Modif `admin_handler.rs` pour exposer la page invitations dans le menu équipes.
616 +- i18n : libellés invitations / acceptation.
617 +
618 +**Done quand** :
619 +
620 +- Admin de `b.tld` invite `alice@a.tld` via UI → activité `Invite` envoyée, statut `pending` côté `b.tld`.
621 +- Alice voit l'invitation sur son IdP (UI ou API JSON), accepte → activité `Accept{Invite}` envoyée → statut `accepted` côté `b.tld`.
622 +- Alice se connecte sur `b.tld` (flow Phase 2) → voit l'équipe + permissions team-scoped.
623 +- **Vérif négative** : Alice n'a aucun rôle global, ne peut pas accéder à `/admin/*` (HTTP 403 + audit log).
624 +- RFC 0008-0009 acceptées.
625 +
626 +### Phase 4 — Signaux et lifecycle
627 +
628 +**Livrables `rustwarden-core`**
629 +
630 +1. Handler `Update{Actor}` reçu → upsert `remote_identities` (display_name, summary, icon, public_key).
631 +2. Handler `Delete{Actor}` reçu → `remote_identities.disabled_at = NOW()` + invalidation sessions actives via `JwtBlacklistService`.
632 +3. Handler `Revoke{Membership}` reçu côté `b.tld` → suppression du `team_member` (initié par l'IdP quand l'utilisateur retire son consentement).
633 +4. **API JSON IdP-side** : `GET /api/v1/fediverse/connected-apps` (mes sessions remote actives), `POST /api/v1/fediverse/connected-apps/{host}/revoke`.
634 +5. **API JSON admin** : `GET /api/v1/admin/fediverse/sessions-remote`, `POST /api/v1/admin/fediverse/domains/{domain}/kill-switch`.
635 +6. Worker outbound : retry/backoff complets (5 min → 30 min → 2 h → 6 h → 24 h → 48 h selon code HTTP). Gestion instances mortes (compteur santé).
636 +7. Authorized fetch togglable (`app_settings.fediverse_authorized_fetch`).
637 +
638 +**Livrables `rustwarden-ui`**
639 +
640 +- Page profil utilisateur : section "Mes apps connectées" (template `profile_connected_apps.html`) — liste hosts + bouton "Révoquer".
641 +- Page admin `/admin/fediverse/sessions-remote` (template `admin/sessions_remote.html`) : sessions distantes actives + kill switch par domaine.
642 +- Modif `user_handler.rs` pour inclure la section apps connectées.
643 +- i18n : libellés révocation.
644 +
645 +**Done quand** :
646 +
647 +- IdP envoie `Delete{Actor}` → toutes les sessions liées invalidées sur tous les hosts en moins de 30 s.
648 +- Utilisateur clique "Révoquer" sur `b.tld` depuis son IdP → session terminée sur `b.tld`, audit log généré.
649 +- Admin de `b.tld` active le kill switch sur `domain-a.tld` → toutes les sessions remote de ce domaine invalidées.
650 +- RFC 0010 acceptée.
651 +
652 +### Phase 5 — Production
653 +
654 +**Livrables `rustwarden-core`**
655 +
656 +- Tests interop avec Mastodon : profil discoverable mais auth refusée proprement (degrade gracieux selon `interop_scope`).
657 +- Monitoring file outbound, métriques `tracing` (compteurs livraisons, retries, instances mortes).
658 +- `cargo audit` propre, `clippy -- -D warnings` zéro.
659 +- Charge : 1 user × 1000 followers × 50 instances → SLA livraison < 5 min P99.
660 +
661 +**Livrables `rustwarden-ui`**
662 +
663 +- Page admin `/admin/fediverse/queue` (template `admin/queue.html`) : dashboard file outbound (statut, retries, dernier échec, instances mortes).
664 +- Page admin `/admin/fediverse/nodeinfo` (template `admin/nodeinfo.html`) : stats lisibles humainement.
665 +- Documentation utilisateur `docs/fediverse.md` (FR) avec captures d'écran des parcours.
666 +- Documentation rustdoc EN sur toute API publique.
667 +
668 +**Done quand** :
669 +
670 +- Tous les quality gates passent (build/test/clippy/doc/audit).
671 +- Coverage globale `fediverse_*` ≥ 80 %, ≥ 90 % sur composants sécu/auth.
672 +- Documentation utilisateur publiée, parcours captures d'écran à jour.
673 +- Promotion feature flag `experimental-fediverse` → `fediverse` standard si critères atteints.
674 +
675 +---
676 +
677 +## 11. Vérification end-to-end
678 +
679 +### Tests automatisés (CI)
680 +1. `cargo test --workspace` — toutes les features off, rétrocompat préservée.
681 +2. `cargo test --workspace --features rustwarden-core/fediverse,rustwarden-ui/fediverse` — tests inline (services, identité, signatures, dispatch).
682 +3. `cargo test --doc` — doctests passent (constitution §I : exemple sur chaque API publique).
683 +4. `cargo doc --no-deps -p rustwarden-core` — 0 warning rustdoc (constitution §132).
684 +5. `cargo audit` — pas de vulnérabilité (release gate, constitution §133).
685 +6. Coverage measurée via `cargo llvm-cov` ou `cargo tarpaulin` ; cible ≥ 80 % par phase, ≥ 90 % pour les composants sécu/auth (`fediverse_auth_service`, `trust`, `keys`, `signatures`).
686 +7. Tests d'intégration `#[ignore]` lancés via `docker-compose.fediverse-test.yml` (deux binaires Rustwarden sur ports différents avec `debug_local_federation=true`) :
687 + - **Test 1 (Phase 1)** : WebFinger lookup + fetch acteur → Person JSON valide.
688 + - **Test 2 (Phase 2)** : Login fédéré bout-en-bout, vérifier que la session sur b.tld est rattachée à `remote_identities`, **pas** à `users`.
689 + - **Test 3 (Phase 3)** : Invitation team cross-instance, acceptation, vérif RBAC team-scoped, **vérif négative** : l'identité distante NE PEUT PAS créer une équipe ni accéder à un endpoint admin (HTTP 403).
690 + - **Test 4 (Phase 4)** : `Delete{Actor}` chez l'IdP → invalidation effective sur l'hôte (`JwtBlacklistService`).
691 +
692 +### Vérifications manuelles
693 +1. `cargo build --no-default-features` + `cargo build --features fediverse` : la feature ne casse rien sans elle, et compile clean avec.
694 +2. `cargo build` sur `app/` (binaire de référence) sans la feature : aucun changement de comportement.
695 +3. Activer `fediverse_enabled=true` runtime, vérifier l'apparition du champ "Connect with Fediverse identity" sur la page login.
696 +4. Tester chaque mode (`allowlist` / `open_with_blocklist`) en flippant le setting et en re-tentant un auth depuis un domaine non listé → log d'audit + refus 403.
697 +5. `curl -H 'Accept: application/activity+json' https://localhost:3000/users/admin` → JSON Person valide.
698 +6. WebFinger : `curl 'https://localhost:3000/.well-known/webfinger?resource=acct:admin@localhost:3000'`.
699 +7. Inviter `@user@otherinstance:3001` dans une équipe via UI admin → observer la séquence d'activités dans les logs `tracing` des deux instances.
700 +
701 +### Compatibilité Rustwarden-only
702 +Mode `interop_scope=rustwarden_only` : refuser proprement (HTTP 403 + message) toute auth depuis un domaine non-Rustwarden, identifié par `software.name` de NodeInfo. Vérification : pointer un Mastodon test vers le flow et observer le refus + log d'audit.
703 +
704 +---
705 +
706 +## 12. Points en suspens à valider en début Phase 1
707 +
708 +1. **Slug FEP exact pour proxy authentication** — `FEP-fffd` est un placeholder. Vérifier https://codeberg.org/fediverse/fep/src/branch/main/fep avant de figer le format des activités. Sinon : flow custom Rustwarden documenté + soumission FEP draft.
709 +2. **Format du token d'assertion dans `Accept{AuthRequest}`** : JWT signé RSA-SHA256 par la clé d'acteur de l'IdP (réutilise mécanique HTTP-Sigs) — recommandé.
710 +3. **Format handle login** : strict `@user@domain` (Mastodon-style) avec normalisation tolérante.
711 +4. **Migration users existants** : génération clés AP en `spawn_blocking` au signup, backfill global en tâche unique au boot.
712 +5. **Bump `sea-orm 2.0.0-rc.20 → rc.38`** : indépendant du plan ; à valider en parallèle (changelog SeaORM 2.0).
713 +
714 +---
715 +
716 +## 13. Risques principaux
717 +
718 +| Risque | Probabilité | Impact | Atténuation |
719 +|---|---|---|---|
720 +| Pas de FEP standard solide pour proxy-auth | Élevée | Moyen | Documenter notre flow, soumettre comme FEP draft, garder simple |
721 +| `activitypub_federation 0.7-beta` API change avant 0.7.0 stable | Moyenne | Bloquant | Pin strict `=0.7.0-beta.11` ; surveiller releases ; tests d'intégration locaux |
722 +| Edition 2024 — sémantique closure capture, `unsafe` set_var | — | — | **Résolu Phase 0** ✅ (4 wraps `unsafe { set_var }` + `// SAFETY:` documenté) |
723 +| Décalage entre RFC formelle et exécution rapide | Moyenne | Moyen | Template suivi mais score 25/35 toléré sur scope interne ; revue inline avant merge plutôt que sync mensuelle (un seul mainteneur pour l'instant) |
724 +| Refacto `Identity` casse tests existants | Moyenne | Important | Conserver méthodes `add_member(user_id)` qui délèguent à `add_member(Identity::Local(user_id))` |
725 +| Latence auth fédérée (round-trip AP) | Faible | UX dégradé | Page d'attente UI avec polling/SSE ; timeout 30s ; cache clé publique IdP |
726 +| Marvin RSA hérité via `rsa 0.9.10` (RUSTSEC-2023-0071) | Moyenne | Sécu | `cargo tree` + doc dans `SECURITY_AUDIT_2026_05.md` ; surveiller upstream |
727 +| Performance backfill clés sur grosse base existante | Faible | Boot lent | Tâche unique différée + index ; pas bloquant |
728 +| Routes `/users/{u}` collision UI ↔ AP | Faible | Bug | Négociation `Accept` dans un seul handler core qui délègue UI |
729 +
730 +---
731 +
732 +## 14. Estimation
733 +
734 +| Phase | Effort dev (j-h) |
735 +|---|---|
736 +| 0 — Edition 2024 + clippy clean | ✅ **Réalisé ~0,5 j** (vs 1–2 estimé) |
737 +| 1 — Découverte + admin trust | 5–7 (+ 0,3 j RFC) |
738 +| 2 — Auth AP-native | 6–9 (+ 0,4 j RFC) |
739 +| 3 — Identity abstraction + invitations | 7–10 (+ 0,4 j RFC) |
740 +| 4 — Signaux et lifecycle | 4–5 (+ 0,2 j RFC) |
741 +| 5 — Production | 4–5 |
742 +| Doc + i18n + UI admin avancée | 3–4 |
743 +| Overhead RFC (11 RFCs × ~0,2 j) | inclus ci-dessus (~2 j total) |
744 +| TDD overhead (test-first) | absorbé (~10 % par phase, compensé par moins de retouches) |
745 +| **Total révisé** | **32–46 j-h** (vs 30–42 initial — +2–4 j pour rigueur RFC + TDD systématique) |
746 +
747 +---
748 +
749 +## 15. Annexe — Commandes utiles
750 +
751 +```bash
752 +# Vérification post-bump edition / reqwest
753 +cargo build --workspace
754 +cargo test --workspace
755 +cargo clippy --workspace -- -D warnings
756 +
757 +# Compilation feature fediverse
758 +cargo build -p rustwarden-core --features fediverse
759 +cargo build -p rustwarden-ui --features fediverse
760 +
761 +# Audit sécu
762 +cargo audit
763 +cargo outdated --root-deps-only
764 +
765 +# Graphe dépendances feature
766 +cargo tree -e features -p rustwarden-core --features fediverse
767 +cargo tree -p activitypub_federation
768 +
769 +# Santé upstream
770 +gh issue list --repo LemmyNet/activitypub-federation-rust --limit 20
771 +gh release list --repo LemmyNet/activitypub-federation-rust --limit 10
772 +gh pr list --repo LemmyNet/activitypub-federation-rust --state all --limit 30 \
773 + --json number,title,createdAt,mergedAt
774 +```
775 +
776 +---
777 +
778 +## 16. État d'avancement détaillé
779 +
780 +| Phase | Statut | RFC associée | Date |
781 +|---|---|---|---|
782 +| 0 — Pré-requis | ✅ Terminée | — (changement non public) | 2026-05-09 |
783 +| 1 — Découverte | ✅ **Terminée** (5/5 RFCs livrées) | RFC 0001 ✅, 0002 ✅, 0003 ✅, 0004 ✅, 0005 ✅ | 2026-05-09 |
784 +| 2 — Auth AP-native | ✅ **Service livré** (3/3 RFCs livrées, routes HTTP différées) | RFC 0006 ✅, 0007 ✅, 0011 ✅ | 2026-05-09 |
785 +| 3 — Identity abstraction & invitations | ✅ **Service livré** (RFC 0008-0009 livrées, routes HTTP & PK relax différées) | RFC 0008 ✅, 0009 ✅ | 2026-05-09 |
786 +| 4 — Signaux & lifecycle | ✅ **Service livré** (RFC 0010 livrée, routes inbox & HTTP-Sig réelle différées) | RFC 0010 ✅ | 2026-05-10 |
787 +| 5 — Production | ⏳ | — | — |
788 +
789 +### RFCs détaillées
790 +
791 +| RFC | Slug | Statut | Score | Tests | Implémentation |
792 +|---|---|---|---|---|---|
793 +| [0001](../rfcs/0001-fediverse-config-and-builder.md) | `fediverse-config-and-builder` | ✅ Accepted | 28/35 | 7 unit + 2 doctests (ignored) | Livrée 2026-05-09 — 171 tests workspace, clippy/fmt/doc 0 warning |
794 +| [0002](../rfcs/0002-webfinger-actor-nodeinfo.md) | `webfinger-actor-nodeinfo` | ✅ Accepted | 29/35 | 15 unit (5 webfinger + 6 actor + 3 nodeinfo + 1 helper) | Livrée 2026-05-09 — 186 tests workspace, build/test/clippy/fmt/doc 0 warning. Limitation : `publicKey` reste `None` jusqu'à RFC 0005. |
795 +| [0003](../rfcs/0003-remote-identity-store.md) | `remote-identity-store` | ✅ Accepted | 26/35 | 11 unit (5 pure + 6 DB-mocked via `sea-orm/mock`) | Livrée 2026-05-09 — 197 tests workspace, 5/5 gates verts. Limitation : `RemoteFetcher` réel (HTTP signé) reporté à RFC 0010 ; placeholder `UnconfiguredFetcher` en attendant. |
796 +| [0004](../rfcs/0004-fediverse-trust-model.md) | `fediverse-trust-model` | ✅ Accepted | 27/35 | 12 unit (6 pure + 6 DB-mocked via `sea-orm/mock`) | Livrée 2026-05-09 — 266 tests workspace, 5/5 gates verts. Migration `m20260510_000010_fediverse_trust_and_blocks` (tables `fediverse_trusted_domains`, `fediverse_blocked_domains` + 6 entrées `app_settings`). `is_domain_allowed()` ready pour gate AP-native (RFC 0006). |
797 +| [0005](../rfcs/0005-actor-key-storage.md) | `actor-key-storage` | ✅ Accepted | 26/35 | 13 unit (4 pure RSA + 9 DB-mocked via `sea-orm/mock`) | Livrée 2026-05-09 — 279 tests workspace, 5/5 gates verts. Migration `m20260510_000011_extend_users_with_actor_keys` (3 colonnes `users` : `ap_public_key_pem`, `ap_private_key_pem` chiffré, `ap_discoverable`). `SeaOrmLocalUserResolver` génère paresseusement la paire RSA-2048 quand `CryptoService::is_available()`. Phase 1 close. |
798 +| [0007](../rfcs/0007-jwt-claims-identity-kind.md) | `jwt-claims-identity-kind` | ✅ Accepted | 27/35 | 8 unit (pure helpers + serde round-trip) | Livrée 2026-05-09 — 287 tests workspace, 5/5 gates verts. Étend `Claims` avec `identity_kind: IdentityKind` (`Local` par défaut, retro-compatible) + `remote_id: Option<Uuid>`. Helpers `is_remote()`, `is_local()`, `remote_identity_id()` (guard confused-deputy). Méthode `JwtService::generate_remote_token`. |
799 +| [0011](../rfcs/0011-fediverse-outbound-worker.md) | `fediverse-outbound-worker` (slice Phase 2) | ✅ Accepted | 26/35 | 10 unit (2 pure + 8 DB-mocked) | Livrée 2026-05-09. Migration `m20260510_000012_fediverse_outbound_queue` (table + index partiel `pending`). Service `FediverseOutboundService` (enqueue + drain_one), trait `OutboundDelivery`, `NoopDelivery`, `OutboundWorker<T>`. Slice Phase 2 : `pending → delivered\|failed`. Retry/backoff + signature HTTP réelle reportés à RFC 0010. |
800 +| [0006](../rfcs/0006-ap-native-auth-flow.md) | `ap-native-auth-flow` (couche service) | ✅ Accepted | 26/35 | 12 unit (3 pure + 9 DB-mocked) | Livrée 2026-05-09. Migration `m20260510_000013_fediverse_auth_challenges`. Service `FediverseAuthService` (state machine `Pending → Approved/Rejected → Consumed`, helper `build_auth_request_payload`). Routes HTTP `/api/v1/auth/login/fediverse/*` + UI consent volontairement reportées (slice service complet). |
801 +| [0008](../rfcs/0008-identity-abstraction.md) | `identity-abstraction` | ✅ Accepted | 26/35 | 8 unit (helpers + serde + claims conv) | Livrée 2026-05-09. Migration `m20260510_000014_identity_sibling_fks` (4 colonnes nullable + 2 indexes partiels). Module `rustwarden_core::identity` (enum `Identity`, trait `IdentityResolver`, helper `from_claims`). Champs ajoutés à `team_member::Model`, `resource_share::Model`, `audit_log::Model`. PK `team_members` inchangée — relax différée. |
802 +| [0009](../rfcs/0009-team-resource-cross-instance.md) | `team-resource-cross-instance` (slice service) | ✅ Accepted | 26/35 | 11 unit (3 pure + 8 DB-mocked) + 2 intégration team_service | Livrée 2026-05-09. Migration `m20260510_000015_fediverse_pending_invites` (table + 2 indexes incluant le UNIQUE partiel `team_members_remote_unique`). Service `FediverseInviteService` (invite_remote/accept/reject/revoke/list_pending_for_team), 4 helpers AP `build_*_payload`. `team_service::add_member_identity` + `teams_of_identity`. Routes HTTP admin/IdP différées. |
803 +| [0010](../rfcs/0010-fediverse-lifecycle-signals.md) | `fediverse-lifecycle-signals` (slice service) | ✅ Accepted | 26/35 | 10 unit signal (1 pure + 9 DB-mocked) + 8 unit outbound retry | Livrée 2026-05-10. Aucune migration. Service `FediverseSignalService` (`apply_update_actor`, `apply_delete_actor`, `apply_revoke_membership`). Trait `SignatureVerifier` + impl `AlwaysAcceptVerifier` (test/dev). Extension `FediverseOutboundService::drain_one` avec retry ladder 5min→30min→2h→6h→24h→48h et transition `pending → dead` à `attempts >= max_attempts`. Routes `/inbox` + impl HTTP-Sig réelle reportées à un follow-up RFC crypto. |
804 +
805 +---
806 +
807 +## 17. RFCs prévues
808 +
809 +Granularité retenue (décision utilisateur 2026-05-09) : **1 RFC par chantier**, conformité maximale à [RUSTWARDEN_EVOLUTION_PROCESS](RUSTWARDEN_EVOLUTION_PROCESS.md) §2. Chaque RFC a son tracking issue dédié et chaque API publique est traçable individuellement.
810 +
811 +| # | Slug | Phase | Couvre |
812 +|---|---|---|---|
813 +| 0001 | `fediverse-config-and-builder` | 1 | `FediverseConfig`, `with_fediverse()`, feature flag `experimental-fediverse` |
814 +| 0002 | `webfinger-actor-nodeinfo` | 1 | Routes découverte AP, négociation `Accept`, `PersonJson` |
815 +| 0003 | `remote-identity-store` | 1 | Table + service `remote_identities`, WebFinger lookup |
816 +| 0004 | `fediverse-trust-model` | 1 | `app_settings` toggles + tables allowlist/blocklist |
817 +| 0005 | `actor-key-storage` | 1 | Génération RSA, chiffrement via `CryptoService`, backfill |
818 +| 0006 | `ap-native-auth-flow` | 2 | `AuthRequest`/`Accept{AuthRequest}`, challenge nonce, page consent |
819 +| 0007 | `jwt-claims-identity-kind` | 2 | Extension `Claims` avec `identity_kind`, `remote_id` |
820 +| 0008 | `identity-abstraction` | 3 | `enum Identity`, `IdentityResolver`, sibling FK migrations |
821 +| 0009 | `team-resource-cross-instance` | 3 | `team_service::add_member(Identity)`, `Principal::Identity`, invitations |
822 +| 0010 | `fediverse-lifecycle-signals` | 4 | `Update{Actor}`, `Delete{Actor}`, `Revoke{Membership}` |
823 +| 0011 | `fediverse-outbound-worker` | 2-4 | Worker + retry/backoff, table `fediverse_outbound_queue` |
824 +
825 +Chaque RFC doit obtenir un score **≥ 25/35** sur 7 critères (Généralité, Stabilité API, Performance, Sécurité, Complexité, Maintenance, Documentation) avant passage en `Accepted`.
826 +
827 +Workflow par RFC :
828 +1. Créer `docs/rfcs/NNNN-slug.md` à partir du template (Evolution Process §3).
829 +2. Écrire les tests AVANT l'implémentation (red phase TDD).
830 +3. Implémenter, valider quality gates (build/test/clippy/doc/audit).
831 +4. Mise à jour `CHANGELOG.md` + rustdoc + doctest fonctionnel.
832 +5. Référencer la RFC dans la PR.
833 +
834 +---
835 +
836 +## 18. Surface UI complète (récap parcours utilisateur)
837 +
838 +Pour s'assurer que **rien n'est oublié côté UI**, voici la synthèse des écrans HTML que `rustwarden-ui` doit livrer à la fin de la Phase 5, organisée par parcours utilisateur. Chaque ligne référence le template Askama et les phases concernées.
839 +
840 +### 18.1 Parcours **utilisateur final** (non admin)
841 +
842 +| Écran | Template | Phase | Contenu |
843 +|---|---|---|---|
844 +| Page de login étendue | partial dans `login.html` | 2 | Champ "Connect with Fediverse identity" (handle `@user@domain`) si `fediverse_enabled=true` |
845 +| Attente pendant login fédéré | `fediverse/login_waiting.html` | 2 | Spinner + polling SSE → redirige quand `Accept{AuthRequest}` reçu ou erreur |
846 +| Erreur d'auth fédérée | `fediverse/error_unauthorized.html` | 2 | Messages user-friendly pour : rejet IdP, timeout, domaine non trusté, signature invalide |
847 +| Consent screen IdP-side | `fediverse/consent_screen.html` | 2 | Domaine demandeur, scope demandé, boutons Approve/Reject + CSRF |
848 +| Acceptation d'invitation IdP-side | `fediverse/invite_accept.html` | 3 | "Vous êtes invité dans `<team>` sur `<host>` au rôle `<role>`" + Accept/Reject + CSRF |
849 +| Profil — section "Apps connectées" | partial `fediverse/profile_connected_apps.html` | 4 | Liste hosts + sessions actives + bouton "Révoquer" |
850 +| Profil — découverte AP | modif `<head>` profil | 1 | `<link rel="alternate" type="application/activity+json">`, `<link rel="webfinger">` |
851 +
852 +### 18.2 Parcours **administrateur**
853 +
854 +| Écran | Template | Phase | Contenu |
855 +|---|---|---|---|
856 +| Réglages fédération | `fediverse/admin/settings.html` | 1 | Toggle `fediverse_enabled`, sélecteurs `trust_mode` (allowlist / open+blocklist), `interop_scope` (rustwarden_only / rustwarden+universal_read / open), `authorized_fetch` |
857 +| Gestion trust (allowlist/blocklist) | `fediverse/admin/trust_list.html` | 1 | Table domaines, form ajout (CSRF), form suppression, sévérité pour blocklist |
858 +| Invitations équipes | `fediverse/admin/invites.html` | 3 | Form "Inviter par handle" + sélecteur rôle ; table pending/accepted/rejected/revoked + bouton révoquer |
859 +| Sessions distantes actives | `fediverse/admin/sessions_remote.html` | 4 | Liste users distants connectés ; kill switch par domaine |
860 +| File outbound | `fediverse/admin/queue.html` | 5 | Dashboard livraisons : pending/delivered/failed/dead, retries, dernier échec, instances mortes |
861 +| NodeInfo stats | `fediverse/admin/nodeinfo.html` | 5 | Users total/active_month/active_halfyear, version, protocoles supportés |
862 +
863 +### 18.3 Conventions UI obligatoires
864 +
865 +- **HTMX + Alpine.js** : tous les forms et listes utilisent HTMX pour les soumissions/refresh (cohérent stack existant).
866 +- **DaisyUI / Tailwind** : composants stylés selon la charte existante (`docs/CSS_STYLE_GUIDE.md`).
867 +- **CSRF** : tous les forms POST passent par le middleware CSRF Rustwarden existant.
868 +- **i18n** : aucun libellé en dur — tout dans `locales/{fr,en,de,es}/fediverse.yaml`.
869 +- **Accessibilité** : labels `<label>` pour les inputs, `aria-*` sur les actions destructives (révocation, kill switch), tabulation logique.
870 +- **Responsive** : DaisyUI responsive par défaut ; vérifier mobile sur les écrans admin denses (queue, sessions).
871 +- **Confirmation** sur actions destructives : modale Alpine pour révoquer / kill switch / supprimer un domaine de la trust list.
872 +- **Feedback** : toast HTMX `hx-swap-oob` pour signaler succès/échec d'une action.
873 +
874 +### 18.4 API JSON parallèles (consommables headless)
875 +
876 +**Toute** action proposée par l'UI HTML est aussi exposée comme route API JSON dans `rustwarden-core`. Cela garantit qu'une SPA, une app mobile, ou une CLI peut piloter exactement les mêmes flows sans dépendre de `rustwarden-ui`.
877 +
878 +Récap des routes JSON par parcours :
879 +
880 +| Parcours | Routes JSON exposées par `rustwarden-core` |
881 +|---|---|
882 +| Login fédéré | `POST /api/v1/auth/login/fediverse/initiate`, `/callback`, `GET /status/{challenge}` |
883 +| Consent IdP-side | `GET /api/v1/fediverse/auth/pending`, `POST /api/v1/fediverse/auth/consent` |
884 +| Invitation reçue | `GET /api/v1/fediverse/invites`, `POST /api/v1/fediverse/invites/{id}/accept`, `/reject` |
885 +| Apps connectées | `GET /api/v1/fediverse/connected-apps`, `POST /api/v1/fediverse/connected-apps/{host}/revoke` |
886 +| Admin trust | `POST /api/v1/admin/fediverse/settings`, `/trust/allow`, `/trust/block`, `GET /status` |
887 +| Admin invitations | `POST /api/v1/teams/{id}/members/invite-fediverse`, `GET /invites`, `POST /invites/{id}/revoke` |
888 +| Admin sessions | `GET /api/v1/admin/fediverse/sessions-remote`, `POST /domains/{domain}/kill-switch` |
889 +| Admin queue | `GET /api/v1/admin/fediverse/queue` |
890 +| NodeInfo | `GET /nodeinfo/2.1` (public, AP standard) |
891 +
892 +---
893 +
894 +*Fin du plan.*
A docs/plans/2026-05-09-rfc-0001-implementation.md
+103 -0
@@ -0,0 +1,103 @@
1 +# Plan d'implémentation — RFC 0001 (TDD)
2 +
3 +## Context
4 +
5 +L'utilisateur a confirmé le workflow strict : **1 RFC → 1 implémentation → tests verts → refactor → RFC suivante**. La RFC 0001 [`docs/rfcs/0001-fediverse-config-and-builder.md`](../../Documents/devel/Rustwarden/docs/rfcs/0001-fediverse-config-and-builder.md) a été rédigée et tient lieu de **contrat d'implémentation détaillé** : Summary, API proposée Rust complète, 4 alternatives évaluées, Testing plan avec 7 tests nominatifs, Migration guide.
6 +
7 +Cette tâche n'est pas une nouvelle planification mais l'**exécution** du contrat RFC 0001 selon TDD. Aucun nouveau plan à versionner dans `docs/plans/` — la RFC est la spec, le master plan [`2026-05-09-fediverse-identity-federation.md`](../../Documents/devel/Rustwarden/docs/plans/2026-05-09-fediverse-identity-federation.md) est la stratégie globale.
8 +
9 +## Approche (cycle TDD)
10 +
11 +### Étape 0 — Auto-revue RFC 0001 (transition `Draft` → `Accepted`)
12 +
13 +Résoudre les 4 open questions de la RFC :
14 +
15 +| Q | Décision proposée |
16 +|---|---|
17 +| Q1 — Naming feature | `experimental-fediverse` (cohérent §10/E + §17 du master plan) |
18 +| Q2 — Visibilité champs | Accesseurs (méthodes `pub fn`), pas de `pub` direct sur les champs (encapsulation, calc lazy futur) |
19 +| Q3 — `from_env()` vs `from_env_with(prefix)` | `from_env()` sans préfixe (cohérent avec `AuthConfig::from_env()`, `EmailConfig::from_env()` existants) |
20 +| Q4 — Stockage builder | Champ `pub(crate) fediverse_config: Option<FediverseConfig>` sur `RustwardenBuilder` |
21 +
22 +Self-score (mainteneur unique) : viser ≥ 25/35.
23 +
24 +Mettre à jour [`docs/rfcs/README.md`](../../Documents/devel/Rustwarden/docs/rfcs/README.md) index : statut `Draft` → `Accepted`.
25 +
26 +### Étape 1 — RED (tests d'abord, doivent échouer)
27 +
28 +1. Modifier [`crates/rustwarden-core/Cargo.toml`](../../Documents/devel/Rustwarden/crates/rustwarden-core/Cargo.toml) : ajouter deps `activitypub_federation`, `ammonia`, `url` (toutes `optional = true`) + feature `experimental-fediverse`.
29 +2. Modifier [`crates/rustwarden-ui/Cargo.toml`](../../Documents/devel/Rustwarden/crates/rustwarden-ui/Cargo.toml) : feature pass-through.
30 +3. Créer `crates/rustwarden-core/src/config/fediverse.rs` avec :
31 + - `pub struct FediverseConfig` (skeleton, accesseurs)
32 + - `pub enum FediverseConfigError` complet
33 + - `impl FediverseConfig { pub fn from_env() -> Result<Self, _> { unimplemented!() } }`
34 + - Accesseurs avec corps `unimplemented!()`
35 + - `#[cfg(test)] mod tests` avec **7 tests nominatifs** (cf. RFC 0001 Testing plan).
36 +4. Modifier [`crates/rustwarden-core/src/config/mod.rs`](../../Documents/devel/Rustwarden/crates/rustwarden-core/src/config/mod.rs) : `#[cfg(feature = "experimental-fediverse")] pub mod fediverse;` + reexports.
37 +5. Modifier [`crates/rustwarden-core/src/lib.rs`](../../Documents/devel/Rustwarden/crates/rustwarden-core/src/lib.rs) : prelude étendu derrière feature.
38 +6. **Vérifier RED** : `cargo test -p rustwarden-core --features experimental-fediverse` → les 7 tests **doivent panic** sur `unimplemented!()` (red phase confirmée pour la bonne raison).
39 +
40 +### Étape 2 — GREEN (impl minimale, tests passent)
41 +
42 +7. Implémenter `FediverseConfig::from_env()` :
43 + - Lire `FEDIVERSE_DOMAIN` (obligatoire) → `MissingDomain` si absent.
44 + - Vérifier qu'il n'est pas un placeholder (`example.com`, `localhost.localdomain`, `your.tld`) → `PlaceholderDomain`.
45 + - Parser via `url::Url` → `InvalidDomain`.
46 + - Construire `base_url` HTTPS sauf si `FEDIVERSE_DEBUG_LOCAL_FEDERATION=true` → `InsecureBaseUrl`.
47 + - Lire les vars optionnelles avec défauts (workers=4, attempts=8, timeouts).
48 + - Numérique invalide → `InvalidNumeric`.
49 +8. Implémenter les accesseurs (1 ligne chacun, retours `&str`/`&Url`/`bool`/`Duration`/`u32`/`usize`).
50 +9. Modifier [`crates/rustwarden-core/src/app.rs`](../../Documents/devel/Rustwarden/crates/rustwarden-core/src/app.rs) :
51 + - Ajouter `pub(crate) fediverse_config: Option<FediverseConfig>` au `RustwardenBuilder` (gated `cfg(feature)`).
52 + - Ajouter méthode `pub fn with_fediverse(self) -> anyhow::Result<Self>` (gated).
53 + - Au moment du `build()`, juste **stocker** le config — pas de routes / middleware / worker (RFC 0002+).
54 +10. **Vérifier GREEN** : `cargo test -p rustwarden-core --features experimental-fediverse -- --test-threads=1` → 7 tests passent + tous les tests existants (rétrocompat).
55 +
56 +### Étape 3 — Refactor (qualité)
57 +
58 +11. `cargo clippy --workspace --all-targets --features experimental-fediverse -- -D warnings` → 0 warning.
59 +12. `cargo fmt --all --check` → OK.
60 +13. `cargo doc --no-deps -p rustwarden-core --features experimental-fediverse` → 0 warning rustdoc.
61 +14. `cargo test --doc -p rustwarden-core --features experimental-fediverse` → doctest valide.
62 +15. `cargo build --workspace` (sans feature) → reste OK (rétrocompat).
63 +16. `cargo build --workspace --features rustwarden-core/experimental-fediverse,rustwarden-ui/experimental-fediverse` → OK.
64 +
65 +### Étape 4 — Trace
66 +
67 +17. Ajouter une entrée `CHANGELOG.md` (créer le fichier si absent) catégorie `Added` (cf. migration guide RFC 0001).
68 +18. Mettre à jour la table de §16 « État d'avancement détaillé » dans le master plan : RFC 0001 → ✅ Accepted + implémentée.
69 +
70 +## Vérification end-to-end
71 +
72 +| Gate | Commande |
73 +|---|---|
74 +| Build sans feature | `cargo build --workspace` |
75 +| Build avec feature | `cargo build --workspace --features rustwarden-core/experimental-fediverse` |
76 +| Tests sans feature | `cargo test --workspace -- --test-threads=1` |
77 +| Tests avec feature | `cargo test --workspace --features rustwarden-core/experimental-fediverse -- --test-threads=1` |
78 +| Clippy | `cargo clippy --workspace --all-targets --features rustwarden-core/experimental-fediverse -- -D warnings` |
79 +| Doctest | `cargo test --doc -p rustwarden-core --features experimental-fediverse` |
80 +| Doc | `cargo doc --no-deps -p rustwarden-core --features experimental-fediverse` |
81 +
82 +Tous doivent retourner exit 0, **0 warning** et **tests verts**.
83 +
84 +## Fichiers touchés
85 +
86 +- `crates/rustwarden-core/Cargo.toml` (ajout deps + feature)
87 +- `crates/rustwarden-ui/Cargo.toml` (pass-through feature)
88 +- `crates/rustwarden-core/src/config/fediverse.rs` (création)
89 +- `crates/rustwarden-core/src/config/mod.rs` (mod + reexport)
90 +- `crates/rustwarden-core/src/lib.rs` (prelude étendu)
91 +- `crates/rustwarden-core/src/app.rs` (champ + méthode `with_fediverse`)
92 +- `docs/rfcs/0001-fediverse-config-and-builder.md` (statut + score + open questions résolues)
93 +- `docs/rfcs/README.md` (index statut)
94 +- `docs/plans/2026-05-09-fediverse-identity-federation.md` (table §16 mise à jour)
95 +- `CHANGELOG.md` (entrée `Added` ; création si absent)
96 +
97 +Aucun changement de schéma DB (RFC 0001 hors scope).
98 +
99 +## Post-implémentation
100 +
101 +Une fois RFC 0001 livrée, mettre à jour TODO global avec passage à RFC 0002 (`webfinger-actor-nodeinfo`).
102 +
103 +Per CLAUDE.md §12 : ce slug file est temporaire, sera supprimé immédiatement après ExitPlanMode (le master plan + la RFC contiennent tout le contenu durable).
A docs/rfcs/0000-template.md
+146 -0
@@ -0,0 +1,146 @@
1 +# RFC NNNN — <Titre court>
2 +
3 +- **Auteur** : <nom + repo d'origine>
4 +- **Date** : YYYY-MM-DD
5 +- **Statut** : Draft | Under Review | Accepted | Rejected | Superseded by #MMMM
6 +- **Apps consommatrices concernées** : <liste des apps qui bénéficient directement>
7 +
8 +## Summary
9 +
10 +<Une seule phrase qu'un dev non-spécialiste doit pouvoir comprendre. Que propose-t-on ?>
11 +
12 +## Motivation
13 +
14 +- Quel problème concret cette RFC résout-elle ?
15 +- Qui est impacté aujourd'hui (apps consommatrices, cas d'usage) ?
16 +- Quelles sont les conséquences si on ne fait rien ?
17 +- Y a-t-il un délai/urgence (incident sécurité, deadline upstream) ?
18 +
19 +## Non-goals
20 +
21 +<Lister explicitement ce qui est **hors scope** pour éviter le scope creep.>
22 +<Ex : "cette RFC ne touche pas au flow OAuth2, qui est traité dans la RFC #XXXX".>
23 +
24 +## API proposée
25 +
26 +Code Rust complet des nouvelles signatures publiques. Exemple d'utilisation côté consommateur.
27 +
28 +```rust
29 +// Signature complète
30 +pub fn new_api(...) -> ... { ... }
31 +
32 +// Exemple d'usage
33 +async fn handler(...) -> Response {
34 + let result = new_api(...)?;
35 + // ...
36 +}
37 +```
38 +
39 +## Analyse de compatibilité
40 +
41 +Checklist :
42 +
43 +- [ ] API existante non modifiée → **non-breaking** (patch/minor possible).
44 +- [ ] API existante étendue avec défaut → **non-breaking** (minor).
45 +- [ ] API existante modifiée ou supprimée → **breaking** (major requis + guide migration).
46 +- [ ] Nouveau comportement par défaut → évaluer l'impact sur les consommateurs, feature-flagger si risque.
47 +- [ ] Changement de schéma DB → prévoir une migration réversible.
48 +- [ ] Changement de format sérialisé → prévoir un migrateur de données persistantes existantes.
49 +
50 +## Alternatives considérées
51 +
52 +**Minimum 3 alternatives** avec analyse pros/cons, y compris "ne rien faire". Sans ça la RFC est incomplète : on ne peut pas juger une proposition sans son coût relatif.
53 +
54 +### Alternative A (recommandée) : <titre>
55 +
56 +**Pros** : …
57 +
58 +**Cons** : …
59 +
60 +### Alternative B : <titre>
61 +
62 +**Pros** : …
63 +
64 +**Cons** : …
65 +
66 +### Alternative C (ne rien faire / garder le workaround) : <titre>
67 +
68 +**Pros** : zéro coût, zéro risque.
69 +
70 +**Cons** : dette accumulée dans les apps consommatrices, risque de divergence.
71 +
72 +## Testing plan
73 +
74 +Tests unitaires obligatoires :
75 +
76 +- Happy path (au moins 1).
77 +- 3 cas d'erreur minimum (input invalide, contrainte violée, timeout/panic path).
78 +
79 +Tests d'intégration si la RFC touche :
80 +
81 +- Un service : test avec DB in-memory ou `MockDatabase` SeaORM.
82 +- Un middleware : test avec client axum fake.
83 +- Une migration : test up/down avec vérification des données existantes.
84 +
85 +Tests de non-régression :
86 +
87 +- Code consommateur pré-RFC doit encore compiler sans warning.
88 +
89 +Doctests :
90 +
91 +- Au moins 1 exemple copie/collable par item public, exécuté comme doctest.
92 +
93 +Benchs (si sensitive-perf) :
94 +
95 +- Comparaison avant/après.
96 +- Régression > 10 % bloque le merge sans justification écrite.
97 +
98 +## Migration guide
99 +
100 +Section à copier telle quelle dans le `CHANGELOG.md` au moment du merge.
101 +
102 +```markdown
103 +### API changes
104 +
105 +**Before (vX.Y.Z)** :
106 +\`\`\`rust
107 +old_api(foo, bar);
108 +\`\`\`
109 +
110 +**After (vX'.Y'.Z')** :
111 +\`\`\`rust
112 +new_api(foo, bar, NewConfig::default());
113 +\`\`\`
114 +
115 +### Migration steps
116 +
117 +1. Bump `Cargo.toml` à vX'.Y'.Z'.
118 +2. `cargo fix --edition-idioms` handles most of it.
119 +3. Manuel : pour chaque appel à `old_api`, décider si `NewConfig::default()` convient.
120 +```
121 +
122 +## Open questions
123 +
124 +Liste des points en suspens. La RFC ne peut pas être acceptée tant qu'ils ne sont pas résolus :
125 +
126 +- [ ] Question 1
127 +- [ ] Question 2
128 +
129 +## Tracking issue
130 +
131 +Lien vers l'issue GitHub où la RFC est discutée.
132 +
133 +---
134 +
135 +## Score d'évaluation (rempli par les reviewers)
136 +
137 +| Critère | Note (1-5) | Commentaire |
138 +|---|---|---|
139 +| Généralité | | |
140 +| Stabilité API | | |
141 +| Performance | | |
142 +| Sécurité | | |
143 +| Complexité d'implémentation | | |
144 +| Charge de maintenance | | |
145 +| Documentation | | |
146 +| **Total** | **/35** | (≥ 25 pour Accepted) |
A docs/rfcs/0001-fediverse-config-and-builder.md
+458 -0
@@ -0,0 +1,458 @@
1 +# RFC 0001 — Fediverse: `FediverseConfig` + `with_fediverse()` builder
2 +
3 +- **Auteur** : EBO (rustwarden)
4 +- **Date** : 2026-05-09
5 +- **Statut** : **Accepted** (auto-revue mainteneur unique 2026-05-09, score 28/35)
6 +- **Apps consommatrices concernées** : toute app héritant de `rustwarden-core` qui souhaite proposer la fédération d'identité ActivityPub (SSO cross-instance, invitations cross-instance dans les équipes).
7 +- **Plan associé** : [`docs/plans/2026-05-09-fediverse-identity-federation.md`](../plans/2026-05-09-fediverse-identity-federation.md), Phase 1.
8 +- **Implémentation** : [`docs/plans/2026-05-09-rfc-0001-implementation.md`](../plans/2026-05-09-rfc-0001-implementation.md).
9 +
10 +## Summary
11 +
12 +Introduire `FediverseConfig` (struct de configuration runtime de la fédération) et la méthode `RustwardenBuilder::with_fediverse()` qui active conditionnellement le module `fediverse` du framework, gardé derrière une nouvelle feature Cargo `experimental-fediverse`.
13 +
14 +## Motivation
15 +
16 +Le plan d'intégration de la fédération d'identité (SSO cross-instance entre apps Rustwarden + invitations team par handle `@user@domain`) nécessite un point d'entrée unique côté builder. Sans ce point d'entrée :
17 +
18 +- Aucune des autres RFCs (0002–0011) ne peut atterrir : elles dépendent toutes du fait que le module `fediverse` soit activable de façon contrôlée.
19 +- Les apps consommatrices n'ont aucun moyen de configurer le domaine fédéré, le mode trust, le worker outbound, etc., sans dupliquer la logique de configuration dans leur propre code.
20 +- Les guidelines (HTTPS obligatoire en prod, blocage placeholder, validation domaine) ne sont pas centralisées.
21 +
22 +**Conséquence si on ne fait rien** : Phase 1+ du plan inexécutable. Bloquant.
23 +
24 +**Délai** : pas d'urgence sécurité ; cette RFC ouvre simplement la voie au travail planifié.
25 +
26 +## Non-goals
27 +
28 +Cette RFC **ne couvre pas** :
29 +
30 +- Les routes HTTP de la fédération (WebFinger, NodeInfo, Acteur) → **RFC 0002**.
31 +- La table `remote_identities` et le service associé → **RFC 0003**.
32 +- Les tables `fediverse_trusted_domains` / `fediverse_blocked_domains` et le service trust → **RFC 0004**.
33 +- La génération de clés RSA et leur stockage chiffré → **RFC 0005**.
34 +- L'abstraction `Identity` et `IdentityResolver` → **RFC 0008** (Phase 3).
35 +- Le worker outbound → **RFC 0011**.
36 +- Le toggle runtime `app_settings.fediverse_enabled` (intégré dans RFC 0004 puisqu'il vit avec les settings trust).
37 +
38 +Cette RFC se concentre **uniquement** sur :
39 +
40 +1. La struct `FediverseConfig` et son chargement depuis l'environnement.
41 +2. La méthode builder `with_fediverse()`.
42 +3. La feature Cargo `experimental-fediverse` et ses dépendances.
43 +
44 +## API proposée
45 +
46 +### Feature Cargo
47 +
48 +`crates/rustwarden-core/Cargo.toml` :
49 +
50 +```toml
51 +[dependencies]
52 +# ... (existing)
53 +activitypub_federation = { version = "=0.7.0-beta.11", features = ["axum"], default-features = false, optional = true }
54 +ammonia = { version = "4", optional = true }
55 +url = { version = "2", features = ["serde"], optional = true }
56 +
57 +[features]
58 +default = []
59 +redis-session = ["tower-sessions-redis-store"]
60 +oauth = ["openidconnect", "reqwest"]
61 +experimental-fediverse = ["dep:activitypub_federation", "dep:ammonia", "dep:url"]
62 +```
63 +
64 +`crates/rustwarden-ui/Cargo.toml` :
65 +
66 +```toml
67 +[features]
68 +default = []
69 +experimental-fediverse = ["rustwarden-core/experimental-fediverse"]
70 +```
71 +
72 +### `FediverseConfig`
73 +
74 +`crates/rustwarden-core/src/config/fediverse.rs` (nouveau fichier, gated par `cfg(feature = "experimental-fediverse")`) :
75 +
76 +```rust
77 +use std::time::Duration;
78 +use url::Url;
79 +
80 +/// Runtime configuration for ActivityPub identity federation.
81 +///
82 +/// This struct is loaded from environment variables at startup via
83 +/// [`FediverseConfig::from_env`]. It is then injected into the Axum
84 +/// application via [`crate::app::RustwardenBuilder::with_fediverse`].
85 +///
86 +/// # Example
87 +///
88 +/// ```rust,ignore
89 +/// # // Doctest gated to the `experimental-fediverse` feature.
90 +/// use rustwarden_core::config::FediverseConfig;
91 +///
92 +/// // SAFETY: doctest fixture; values are stable test constants.
93 +/// unsafe {
94 +/// std::env::set_var("FEDIVERSE_DOMAIN", "example.com");
95 +/// }
96 +///
97 +/// let config = FediverseConfig::from_env().unwrap();
98 +/// assert_eq!(config.domain(), "example.com");
99 +/// assert_eq!(config.base_url().as_str(), "https://example.com/");
100 +/// ```
101 +#[derive(Debug, Clone)]
102 +pub struct FediverseConfig {
103 + domain: String,
104 + base_url: Url,
105 + user_agent: String,
106 + authorized_fetch: bool,
107 + outbound_workers: usize,
108 + outbound_max_attempts: u32,
109 + outbound_request_timeout: Duration,
110 + fetch_actor_cache_ttl: Duration,
111 + auth_challenge_ttl: Duration,
112 + debug_local_federation: bool,
113 +}
114 +
115 +/// Errors that can occur while loading or validating [`FediverseConfig`].
116 +#[derive(Debug, thiserror::Error)]
117 +pub enum FediverseConfigError {
118 + /// `FEDIVERSE_DOMAIN` environment variable is missing or empty.
119 + #[error("FEDIVERSE_DOMAIN environment variable is missing or empty")]
120 + MissingDomain,
121 +
122 + /// `FEDIVERSE_DOMAIN` value is a known placeholder (e.g. `example.com`,
123 + /// `localhost.localdomain`) and refused outside debug mode.
124 + #[error("FEDIVERSE_DOMAIN value `{0}` is a placeholder; set a real domain")]
125 + PlaceholderDomain(String),
126 +
127 + /// Computed base URL is not HTTPS while `debug_local_federation` is off.
128 + #[error("base URL `{0}` must use HTTPS in production")]
129 + InsecureBaseUrl(String),
130 +
131 + /// `FEDIVERSE_DOMAIN` cannot be parsed as a valid URL host.
132 + #[error("FEDIVERSE_DOMAIN value `{0}` is not a valid host: {1}")]
133 + InvalidDomain(String, String),
134 +
135 + /// A numeric environment variable could not be parsed.
136 + #[error("environment variable `{0}` has an invalid value: {1}")]
137 + InvalidNumeric(&'static str, String),
138 +}
139 +
140 +impl FediverseConfig {
141 + /// Load and validate a `FediverseConfig` from environment variables.
142 + ///
143 + /// Required variables:
144 + /// - `FEDIVERSE_DOMAIN` — the federated domain (no scheme, no path),
145 + /// e.g. `example.com`.
146 + ///
147 + /// Optional variables (with defaults):
148 + /// - `FEDIVERSE_AUTHORIZED_FETCH` (`false`)
149 + /// - `FEDIVERSE_OUTBOUND_WORKERS` (`4`)
150 + /// - `FEDIVERSE_OUTBOUND_MAX_ATTEMPTS` (`8`)
151 + /// - `FEDIVERSE_OUTBOUND_REQUEST_TIMEOUT_SECS` (`30`)
152 + /// - `FEDIVERSE_FETCH_ACTOR_CACHE_TTL_SECS` (`86400`)
153 + /// - `FEDIVERSE_AUTH_CHALLENGE_TTL_SECS` (`300`)
154 + /// - `FEDIVERSE_DEBUG_LOCAL_FEDERATION` (`false`) — when `true`, allows
155 + /// HTTP base URLs for local 2-instance integration tests.
156 + ///
157 + /// # Errors
158 + ///
159 + /// Returns [`FediverseConfigError`] when validation fails (missing domain,
160 + /// placeholder domain, insecure URL outside debug mode, …).
161 + pub fn from_env() -> Result<Self, FediverseConfigError> { /* … */ }
162 +
163 + /// Federated domain (no scheme, no trailing slash). Example: `example.com`.
164 + pub fn domain(&self) -> &str { &self.domain }
165 +
166 + /// Canonical base URL, always with a trailing slash.
167 + /// Example: `https://example.com/`.
168 + pub fn base_url(&self) -> &Url { &self.base_url }
169 +
170 + /// HTTP `User-Agent` to send on outbound federation requests.
171 + pub fn user_agent(&self) -> &str { &self.user_agent }
172 +
173 + /// Whether incoming `GET` requests on actor/object endpoints must be
174 + /// signed (Mastodon "secure mode").
175 + pub fn authorized_fetch(&self) -> bool { self.authorized_fetch }
176 +
177 + /// Number of concurrent worker tasks delivering outbound activities.
178 + pub fn outbound_workers(&self) -> usize { self.outbound_workers }
179 +
180 + /// Maximum delivery attempts before a job is marked `dead`.
181 + pub fn outbound_max_attempts(&self) -> u32 { self.outbound_max_attempts }
182 +
183 + /// Per-request HTTP timeout for outbound deliveries.
184 + pub fn outbound_request_timeout(&self) -> Duration { self.outbound_request_timeout }
185 +
186 + /// TTL for cached remote actor documents.
187 + pub fn fetch_actor_cache_ttl(&self) -> Duration { self.fetch_actor_cache_ttl }
188 +
189 + /// TTL for federated authentication challenges (cf. Phase 2).
190 + pub fn auth_challenge_ttl(&self) -> Duration { self.auth_challenge_ttl }
191 +
192 + /// When `true`, HTTP base URLs are allowed (for local 2-instance tests).
193 + pub fn debug_local_federation(&self) -> bool { self.debug_local_federation }
194 +}
195 +```
196 +
197 +### `RustwardenBuilder::with_fediverse()`
198 +
199 +`crates/rustwarden-core/src/app.rs` (modification, gated par `cfg(feature = "experimental-fediverse")`) :
200 +
201 +```rust
202 +impl RustwardenBuilder {
203 + /// Enable ActivityPub identity federation on the application.
204 + ///
205 + /// Loads [`FediverseConfig`] from environment variables and registers
206 + /// the federation router and outbound worker on the underlying Axum app.
207 + ///
208 + /// This method is **only available when the `experimental-fediverse`
209 + /// feature is enabled** at compile time. Without the feature, the symbol
210 + /// is not present and consumer code that calls it will fail to compile,
211 + /// which is the desired behaviour (compile-time opt-in).
212 + ///
213 + /// At runtime, the federation router additionally honours the
214 + /// `app_settings.fediverse_enabled` flag (covered by RFC 0004): when
215 + /// `false`, the routes return `404 Not Found` even though they are
216 + /// mounted.
217 + ///
218 + /// # Errors
219 + ///
220 + /// Returns [`anyhow::Error`] if [`FediverseConfig::from_env`] fails.
221 + ///
222 + /// # Example
223 + ///
224 + /// ```rust,ignore
225 + /// use rustwarden_core::app::RustwardenApp;
226 + ///
227 + /// #[tokio::main]
228 + /// async fn main() -> anyhow::Result<()> {
229 + /// RustwardenApp::builder()
230 + /// .from_env()
231 + /// .with_fediverse()? // compile-time opt-in via feature
232 + /// .build()
233 + /// .await?
234 + /// .run()
235 + /// .await
236 + /// }
237 + /// ```
238 + #[cfg(feature = "experimental-fediverse")]
239 + pub fn with_fediverse(self) -> anyhow::Result<Self> {
240 + let config = crate::config::fediverse::FediverseConfig::from_env()?;
241 + // The actual wiring (router mount, FederationMiddleware, outbound
242 + // worker spawn) is added by RFCs 0002, 0004, 0005, 0011.
243 + // For RFC 0001 we only store the config on the builder.
244 + Ok(Self {
245 + fediverse_config: Some(config),
246 + ..self
247 + })
248 + }
249 +}
250 +```
251 +
252 +### Réexport
253 +
254 +`crates/rustwarden-core/src/config/mod.rs` :
255 +
256 +```rust
257 +#[cfg(feature = "experimental-fediverse")]
258 +pub mod fediverse;
259 +
260 +#[cfg(feature = "experimental-fediverse")]
261 +pub use fediverse::{FediverseConfig, FediverseConfigError};
262 +```
263 +
264 +`crates/rustwarden-core/src/lib.rs` (prelude étendu) :
265 +
266 +```rust
267 +pub mod prelude {
268 + // ... existing re-exports
269 +
270 + #[cfg(feature = "experimental-fediverse")]
271 + pub use crate::config::FediverseConfig;
272 +}
273 +```
274 +
275 +## Analyse de compatibilité
276 +
277 +- [x] API existante non modifiée → **non-breaking** (patch/minor possible).
278 +- [x] API existante étendue avec défaut → **non-breaking** (minor) : ajout de méthode `with_fediverse()` derrière feature flag.
279 +- [ ] API existante modifiée ou supprimée → non.
280 +- [x] Nouveau comportement par défaut → la feature `experimental-fediverse` est **off par défaut**. Aucune app existante n'est affectée.
281 +- [ ] Changement de schéma DB → non (RFC 0003-0005 introduiront les tables).
282 +- [ ] Changement de format sérialisé → non.
283 +
284 +**Conclusion** : non-breaking, additif pur. Peut sortir en **minor release** (`0.x+1.0`).
285 +
286 +## Alternatives considérées
287 +
288 +### Alternative A (recommandée) — `FediverseConfig` + `with_fediverse()` + feature `experimental-fediverse`
289 +
290 +**Pros** :
291 +
292 +- Conforme au pattern existant (`oauth`, `redis-session`).
293 +- Compile-time opt-in via feature flag → coût nul pour les apps qui n'en veulent pas.
294 +- Validation centralisée (HTTPS, placeholders, parsing).
295 +- API simple, alignée avec `with_default_ui()` et autres méthodes du builder.
296 +- Permet un naming progressif `experimental-*` → `*` après stabilisation (cf. Evolution Process §5.1).
297 +
298 +**Cons** :
299 +
300 +- Ajoute 3 nouvelles dépendances optionnelles (`activitypub_federation`, `ammonia`, `url`).
301 +- Une seconde version transitive de `reqwest` (0.13 via crate AP) coexistera avec la 0.12 actuelle quand `oauth` ET `experimental-fediverse` sont activés. ~2 Mo de poids binaire. Mitigation : à ré-évaluer quand `oauth2 6` (compatible reqwest 0.13) sortira.
302 +
303 +### Alternative B — Boot fully driven by env-vars (no explicit builder method)
304 +
305 +L'intégration se ferait automatiquement dès que la feature est compilée et que `FEDIVERSE_DOMAIN` est défini, sans appel explicite à `with_fediverse()`.
306 +
307 +**Pros** :
308 +
309 +- Une étape de moins pour le consommateur.
310 +
311 +**Cons** :
312 +
313 +- Implicite : un consommateur qui active la feature par mégarde voit la fédération démarrer.
314 +- Pas de flexibilité pour avoir la feature compilée mais la désactiver à un seul endroit.
315 +- Diverge du pattern de builder utilisé partout ailleurs (`with_default_ui`, `with_admin`, …).
316 +- Les erreurs de config seraient remontées implicitement, plus difficile à diagnostiquer.
317 +
318 +### Alternative C — Configuration via TOML/YAML file
319 +
320 +Au lieu de `from_env()`, charger depuis un fichier `fediverse.toml` ou `fediverse.yaml`.
321 +
322 +**Pros** :
323 +
324 +- Configuration plus riche et versionnable.
325 +- Plus lisible pour des configs complexes.
326 +
327 +**Cons** :
328 +
329 +- Ajoute une dépendance `toml`/`serde_yaml`.
330 +- Diverge des autres configs Rustwarden (`AuthConfig`, `EmailConfig`, …) qui utilisent toutes `from_env()`.
331 +- Inutile à ce stade : la dizaine de variables qui composent la config tient parfaitement en env.
332 +
333 +### Alternative D (ne rien faire / workaround)
334 +
335 +Demander aux apps consommatrices de gérer elles-mêmes la config et l'intégration.
336 +
337 +**Pros** : zéro coût, zéro risque pour le framework.
338 +
339 +**Cons** :
340 +
341 +- Chaque app consommatrice devra réimplémenter la même logique (validation, parsing).
342 +- Aucune garantie de cohérence (une app pourrait oublier de valider HTTPS, de rejeter les placeholders).
343 +- Bloque le plan : aucune des autres RFCs ne peut s'appuyer sur une config commune.
344 +- Anti-pattern explicite (cf. Evolution Process §11.4 : "Dupliquer un service du framework dans l'app : dette certaine").
345 +
346 +## Testing plan
347 +
348 +### Tests unitaires (`crates/rustwarden-core/src/config/fediverse.rs#tests`)
349 +
350 +1. **Happy path** — `from_env_returns_valid_config_with_required_var_only` : seul `FEDIVERSE_DOMAIN` est défini, tous les autres prennent leurs défauts. La config est construite avec succès, `domain()`, `base_url()`, `outbound_workers()` etc. retournent les valeurs attendues.
351 +
352 +2. **Erreur — domain manquant** : `from_env_returns_missing_domain_when_var_unset`. Test vérifie `Err(FediverseConfigError::MissingDomain)`.
353 +
354 +3. **Erreur — domain placeholder** : `from_env_returns_placeholder_error_for_example_com`, `..._for_localhost_localdomain`. Test vérifie `Err(FediverseConfigError::PlaceholderDomain(_))`.
355 +
356 +4. **Erreur — base URL non HTTPS hors debug** : `from_env_rejects_http_base_url_in_production`. Avec `FEDIVERSE_DEBUG_LOCAL_FEDERATION=false` et un domain qui produit `http://`, attendu `Err(FediverseConfigError::InsecureBaseUrl(_))`.
357 +
358 +5. **Cas debug** — `from_env_allows_http_when_debug_local_federation` : avec `FEDIVERSE_DEBUG_LOCAL_FEDERATION=true`, http://localhost:3000 est accepté.
359 +
360 +6. **Erreur — numeric invalid** : `from_env_returns_invalid_numeric_for_bad_workers_value` (env var `FEDIVERSE_OUTBOUND_WORKERS=abc`).
361 +
362 +7. **Default** — `defaults_match_documented_values` : sans aucune variable opt-in, vérifier `outbound_workers == 4`, `max_attempts == 8`, `request_timeout == 30s`, etc.
363 +
364 +> **TDD** : ces tests sont écrits **avant** l'implémentation. Ils doivent échouer initialement (red), puis passer une fois `from_env` implémenté (green). Les tests utilisent le pattern `unsafe { std::env::set_var(…) }` + `// SAFETY:` (constitution ANSSI Rust + amendement edition 2024 réglé en Phase 0).
365 +
366 +### Doctests
367 +
368 +- Au moins 1 exemple sur `FediverseConfig` (cf. `# Example` dans la rustdoc ci-dessus).
369 +- Au moins 1 exemple sur `with_fediverse()` (idem).
370 +
371 +### Tests d'intégration
372 +
373 +- N/A à ce stade (RFC 0001 introduit la config, pas le routing). Les tests d'intégration arriveront à la RFC 0002 (WebFinger / Actor / NodeInfo).
374 +
375 +### Tests de non-régression
376 +
377 +- `cargo build --no-default-features` doit toujours passer (workspace fonctionne sans la feature `experimental-fediverse`).
378 +- `cargo build --features experimental-fediverse` (sur `rustwarden-core`) doit compiler.
379 +- Tous les tests existants doivent rester verts (`cargo test --workspace`).
380 +
381 +### Quality gates
382 +
383 +- `cargo build --workspace` ✅ 0 erreur.
384 +- `cargo test --workspace -- --test-threads=1` ✅ tous verts.
385 +- `cargo clippy --workspace --all-targets -- -D warnings` ✅ 0 warning.
386 +- `cargo fmt --check` ✅.
387 +- `cargo doc --no-deps -p rustwarden-core` ✅ 0 warning rustdoc.
388 +- Couverture sur `config/fediverse.rs` : viser 95 %+ (composant simple, validation pure).
389 +
390 +## Migration guide
391 +
392 +```markdown
393 +### Added
394 +
395 +- New optional Cargo feature `experimental-fediverse` in both `rustwarden-core`
396 + and `rustwarden-ui`. Disabled by default. Enables ActivityPub identity
397 + federation primitives.
398 +- `rustwarden_core::config::FediverseConfig` and `FediverseConfigError`
399 + (re-exported from the prelude when the feature is on).
400 +- `RustwardenBuilder::with_fediverse()` method to wire the federation into
401 + the Axum app at startup.
402 +
403 +### Migration steps
404 +
405 +For consumers who do **not** want federation: nothing to do. The feature is
406 +off by default and adds zero overhead.
407 +
408 +For consumers who want federation:
409 +
410 +1. In `Cargo.toml`, add the feature flag:
411 + \`\`\`toml
412 + rustwarden-core = { version = "X.Y.Z", features = ["experimental-fediverse"] }
413 + rustwarden-ui = { version = "X.Y.Z", features = ["experimental-fediverse"] }
414 + \`\`\`
415 +2. In `.env`, define at minimum:
416 + \`\`\`
417 + FEDIVERSE_DOMAIN=your.tld
418 + \`\`\`
419 +3. In `main.rs`, call `.with_fediverse()` on the builder:
420 + \`\`\`rust
421 + RustwardenApp::builder()
422 + .from_env()
423 + .with_fediverse()?
424 + .build().await?
425 + .run().await
426 + \`\`\`
427 +
428 +This RFC alone does not yet expose any HTTP routes — those arrive with
429 +RFC 0002 (WebFinger/Actor/NodeInfo).
430 +```
431 +
432 +## Open questions
433 +
434 +Résolues lors de l'auto-revue 2026-05-09 :
435 +
436 +- [x] **Naming feature flag** — **Décision** : `experimental-fediverse`. Cohérent avec Evolution Process §5.1 (cycle birth → adoption → promotion → cleanup) et avec la méthodologie §10/E du master plan. Promotion en `fediverse` standard à prévoir après ≥ 2 apps adoptantes ou 30 j sans bug critique.
437 +- [x] **Visibilité des champs** — **Décision** : accesseurs `pub fn` uniquement, champs privés. Permet d'évoluer vers du calcul lazy ou de la validation différée sans breaking change.
438 +- [x] **`from_env()` vs `from_env_with(prefix)`** — **Décision** : `from_env()` sans préfixe. Cohérent avec les configs existantes du framework (`AuthConfig::from_env()`, `EmailConfig::from_env()`, `ServerConfig::from_env()`).
439 +- [x] **Stockage dans `RustwardenBuilder`** — **Décision** : ajouter le champ `pub(crate) fediverse_config: Option<FediverseConfig>` au builder, gated `cfg(feature = "experimental-fediverse")`. Pas exposé publiquement.
440 +
441 +## Tracking issue
442 +
443 +À créer sur le repo upstream avec le label `rfc` une fois la RFC en `Under Review`.
444 +
445 +---
446 +
447 +## Score d'évaluation (auto-revue 2026-05-09, mainteneur unique)
448 +
449 +| Critère | Note | Commentaire |
450 +|---|---|---|
451 +| Généralité | 4 | Sert toute app héritière qui veut la fédération. Pas universel (apps SPA-only n'ont pas besoin du builder, mais peuvent toujours appeler `FediverseConfig::from_env()` directement). |
452 +| Stabilité API | 5 | Additif pur derrière feature flag off par défaut. Non-breaking. Sortable en minor. |
453 +| Performance | 5 | Zéro impact runtime si feature off (cargo n'inclut pas le code). À l'on : seulement chargement env + parsing au démarrage, négligeable. |
454 +| Sécurité | 4 | Centralise validation HTTPS, rejet placeholders, parsing URL strict. Pas de hardening net (pas de signature/auth ici), mais base saine pour les RFCs suivantes. |
455 +| Complexité d'implémentation | 5 | < 200 lignes (struct + parser env + builder method). Patterns existants (`AuthConfig::from_env()`). Aucun `unsafe`, aucun refacto. |
456 +| Charge de maintenance | 2 | 3 nouvelles deps directes optionnelles. `activitypub_federation 0.7.0-beta` est en API instable (pin strict requis, surveiller releases). `ammonia` et `url` sont stables. **Risque modéré**. |
457 +| Documentation | 3 | Rustdoc EN sur tous les `pub` prévue, 2 doctests (struct + builder method). CHANGELOG préparé dans la migration guide. Pas encore d'exemple `examples/` — à ajouter en phase ultérieure. |
458 +| **Total** | **28/35** | ≥ 25 → **Accepted** |
A docs/rfcs/0002-webfinger-actor-nodeinfo.md
+410 -0
@@ -0,0 +1,410 @@
1 +# RFC 0002 — Fediverse: WebFinger + Acteur + NodeInfo (couche découverte)
2 +
3 +- **Auteur** : EBO (rustwarden)
4 +- **Date** : 2026-05-09
5 +- **Statut** : **Accepted** (auto-revue mainteneur unique 2026-05-09, score 29/35)
6 +- **Apps consommatrices concernées** : toute app utilisant `rustwarden-core` avec la feature `experimental-fediverse` qui veut être **découvrable** depuis le Fediverse (résolution de handle `@user@domain`, lecture de profil ActivityPub, métadonnées d'instance).
7 +- **Plan associé** : [`docs/plans/2026-05-09-fediverse-identity-federation.md`](../plans/2026-05-09-fediverse-identity-federation.md), Phase 1.
8 +- **Dépendance** : RFC 0001 (`FediverseConfig`/`with_fediverse()`).
9 +- **Dépendance partielle** : RFC 0005 (`actor-key-storage`) — l'acteur expose `publicKey` comme `Option<...>` ; tant que 0005 n'est pas livrée, le champ vaut `None` et l'acteur n'est pas réellement fédérable.
10 +
11 +## Summary
12 +
13 +Exposer trois endpoints HTTP côté `rustwarden-core` qui rendent une instance Rustwarden **découvrable** sur le Fediverse : WebFinger (résolution `@user@domain` → URL d'acteur), Acteur ActivityPub (`GET /users/{u}` avec négociation de contenu `Accept`), et NodeInfo 2.1 (métadonnées d'instance). Aucune écriture, aucune signature ; uniquement de la lecture publique anonyme. Routes câblées dans le builder via `with_fediverse()` (RFC 0001).
14 +
15 +## Motivation
16 +
17 +- **Sans WebFinger**, aucun client Fediverse externe ne peut résoudre `@alice@b.tld` vers son URL d'acteur — la résolution de handles est universellement standardisée par RFC 7033.
18 +- **Sans endpoint Acteur** au format ActivityPub, Mastodon/GoToSocial/Misskey/Lemmy ne peuvent pas afficher le profil ni l'inbox cible pour livrer une activité.
19 +- **Sans NodeInfo**, les annuaires Fediverse (fediverse.observer, masto.host, fedidb) ne peuvent pas indexer l'instance.
20 +
21 +Conséquence si on ne fait rien : Phase 1 du plan inexécutable. C'est l'étape **« être trouvable »** sans laquelle les RFCs suivantes (auth, invitations, lifecycle) sont sans objet.
22 +
23 +**Délai** : pas d'urgence sécurité. Bloque la suite.
24 +
25 +## Non-goals
26 +
27 +Cette RFC **ne couvre pas** :
28 +
29 +- La génération et le stockage des clés RSA d'acteurs → **RFC 0005** (le champ `publicKey` est exposé comme `Option<...>` ici, rempli quand 0005 atterrit).
30 +- L'inbox (`POST /users/{u}/inbox`, `POST /inbox`) → **RFC 0006** (couche auth) et **RFC 0011** (worker outbound + inbox dispatch).
31 +- L'outbox paginée (`GET /users/{u}/outbox`) — exposée comme collection vide (`totalItems: 0`) à ce stade ; la pagination réelle viendra avec les flows d'invitation/auth qui produisent des activités locales.
32 +- Les collections `followers`/`following` (renvoyées comme collections vides ; remplies par RFC 0009).
33 +- La table `remote_identities` et la résolution de handles **distants** (uniquement les handles **locaux** sont résolus ici) → **RFC 0003**.
34 +- Le toggle runtime `app_settings.fediverse_enabled` → **RFC 0004** (en attendant, les routes sont toujours montées si `with_fediverse()` est appelé).
35 +- L'authorized fetch (signatures sur GET) → **RFC 0010** (production hardening).
36 +
37 +## API proposée
38 +
39 +### Module `fediverse` (création)
40 +
41 +`crates/rustwarden-core/src/fediverse/mod.rs` (nouveau, `cfg(feature = "experimental-fediverse")`) :
42 +
43 +```rust
44 +//! ActivityPub identity federation primitives (RFC 0001+).
45 +pub mod webfinger;
46 +pub mod nodeinfo;
47 +pub mod actor;
48 +pub mod types;
49 +pub mod routes;
50 +pub mod error;
51 +```
52 +
53 +### Types JSON (`fediverse::types`)
54 +
55 +`crates/rustwarden-core/src/fediverse/types.rs` :
56 +
57 +```rust
58 +use serde::{Deserialize, Serialize};
59 +use url::Url;
60 +
61 +/// ActivityPub Person actor (W3C ActivityStreams 2.0 + Security V1).
62 +#[derive(Debug, Clone, Serialize, Deserialize)]
63 +#[serde(rename_all = "camelCase")]
64 +pub struct PersonJson {
65 + #[serde(rename = "@context")]
66 + pub context: serde_json::Value,
67 + #[serde(rename = "type")]
68 + pub kind: String, // "Person"
69 + pub id: Url,
70 + pub preferred_username: String,
71 + #[serde(skip_serializing_if = "Option::is_none")]
72 + pub name: Option<String>,
73 + #[serde(skip_serializing_if = "Option::is_none")]
74 + pub summary: Option<String>,
75 + #[serde(skip_serializing_if = "Option::is_none")]
76 + pub url: Option<Url>,
77 + pub inbox: Url,
78 + pub outbox: Url,
79 + pub followers: Url,
80 + pub following: Url,
81 + /// Mastodon-compat shared inbox; always present once `with_fediverse()`
82 + /// is wired (cf. RFC 0011).
83 + #[serde(skip_serializing_if = "Option::is_none")]
84 + pub endpoints: Option<EndpointsJson>,
85 + /// Filled by RFC 0005 (`actor-key-storage`). `None` until that RFC lands.
86 + #[serde(skip_serializing_if = "Option::is_none")]
87 + pub public_key: Option<PublicKeyJson>,
88 + #[serde(default)]
89 + pub manually_approves_followers: bool,
90 + #[serde(default = "default_true")]
91 + pub discoverable: bool,
92 +}
93 +
94 +fn default_true() -> bool { true }
95 +
96 +#[derive(Debug, Clone, Serialize, Deserialize)]
97 +#[serde(rename_all = "camelCase")]
98 +pub struct PublicKeyJson {
99 + pub id: Url, // "{actor_id}#main-key"
100 + pub owner: Url, // actor_id
101 + pub public_key_pem: String,
102 +}
103 +
104 +#[derive(Debug, Clone, Serialize, Deserialize)]
105 +#[serde(rename_all = "camelCase")]
106 +pub struct EndpointsJson {
107 + pub shared_inbox: Url,
108 +}
109 +
110 +/// WebFinger JRD (RFC 7033).
111 +#[derive(Debug, Clone, Serialize, Deserialize)]
112 +pub struct WebFingerResponse {
113 + pub subject: String,
114 + #[serde(default, skip_serializing_if = "Vec::is_empty")]
115 + pub aliases: Vec<String>,
116 + pub links: Vec<WebFingerLink>,
117 +}
118 +
119 +#[derive(Debug, Clone, Serialize, Deserialize)]
120 +pub struct WebFingerLink {
121 + pub rel: String,
122 + #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
123 + pub media_type: Option<String>,
124 + #[serde(skip_serializing_if = "Option::is_none")]
125 + pub href: Option<String>,
126 + #[serde(skip_serializing_if = "Option::is_none")]
127 + pub template: Option<String>,
128 +}
129 +
130 +/// NodeInfo discovery document (`/.well-known/nodeinfo`).
131 +#[derive(Debug, Clone, Serialize, Deserialize)]
132 +pub struct NodeInfoDiscovery {
133 + pub links: Vec<NodeInfoLink>,
134 +}
135 +
136 +#[derive(Debug, Clone, Serialize, Deserialize)]
137 +pub struct NodeInfoLink {
138 + pub rel: String,
139 + pub href: String,
140 +}
141 +
142 +/// NodeInfo 2.1 (`/nodeinfo/2.1`).
143 +#[derive(Debug, Clone, Serialize, Deserialize)]
144 +#[serde(rename_all = "camelCase")]
145 +pub struct NodeInfo {
146 + pub version: String, // "2.1"
147 + pub software: NodeInfoSoftware,
148 + pub protocols: Vec<String>, // ["activitypub"]
149 + pub services: NodeInfoServices,
150 + pub usage: NodeInfoUsage,
151 + pub open_registrations: bool,
152 + #[serde(default)]
153 + pub metadata: serde_json::Value,
154 +}
155 +
156 +#[derive(Debug, Clone, Serialize, Deserialize)]
157 +pub struct NodeInfoSoftware {
158 + pub name: String, // "rustwarden"
159 + pub version: String, // CARGO_PKG_VERSION
160 + #[serde(skip_serializing_if = "Option::is_none")]
161 + pub repository: Option<String>,
162 + #[serde(skip_serializing_if = "Option::is_none")]
163 + pub homepage: Option<String>,
164 +}
165 +
166 +#[derive(Debug, Clone, Default, Serialize, Deserialize)]
167 +pub struct NodeInfoServices {
168 + #[serde(default)]
169 + pub inbound: Vec<String>,
170 + #[serde(default)]
171 + pub outbound: Vec<String>,
172 +}
173 +
174 +#[derive(Debug, Clone, Serialize, Deserialize)]
175 +#[serde(rename_all = "camelCase")]
176 +pub struct NodeInfoUsage {
177 + pub users: NodeInfoUsageUsers,
178 + pub local_posts: u64,
179 +}
180 +
181 +#[derive(Debug, Clone, Default, Serialize, Deserialize)]
182 +#[serde(rename_all = "camelCase")]
183 +pub struct NodeInfoUsageUsers {
184 + pub total: u64,
185 + pub active_month: u64,
186 + pub active_halfyear: u64,
187 +}
188 +```
189 +
190 +### Trait `LocalUserResolver`
191 +
192 +Pour découpler les handlers de SeaORM dans les tests, on introduit un trait minimal. La RFC 0003 fournira l'implémentation `RemoteIdentityService` ; la RFC 0005 enrichira `UserSummary` avec `ap_public_key_pem`. Pour l'instant :
193 +
194 +```rust
195 +use async_trait::async_trait;
196 +use uuid::Uuid;
197 +
198 +/// Minimal projection of a local user usable to render WebFinger / Person JSON.
199 +/// Will be extended by RFC 0005 (`actor-key-storage`) with `ap_public_key_pem`.
200 +#[derive(Debug, Clone)]
201 +pub struct UserSummary {
202 + pub id: Uuid,
203 + pub username: String,
204 + pub display_name: Option<String>,
205 + pub bio: Option<String>,
206 + /// `None` until RFC 0005 lands.
207 + pub ap_public_key_pem: Option<String>,
208 +}
209 +
210 +#[async_trait]
211 +pub trait LocalUserResolver: Send + Sync {
212 + /// Look up a local user by username. Returns `None` if no such user
213 + /// exists or if the user is soft-deleted / disabled.
214 + async fn find_local_user(
215 + &self,
216 + username: &str,
217 + ) -> Result<Option<UserSummary>, crate::error::AppError>;
218 +}
219 +```
220 +
221 +Une implémentation par défaut adossée à SeaORM est fournie : `SeaOrmLocalUserResolver(DatabaseConnection)`. Les tests peuvent injecter un `MockResolver` qui retourne des `UserSummary` figés.
222 +
223 +### Handlers Axum
224 +
225 +Routes câblées par `fediverse::routes::router(state)` :
226 +
227 +| Méthode | Route | Handler | Notes |
228 +|---|---|---|---|
229 +| GET | `/.well-known/webfinger` | `webfinger::handle` | Query `?resource=acct:user@domain` |
230 +| GET | `/.well-known/nodeinfo` | `nodeinfo::discovery` | Constante : un seul lien vers 2.1 |
231 +| GET | `/nodeinfo/2.1` | `nodeinfo::detail` | Métadonnées agrégées (count users) |
232 +| GET | `/users/{username}` | `actor::handle` | Négociation `Accept` |
233 +
234 +`actor::handle` lit `Accept` :
235 +- `application/activity+json` ou `application/ld+json` → JSON `PersonJson`.
236 +- `text/html` → délégué au handler UI s'il est monté ; sinon **HTTP 406 Not Acceptable**.
237 +- Aucun header → comportement default : si UI montée, HTML ; sinon AP JSON (la convention Mastodon est de retourner AP JSON par défaut quand pas d'`Accept` explicite).
238 +
239 +### Wiring dans `with_fediverse()`
240 +
241 +`crates/rustwarden-core/src/app.rs` (modification) :
242 +
243 +```rust
244 +// Dans build() :
245 +#[cfg(feature = "experimental-fediverse")]
246 +if let Some(fediverse_config) = self.fediverse_config.as_ref() {
247 + let fediverse_router = crate::fediverse::routes::router(fediverse_config.clone(), db.clone());
248 + app = app.merge(fediverse_router);
249 + tracing::info!(
250 + "ActivityPub federation routes mounted on domain={}",
251 + fediverse_config.domain()
252 + );
253 +}
254 +```
255 +
256 +## Analyse de compatibilité
257 +
258 +- [x] API existante non modifiée → **non-breaking**.
259 +- [x] API existante étendue avec défaut → **non-breaking** : nouveau module gated par feature.
260 +- [ ] API existante modifiée ou supprimée → non.
261 +- [x] Nouveau comportement par défaut → routes ajoutées **uniquement** si `with_fediverse()` est appelé. Les apps qui activent la feature mais n'appellent pas la méthode ne voient aucune route ajoutée.
262 +- [ ] Changement de schéma DB → non (RFC 0005 introduira les colonnes AP sur `users`).
263 +- [x] Nouveau format sérialisé → **oui** (PersonJson, WebFinger JRD, NodeInfo). Format public consommé par tiers, donc breaking change si modifié plus tard. **Pin SemVer strict** sur les noms de champs camelCase dès le départ.
264 +
265 +## Alternatives considérées
266 +
267 +### Alternative A (recommandée) — Routes côté `rustwarden-core` + trait `LocalUserResolver`
268 +
269 +Routes natives dans le crate core, découplées de SeaORM via un trait. Câblage automatique par `with_fediverse()`.
270 +
271 +**Pros** :
272 +- Tests unitaires faciles (mock du resolver).
273 +- L'app consommatrice n'a rien à coder pour exposer la découverte.
274 +- Pattern aligné sur les autres modules (`auth`, `services`).
275 +
276 +**Cons** :
277 +- 1 trait de plus dans la surface publique (mineur).
278 +
279 +### Alternative B — Routes côté `rustwarden-ui` uniquement
280 +
281 +Servir WebFinger/Acteur/NodeInfo depuis `rustwarden-ui`.
282 +
283 +**Pros** :
284 +- Une apps API-only (sans UI) n'aurait pas ces routes (sympa pour les services internes).
285 +
286 +**Cons** :
287 +- Casse le contrat « `rustwarden-core` exploitable headless » du plan §4.1. Les apps SPA qui veulent être discoverable dans le Fediverse seraient forcées de tirer `rustwarden-ui`.
288 +- WebFinger et NodeInfo n'ont **rien** de UI — ce sont des endpoints de découverte JSON.
289 +- **Rejeté**.
290 +
291 +### Alternative C — Acteur dans `rustwarden-core`, mais WebFinger dans une crate à part
292 +
293 +Séparer la couche découverte AP en un crate `rustwarden-fediverse` distinct.
294 +
295 +**Pros** :
296 +- Encore plus modulaire.
297 +
298 +**Cons** :
299 +- Surcoût d'un crate supplémentaire pour ~1500 lignes.
300 +- Aujourd'hui un seul consommateur (`rustwarden-core` lui-même via la feature). Le surcoût n'est pas justifié.
301 +- Si demain un cas d'usage l'impose (mainteneur séparé), facile de migrer plus tard.
302 +- **Rejeté pour l'instant**.
303 +
304 +### Alternative D (ne rien faire)
305 +
306 +Demander aux apps consommatrices de coder elles-mêmes les routes.
307 +
308 +**Cons** :
309 +- Chaque app ré-implémenterait WebFinger/Acteur/NodeInfo avec risque d'incompatibilité Mastodon.
310 +- Annule l'intérêt de la fédération « clé en main » du framework.
311 +- **Rejeté**.
312 +
313 +## Testing plan
314 +
315 +### Tests unitaires (à écrire AVANT l'implémentation — RED phase)
316 +
317 +`crates/rustwarden-core/src/fediverse/webfinger.rs#tests` :
318 +
319 +1. **Happy path** — `webfinger_returns_jrd_for_existing_local_user` : resolver mock renvoie un user, vérifie la réponse JRD avec `subject`, `aliases`, `links[rel=self]` pointant vers `https://{domain}/users/{username}`.
320 +2. **Erreur — user inconnu** — `webfinger_returns_404_for_unknown_user` : resolver renvoie `None` → HTTP 404.
321 +3. **Erreur — domaine inconnu** — `webfinger_returns_404_when_domain_mismatches` : resource demande `@user@other.tld` alors que `FEDIVERSE_DOMAIN=local.tld` → 404.
322 +4. **Erreur — resource malformée** — `webfinger_returns_400_for_missing_resource_param`, `..._for_resource_without_acct_prefix`, `..._for_resource_without_at_separator` (3 sous-cas).
323 +5. **CORS** — `webfinger_response_has_cors_star` : header `Access-Control-Allow-Origin: *` présent (RFC 7033 §3.2 + obligation Mastodon).
324 +
325 +`crates/rustwarden-core/src/fediverse/actor.rs#tests` :
326 +
327 +6. **Happy path AP JSON** — `actor_returns_person_json_with_ap_accept` : `GET /users/alice` avec `Accept: application/activity+json` → 200, `Content-Type: application/activity+json`, JSON Person valide (id, preferredUsername, inbox, outbox, followers, following, endpoints.shared_inbox). `public_key` est `None` (RFC 0005 pendante).
328 +7. **Happy path LD JSON** — `actor_returns_person_json_with_ld_json_accept` : variante avec `Accept: application/ld+json; profile="..."` → 200 idem.
329 +8. **Erreur — user inconnu** — `actor_returns_404_for_unknown_user` : 404.
330 +9. **Negotiation — HTML sans UI montée** — `actor_returns_406_for_html_when_no_ui_handler` : `Accept: text/html` mais pas de UI → 406 Not Acceptable.
331 +10. **Default Accept** — `actor_default_accept_returns_ap_json` : aucun `Accept` → AP JSON (convention Mastodon).
332 +
333 +`crates/rustwarden-core/src/fediverse/nodeinfo.rs#tests` :
334 +
335 +11. **Discovery** — `nodeinfo_discovery_links_to_2_1` : `GET /.well-known/nodeinfo` → JSON avec `links[0].rel == "http://nodeinfo.diaspora.software/ns/schema/2.1"`, `links[0].href == "https://{domain}/nodeinfo/2.1"`.
336 +12. **Detail** — `nodeinfo_detail_returns_software_metadata` : `GET /nodeinfo/2.1` → JSON avec `software.name = "rustwarden"`, `software.version = CARGO_PKG_VERSION`, `protocols = ["activitypub"]`, usage stats (mocked).
337 +13. **Usage stats** — `nodeinfo_detail_counts_users_via_resolver` : resolver fournit total/active_month/active_halfyear, vérifier qu'ils apparaissent dans la réponse.
338 +
339 +### Doctests
340 +
341 +- 1 sur `PersonJson` (montrer la structure typique).
342 +- 1 sur `webfinger::handle` (rust,ignore — nécessite un Axum context).
343 +
344 +### Tests d'intégration
345 +
346 +- `tests/integration/fediverse_discovery.rs` (`#[ignore]`, lance le binaire avec DB Docker) : `curl -H 'Accept: application/activity+json' /users/admin` → JSON valide. WebFinger : `curl '.well-known/webfinger?resource=acct:admin@local.tld'` → JRD valide.
347 +- À ajouter quand RFC 0005 atterrira : test « Mastodon Docker résout `@admin@local.tld` ».
348 +
349 +### Quality gates
350 +
351 +Mêmes que RFC 0001 :
352 +- `cargo build --workspace` (sans feature) → OK
353 +- `cargo build --workspace --features rustwarden-core/experimental-fediverse,rustwarden-ui/experimental-fediverse` → OK
354 +- `cargo test --workspace --features ... -- --test-threads=1` → tous verts (171 + 13 = 184 tests)
355 +- `cargo clippy --workspace --all-targets --features ... -- -D warnings` → 0
356 +- `cargo fmt --check` → OK
357 +- `cargo doc --no-deps -p rustwarden-core --features experimental-fediverse` → 0 warning rustdoc
358 +- Coverage cible : ≥ 90 % sur `webfinger`, `nodeinfo`, `actor` (composants exposés publiquement, surface réduite).
359 +
360 +## Migration guide
361 +
362 +```markdown
363 +### Added (post RFC 0002)
364 +
365 +- HTTP routes (added by `with_fediverse()`):
366 + - `GET /.well-known/webfinger` (CORS `*`)
367 + - `GET /.well-known/nodeinfo`
368 + - `GET /nodeinfo/2.1`
369 + - `GET /users/{username}` (content negotiation: AP JSON or HTML if UI mounted)
370 +- Public types: `rustwarden_core::fediverse::types::{PersonJson, PublicKeyJson, WebFingerResponse, NodeInfo, ...}`
371 +- Trait: `rustwarden_core::fediverse::LocalUserResolver` (default impl `SeaOrmLocalUserResolver` provided).
372 +
373 +### Migration steps
374 +
375 +For consumers already on `with_fediverse()` (RFC 0001): nothing to do — the
376 +new routes are mounted automatically when `with_fediverse()` is called and the
377 +config is valid. Verify your reverse proxy forwards `/.well-known/webfinger`
378 +and `/.well-known/nodeinfo` to the Rustwarden process (some proxies block
379 +`.well-known` paths by default).
380 +
381 +For consumers wiring custom routes that conflict with `/users/{username}`:
382 +this RFC's actor handler delegates to the UI handler when `Accept: text/html`,
383 +so existing HTML profile pages should keep working when both are mounted.
384 +```
385 +
386 +## Open questions
387 +
388 +- [ ] **Comportement par défaut sans `Accept`** — choix : AP JSON (convention Mastodon) ou redirection vers HTML (UX humaine) ? Recommandation : AP JSON par défaut, sauf si la feature UI est compilée → délégation HTML. À ratifier.
389 +- [ ] **`open_registrations` dans NodeInfo** — exposer la valeur de `app_settings.allow_signup` ? Recommandation : oui, dériver dynamiquement.
390 +- [ ] **`active_month` / `active_halfyear`** — basés sur `users.last_login_at` (déjà présent). Confirmer le critère exact (≥ 1 login dans la fenêtre).
391 +- [ ] **`@context` JSON-LD** — émettre `["activitystreams", "security/v1", { "toot": ... }]` (étendu Mastodon) dès le départ ou démarrer minimal ? Recommandation : étendu pour interop maximale.
392 +
393 +## Tracking issue
394 +
395 +À créer sur le repo upstream avec le label `rfc` une fois la RFC en `Under Review`.
396 +
397 +---
398 +
399 +## Score d'évaluation (auto-revue 2026-05-09, mainteneur unique)
400 +
401 +| Critère | Note | Commentaire |
402 +|---|---|---|
403 +| Généralité | 5 | Sert toute app héritière qui veut être discoverable Fediverse. AP/NodeInfo sont les standards de facto. |
404 +| Stabilité API | 4 | Additif via feature flag, MAIS nouveau format sérialisé public → engagement SemVer strict sur les noms camelCase. Atténuation : feature `experimental-*` permet du breaking pré-stabilisation. |
405 +| Performance | 5 | Routes JSON pures, lecture seule, négligeable. NodeInfo cache optionnel à terme. |
406 +| Sécurité | 5 | Read-only public anonyme. Pas de surface d'attaque nouvelle. CORS `*` sur WebFinger (par spec RFC 7033). |
407 +| Complexité d'implémentation | 3 | ~400-500 lignes (4 handlers + types serde + trait resolver + ~13 tests). Touche `app.rs` pour le wiring. Patterns Axum/serde standards. |
408 +| Charge de maintenance | 3 | Formats AP/NodeInfo stables ; quelques évolutions Mastodon à surveiller (champs `discoverable`, `indexable`, `featured`...). Trait `LocalUserResolver` à maintenir. |
409 +| Documentation | 4 | Rustdoc EN sur tous les `pub`, doctests, CHANGELOG, migration guide explicite. |
410 +| **Total** | **29/35** | ≥ 25 → **Accepted** |
A docs/rfcs/0003-remote-identity-store.md
+345 -0
@@ -0,0 +1,345 @@
1 +# RFC 0003 — Fediverse: Stockage des identités distantes + résolution `@user@domain`
2 +
3 +- **Auteur** : EBO (rustwarden)
4 +- **Date** : 2026-05-09
5 +- **Statut** : **Accepted** (auto-revue mainteneur unique 2026-05-09, score 26/35)
6 +- **Apps consommatrices concernées** : toute app utilisant `rustwarden-core` avec la feature `experimental-fediverse` qui doit interagir avec des utilisateurs Fediverse externes (consulter un profil distant, l'inviter dans une équipe, mentionner par handle).
7 +- **Plan associé** : [`docs/plans/2026-05-09-fediverse-identity-federation.md`](../plans/2026-05-09-fediverse-identity-federation.md), Phase 1.
8 +- **Dépendances** : RFC 0001 (`FediverseConfig`), RFC 0002 (types `WebFingerResponse`, `PersonJson`).
9 +
10 +## Summary
11 +
12 +Introduire la table `remote_identities` (cache local des acteurs Fediverse distants), son entité SeaORM, et un service `RemoteIdentityService` qui orchestre la résolution d'un handle `@user@domain` via WebFinger + fetch d'acteur, avec cache TTL configurable. La couche HTTP est abstraite derrière le trait `RemoteFetcher` pour permettre des tests unitaires propres.
13 +
14 +## Motivation
15 +
16 +- Sans cache local, chaque interaction avec un utilisateur distant nécessiterait de re-faire WebFinger + fetch d'acteur (~ 2 round-trips HTTPS), inacceptable au-delà de quelques utilisateurs.
17 +- Sans table dédiée, les RFCs 0008 (`Identity`) et 0009 (invitations cross-instance) n'auraient nulle part où pointer leurs FK pour les identités distantes.
18 +- Sans abstraction HTTP, les tests des services dépendraient du réseau et des instances Mastodon réelles.
19 +
20 +**Conséquence si on ne fait rien** : les invitations cross-instance (Phase 3) et les signaux de lifecycle (Phase 4) sont bloqués, faute d'identifiant interne stable pour les utilisateurs distants.
21 +
22 +## Non-goals
23 +
24 +- Pas de migration de schéma sur `users` — RFC 0005 fera ça pour les clés AP des utilisateurs **locaux**.
25 +- Pas de FK sibling sur `team_members` / `resource_shares` — RFC 0008.
26 +- Pas de modèle de confiance (allowlist/blocklist appliquée avant fetch) — RFC 0004 ; cette RFC suppose que la résolution est autorisée et laisse la vérification trust aux RFCs suivantes (les services exposent juste des hooks pour brancher la vérification).
27 +- Pas d'authorized fetch (signatures sur GET sortants) — RFC 0010.
28 +- Pas de webhook ou d'invalidation push du cache — TTL pull-based uniquement.
29 +
30 +## API proposée
31 +
32 +### Migration `m20260510_000009_remote_identities`
33 +
34 +`crates/rustwarden-core/migrations/src/m20260510_000009_remote_identities.rs` :
35 +
36 +```rust
37 +use sea_orm_migration::prelude::*;
38 +
39 +#[derive(DeriveMigrationName)]
40 +pub struct Migration;
41 +
42 +#[async_trait::async_trait]
43 +impl MigrationTrait for Migration {
44 + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
45 + manager.create_table(
46 + Table::create().table(RemoteIdentities::Table)
47 + .col(ColumnDef::new(RemoteIdentities::Id).uuid().not_null().primary_key())
48 + .col(ColumnDef::new(RemoteIdentities::ApId).text().not_null().unique_key())
49 + .col(ColumnDef::new(RemoteIdentities::Username).text().not_null())
50 + .col(ColumnDef::new(RemoteIdentities::Domain).text().not_null())
51 + .col(ColumnDef::new(RemoteIdentities::DisplayName).text().null())
52 + .col(ColumnDef::new(RemoteIdentities::SummaryHtml).text().null())
53 + .col(ColumnDef::new(RemoteIdentities::InboxUrl).text().not_null())
54 + .col(ColumnDef::new(RemoteIdentities::SharedInboxUrl).text().null())
55 + .col(ColumnDef::new(RemoteIdentities::PublicKeyPem).text().not_null())
56 + .col(ColumnDef::new(RemoteIdentities::IconUrl).text().null())
57 + .col(ColumnDef::new(RemoteIdentities::ActorType).text().not_null().default("Person"))
58 + .col(ColumnDef::new(RemoteIdentities::RawJson).json_binary().null())
59 + .col(ColumnDef::new(RemoteIdentities::LastFetchedAt).timestamp_with_time_zone().not_null())
60 + .col(ColumnDef::new(RemoteIdentities::DisabledAt).timestamp_with_time_zone().null())
61 + .col(ColumnDef::new(RemoteIdentities::CreatedAt).timestamp_with_time_zone().not_null())
62 + .col(ColumnDef::new(RemoteIdentities::UpdatedAt).timestamp_with_time_zone().not_null())
63 + .to_owned()
64 + ).await?;
65 +
66 + manager.create_index(
67 + Index::create()
68 + .table(RemoteIdentities::Table)
69 + .name("uniq_remote_identities_handle")
70 + .col(RemoteIdentities::Username).col(RemoteIdentities::Domain)
71 + .unique().to_owned()
72 + ).await?;
73 +
74 + Ok(())
75 + }
76 + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
77 + manager.drop_table(Table::drop().table(RemoteIdentities::Table).to_owned()).await
78 + }
79 +}
80 +```
81 +
82 +### Entité SeaORM
83 +
84 +`crates/rustwarden-core/src/models/remote_identity.rs` :
85 +
86 +```rust
87 +use chrono::{DateTime, Utc};
88 +use sea_orm::entity::prelude::*;
89 +use serde::{Deserialize, Serialize};
90 +use uuid::Uuid;
91 +
92 +#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)]
93 +#[sea_orm(table_name = "remote_identities")]
94 +pub struct Model {
95 + #[sea_orm(primary_key)]
96 + pub id: Uuid,
97 + #[sea_orm(unique)]
98 + pub ap_id: String,
99 + pub username: String,
100 + pub domain: String,
101 + pub display_name: Option<String>,
102 + pub summary_html: Option<String>,
103 + pub inbox_url: String,
104 + pub shared_inbox_url: Option<String>,
105 + pub public_key_pem: String,
106 + pub icon_url: Option<String>,
107 + pub actor_type: String,
108 + pub raw_json: Option<serde_json::Value>,
109 + pub last_fetched_at: DateTime<Utc>,
110 + pub disabled_at: Option<DateTime<Utc>>,
111 + pub created_at: DateTime<Utc>,
112 + pub updated_at: DateTime<Utc>,
113 +}
114 +
115 +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
116 +pub enum Relation {}
117 +
118 +impl ActiveModelBehavior for ActiveModel {}
119 +```
120 +
121 +### Trait `RemoteFetcher` + impl par défaut
122 +
123 +`crates/rustwarden-core/src/fediverse/fetcher.rs` (nouveau) :
124 +
125 +```rust
126 +use async_trait::async_trait;
127 +use url::Url;
128 +
129 +use super::error::FediverseError;
130 +use super::types::{PersonJson, WebFingerResponse};
131 +
132 +/// Abstracts HTTP I/O so the service layer is unit-testable.
133 +#[async_trait]
134 +pub trait RemoteFetcher: Send + Sync {
135 + async fn webfinger(&self, handle: &str) -> Result<WebFingerResponse, FediverseError>;
136 + async fn fetch_actor(&self, actor_url: &Url) -> Result<PersonJson, FediverseError>;
137 +}
138 +
139 +/// Default implementation backed by `reqwest 0.13` (transitively pulled by
140 +/// `activitypub_federation`). Honours timeouts from `FediverseConfig`.
141 +pub struct ReqwestRemoteFetcher {
142 + client: reqwest::Client,
143 + user_agent: String,
144 +}
145 +
146 +impl ReqwestRemoteFetcher {
147 + pub fn new(config: &crate::config::FediverseConfig) -> Result<Self, FediverseError> { /* … */ }
148 +}
149 +
150 +#[async_trait]
151 +impl RemoteFetcher for ReqwestRemoteFetcher {
152 + async fn webfinger(&self, handle: &str) -> Result<WebFingerResponse, FediverseError> { /* … */ }
153 + async fn fetch_actor(&self, actor_url: &Url) -> Result<PersonJson, FediverseError> { /* … */ }
154 +}
155 +```
156 +
157 +### Service
158 +
159 +`crates/rustwarden-core/src/services/remote_identity_service.rs` (nouveau) :
160 +
161 +```rust
162 +use std::sync::Arc;
163 +use chrono::{DateTime, Utc};
164 +use sea_orm::DatabaseConnection;
165 +use uuid::Uuid;
166 +
167 +use crate::error::AppError;
168 +use crate::fediverse::error::FediverseError;
169 +use crate::fediverse::fetcher::RemoteFetcher;
170 +use crate::fediverse::types::PersonJson;
171 +use crate::models::remote_identity;
172 +
173 +pub struct RemoteIdentityService {
174 + db: DatabaseConnection,
175 + fetcher: Arc<dyn RemoteFetcher>,
176 + cache_ttl_secs: u64,
177 +}
178 +
179 +impl RemoteIdentityService {
180 + pub fn new(
181 + db: DatabaseConnection,
182 + fetcher: Arc<dyn RemoteFetcher>,
183 + config: &crate::config::FediverseConfig,
184 + ) -> Self {
185 + Self {
186 + db,
187 + fetcher,
188 + cache_ttl_secs: config.fetch_actor_cache_ttl().as_secs(),
189 + }
190 + }
191 +
192 + /// Resolve a `@user@domain` handle, returning the stored row.
193 + /// Hits the cache first; on miss or stale, fetches via WebFinger + actor URL.
194 + pub async fn resolve_handle(&self, handle: &str) -> Result<remote_identity::Model, FediverseError> { /* … */ }
195 +
196 + /// Direct DB lookup by handle (no fetch).
197 + pub async fn find_by_handle(&self, username: &str, domain: &str) -> Result<Option<remote_identity::Model>, AppError> { /* … */ }
198 +
199 + /// Direct DB lookup by AP id (no fetch).
200 + pub async fn find_by_uri(&self, ap_id: &str) -> Result<Option<remote_identity::Model>, AppError> { /* … */ }
201 +
202 + /// Insert or update a remote identity from a freshly fetched `PersonJson`.
203 + pub async fn upsert_from_person(&self, person: PersonJson, expected_domain: &str) -> Result<remote_identity::Model, FediverseError> { /* … */ }
204 +
205 + /// Mark a remote identity as disabled (used by `Delete{Actor}` in RFC 0010).
206 + pub async fn mark_disabled(&self, id: Uuid) -> Result<(), AppError> { /* … */ }
207 +
208 + /// Helper: is this row past the configured cache TTL?
209 + pub fn is_stale(&self, last_fetched_at: DateTime<Utc>) -> bool { /* … */ }
210 +}
211 +```
212 +
213 +### Câblage
214 +
215 +- `crates/rustwarden-core/src/models/mod.rs` : ajouter `pub mod remote_identity;` (gated `experimental-fediverse`).
216 +- `crates/rustwarden-core/src/services/mod.rs` : ajouter `pub mod remote_identity_service;` (gated).
217 +- `crates/rustwarden-core/src/fediverse/mod.rs` : ajouter `pub mod fetcher;` + reexport `pub use fetcher::{RemoteFetcher, ReqwestRemoteFetcher};`.
218 +- `crates/rustwarden-core/migrations/src/lib.rs` : enregistrer la nouvelle migration dans le `Migrator`.
219 +- Pas de changement à `with_fediverse()` à ce stade — le service est instanciable manuellement par les RFCs suivantes.
220 +
221 +## Analyse de compatibilité
222 +
223 +- [x] API existante non modifiée → **non-breaking**.
224 +- [x] Nouveau item `pub` derrière feature flag → minor.
225 +- [x] Changement de schéma DB → **oui**, nouvelle table. Migration up/down réversible.
226 +- [ ] Changement de format sérialisé → non.
227 +- [x] Nouveau comportement par défaut → la table est créée à la migration mais inutilisée tant que personne n'instancie le service.
228 +
229 +## Alternatives considérées
230 +
231 +### Alternative A (recommandée) — Service + trait `RemoteFetcher`
232 +
233 +Service en tant que struct concrète (pas trait), HTTP abstrait derrière un trait pour les tests.
234 +
235 +**Pros** : balance pragmatique entre testabilité (mock fetcher) et simplicité (service direct, pas un trait de plus).
236 +
237 +**Cons** : aucun majeur.
238 +
239 +### Alternative B — `activitypub_federation::ObjectId<RemoteIdentity>` natif
240 +
241 +Utiliser le système d'`ObjectId` de la crate AP qui gère cache et fetch automatiquement.
242 +
243 +**Pros** : moins de code custom.
244 +
245 +**Cons** :
246 +- Couple le modèle métier au design de la crate AP (changements API en 0.7-beta).
247 +- Le cache de la crate AP est in-memory (`moka`), pas DB-persistent. On veut la persistance.
248 +- Le contrat `Object::read_from_id` impose une signature qu'on ne peut pas adapter à notre table.
249 +
250 +**Rejeté** — on garde notre propre cache DB-backed, on appellera ponctuellement les helpers de la crate AP pour les signatures HTTP plus tard (RFC 0010).
251 +
252 +### Alternative C — Service générique sans abstraction HTTP
253 +
254 +Pas de trait `RemoteFetcher`, appel direct à `reqwest`.
255 +
256 +**Pros** : un trait de moins.
257 +
258 +**Cons** : tests unitaires impossibles sans réseau / serveur factice. Casse le pattern « tests inline avec stubs » utilisé partout dans Rustwarden.
259 +
260 +**Rejeté**.
261 +
262 +### Alternative D — Ne rien faire
263 +
264 +Bloque RFC 0008 et 0009. **Rejeté**.
265 +
266 +## Testing plan
267 +
268 +### Tests unitaires (à écrire AVANT — RED phase)
269 +
270 +Service tests utilisent `sea_orm::MockDatabase` (pattern obs S18) ou un `DatabaseConnection` Postgres réel selon le test :
271 +
272 +1. **`is_stale_returns_false_within_ttl`** — pure helper, pas de DB.
273 +2. **`is_stale_returns_true_past_ttl`** — pure helper.
274 +3. **`upsert_from_person_inserts_new_when_uri_unknown`** — MockDatabase, vérifie INSERT généré.
275 +4. **`upsert_from_person_updates_existing_record`** — MockDatabase avec ligne existante.
276 +5. **`upsert_from_person_rejects_domain_mismatch`** — `expected_domain != json.id.host()` → `FediverseError::DomainMismatch`.
277 +6. **`find_by_uri_returns_none_when_absent`** — MockDatabase vide.
278 +7. **`find_by_handle_returns_none_when_absent`** — idem.
279 +8. **`mark_disabled_sets_disabled_at`** — MockDatabase, vérifie UPDATE.
280 +9. **`resolve_handle_returns_cached_when_fresh`** — stub fetcher qui panic si appelé ; l'appel doit retourner depuis le cache.
281 +10. **`resolve_handle_calls_fetcher_when_stale`** — stub fetcher renvoie un Person, vérifie qu'il est upserted.
282 +11. **`resolve_handle_propagates_webfinger_404`** — stub fetcher renvoie `UserNotFound`, l'erreur remonte.
283 +12. **`resolve_handle_returns_error_for_invalid_handle_format`** — `"not-a-handle"` → `InvalidResource`.
284 +
285 +Doctests : 1 sur `RemoteIdentityService::resolve_handle` (rust,ignore — nécessite DB), 1 sur `RemoteFetcher` (trait doc).
286 +
287 +### Quality gates
288 +
289 +Mêmes que RFC 0001/0002.
290 +
291 +Coverage cible : ≥ 90 % sur `remote_identity_service` (composant central, beaucoup de chemins d'erreur).
292 +
293 +### Tests d'intégration (post-implémentation, `#[ignore]`)
294 +
295 +- Lancer une instance Mastodon Docker, résoudre `@admin@masto-test.local` depuis Rustwarden → vérifier que la ligne `remote_identities` est insérée avec les bons champs.
296 +- Re-résoudre dans la même fenêtre TTL → vérifier qu'**aucun** appel HTTP n'est fait (compteur sur le fetcher mock).
297 +
298 +## Migration guide
299 +
300 +```markdown
301 +### Added (post RFC 0003)
302 +
303 +- New SeaORM migration `m20260510_000009_remote_identities`. Creates the
304 + `remote_identities` table (cache for remote ActivityPub actors).
305 +- Public types: `rustwarden_core::models::remote_identity::Model`,
306 + `rustwarden_core::services::remote_identity_service::RemoteIdentityService`,
307 + `rustwarden_core::fediverse::{RemoteFetcher, ReqwestRemoteFetcher}`.
308 +
309 +### Migration steps
310 +
311 +For consumers using `with_fediverse()` and `auto_migrate(true)`: nothing to do
312 +— the migration runs automatically.
313 +
314 +For consumers managing migrations manually: run
315 +`cargo run --bin migrate` (or the equivalent SeaORM command) to create the
316 +new table.
317 +
318 +No public API breakage — existing routes and types are unchanged.
319 +```
320 +
321 +## Open questions
322 +
323 +- [ ] **`raw_json`** : faut-il imposer `NOT NULL` ou laisser `NULL` pour les acteurs résolus avant que l'on conserve le JSON brut (futur retraitement) ? Recommandation : nullable au début, `NOT NULL` plus tard.
324 +- [ ] **TTL** : valeur par défaut 86 400 s (24 h, alignée avec Mastodon). À confirmer.
325 +- [ ] **`disabled_at` vs DELETE** : on garde `disabled_at` (tombstone soft-delete) plutôt qu'une suppression dure pour préserver l'audit trail. Confirmé.
326 +- [ ] **WebFinger via shared inbox shortcut** : si l'IdP a déjà publié `aliases` pointant vers `https://other.tld/users/X`, on peut shortcuter le second fetch ? Recommandation : oui, mais validation stricte du domaine après.
327 +
328 +## Tracking issue
329 +
330 +À créer sur le repo upstream avec le label `rfc` une fois la RFC en `Under Review`.
331 +
332 +---
333 +
334 +## Score d'évaluation (auto-revue 2026-05-09)
335 +
336 +| Critère | Note | Commentaire |
337 +|---|---|---|
338 +| Généralité | 4 | Sert toute app fédérée ; reste spécifique au cas d'usage AP. |
339 +| Stabilité API | 4 | Additif via feature, mais migration DB introduit du schéma public engagé pour la durée de vie de la feature. |
340 +| Performance | 4 | Cache TTL 24 h évite les round-trips répétés. Trait `RemoteFetcher` permet d'optimiser plus tard (HTTP/2, connection pooling) sans casser l'API. |
341 +| Sécurité | 4 | Domain validation lors de l'upsert empêche le spoofing trivial. La vérification trust (allowlist/blocklist) est laissée à RFC 0004 — nécessitera un hook ici plus tard. |
342 +| Complexité d'implémentation | 3 | ~600 lignes (entité + migration + service + trait + impl reqwest + ~12 tests). Touche plusieurs modules. |
343 +| Charge de maintenance | 3 | Schéma DB stable. Le format JSON ActivityPub évolue dans Mastodon ; on reste tolérant en parsage (`raw_json` conserve l'original). |
344 +| Documentation | 4 | Rustdoc EN sur tous les `pub`, doctests, CHANGELOG, migration guide. |
345 +| **Total** | **26/35** | ≥ 25 → **Accepted** |
A docs/rfcs/0004-fediverse-trust-model.md
+149 -0
@@ -0,0 +1,149 @@
1 +# RFC 0004 — Fediverse: modèle de confiance (allowlist / blocklist + scope d'interop)
2 +
3 +- **Auteur** : EBO (rustwarden)
4 +- **Date** : 2026-05-09
5 +- **Statut** : **Accepted** (auto-revue mainteneur unique 2026-05-09, score 27/35)
6 +- **Apps consommatrices concernées** : toute app utilisant `rustwarden-core` avec la feature `experimental-fediverse` qui a besoin de contrôler **quels domaines** Fediverse peuvent interagir avec elle (auth cross-instance, invitations, signaux de lifecycle).
7 +- **Plan associé** : [`docs/plans/2026-05-09-fediverse-identity-federation.md`](../plans/2026-05-09-fediverse-identity-federation.md), Phase 1.
8 +- **Dépendances** : RFC 0001 (`FediverseConfig`).
9 +
10 +## Summary
11 +
12 +Introduire le modèle de confiance configurable par l'admin : table `fediverse_trusted_domains` (allowlist), table `fediverse_blocked_domains` (blocklist + sévérité), entrées dans `app_settings` (`fediverse_enabled`, `fediverse_trust_mode`, `fediverse_interop_scope`, `fediverse_authorized_fetch`, `fediverse_node_name`, `fediverse_node_description`), et service `FediverseTrustService` exposant le check central `is_domain_allowed(domain)` que les RFCs suivantes appliqueront **avant** toute opération avec un domaine distant.
13 +
14 +## Motivation
15 +
16 +Sans modèle de confiance, n'importe quelle instance Fediverse peut authentifier ses utilisateurs ou être ajoutée à des équipes — surface d'attaque inacceptable. Le master plan §2.1 a tranché : configuration runtime par admin entre `allowlist` (zero-trust par défaut) ou `open_with_blocklist` (modèle Mastodon-classique).
17 +
18 +Sans cette RFC, RFC 0006 (auth AP-native) n'a pas de check à appliquer et tout domaine peut tenter une connexion.
19 +
20 +## Non-goals
21 +
22 +- Pas de check signature HTTP — RFC 0010.
23 +- Pas de UI admin — `rustwarden-ui` ajoutera plus tard les écrans `/admin/fediverse/...`.
24 +- Pas de monitoring / métriques sur les rejets — différé.
25 +- Pas de propagation transitive (A trust B, B trust C ⇒ A trust C) — explicite seulement.
26 +
27 +## API proposée
28 +
29 +### Migration `m20260510_000010_fediverse_trust_and_blocks`
30 +
31 +- Table `fediverse_trusted_domains` : `id UUID PK`, `domain TEXT UNIQUE NOT NULL`, `note TEXT NULL`, `added_by_user_id UUID NULL` (nullable car cycle de bootstrap), `created_at TIMESTAMPTZ NOT NULL`.
32 +- Table `fediverse_blocked_domains` : `id UUID PK`, `domain TEXT UNIQUE NOT NULL`, `severity TEXT NOT NULL DEFAULT 'suspend'` (`suspend`/`silence`/`reject_auth`), `reason TEXT NULL`, `added_by_user_id UUID NULL`, `created_at TIMESTAMPTZ NOT NULL`.
33 +- Insertions `app_settings` (clé/valeur, tous nullable au niveau lecture) :
34 + - `fediverse_enabled = false`
35 + - `fediverse_trust_mode = allowlist`
36 + - `fediverse_interop_scope = rustwarden_only`
37 + - `fediverse_authorized_fetch = false`
38 + - `fediverse_node_name = Rustwarden`
39 + - `fediverse_node_description = ` (vide)
40 +
41 +### Entités SeaORM
42 +
43 +- `models::fediverse_trusted_domain::Model`
44 +- `models::fediverse_blocked_domain::Model`
45 +
46 +### Enums et service
47 +
48 +```rust
49 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50 +pub enum TrustMode { Allowlist, OpenWithBlocklist }
51 +
52 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53 +pub enum InteropScope { RustwardenOnly, RustwardenPlusUniversalRead, Open }
54 +
55 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56 +pub enum BlockSeverity { Suspend, Silence, RejectAuth }
57 +
58 +pub struct FediverseTrustService { db: DatabaseConnection }
59 +
60 +impl FediverseTrustService {
61 + pub fn new(db: DatabaseConnection) -> Self;
62 +
63 + /// Read-through to `app_settings.fediverse_enabled`.
64 + pub async fn is_enabled(&self) -> Result<bool, AppError>;
65 + pub async fn trust_mode(&self) -> Result<TrustMode, AppError>;
66 + pub async fn interop_scope(&self) -> Result<InteropScope, AppError>;
67 +
68 + /// Central check applied BEFORE any AP signature verification or
69 + /// fetch towards a remote domain. Returns `false` when:
70 + /// - the feature is disabled at runtime
71 + /// - mode = allowlist and the domain is not in trusted (or is in blocked)
72 + /// - mode = open_with_blocklist and the domain is in blocked
73 + pub async fn is_domain_allowed(&self, domain: &str) -> Result<bool, AppError>;
74 +
75 + pub async fn add_trusted_domain(&self, domain: &str, note: Option<&str>, added_by: Option<Uuid>) -> Result<Model, AppError>;
76 + pub async fn remove_trusted_domain(&self, domain: &str) -> Result<bool, AppError>;
77 + pub async fn list_trusted_domains(&self) -> Result<Vec<Model>, AppError>;
78 +
79 + pub async fn add_blocked_domain(&self, domain: &str, severity: BlockSeverity, reason: Option<&str>, added_by: Option<Uuid>) -> Result<BlockedModel, AppError>;
80 + pub async fn remove_blocked_domain(&self, domain: &str) -> Result<bool, AppError>;
81 + pub async fn list_blocked_domains(&self) -> Result<Vec<BlockedModel>, AppError>;
82 +}
83 +```
84 +
85 +`is_domain_allowed` normalise toujours en lowercase + strip `www.` éventuel. Domain comparé strictement.
86 +
87 +## Analyse de compatibilité
88 +
89 +- [x] Non-breaking, additif derrière feature.
90 +- [x] Schéma DB : 2 nouvelles tables + insertions `app_settings`. Migration up/down réversible.
91 +- [ ] Pas de modification d'API publique existante.
92 +
93 +## Alternatives considérées
94 +
95 +### Alternative A (recommandée) — Tables dédiées + service avec enums
96 +
97 +**Pros** : RBAC clair, requêtes simples, audit trail (`added_by_user_id`).
98 +
99 +### Alternative B — Tout dans `app_settings` (CSV serialisé)
100 +
101 +Stocker la liste des domaines comme CSV dans une clé `app_settings`.
102 +
103 +**Cons** : pas indexable, pas auditable, race conditions sur l'écriture concurrente.
104 +
105 +**Rejeté.**
106 +
107 +### Alternative C — Configuration en ENV vars
108 +
109 +Passer la trust list via env.
110 +
111 +**Cons** : statique, nécessite redéploiement, ne respecte pas la décision plan §2.1 (admin-configurable runtime).
112 +
113 +**Rejeté.**
114 +
115 +## Testing plan
116 +
117 +12 tests :
118 +
119 +1. `trust_mode_defaults_to_allowlist_when_setting_missing` (pure)
120 +2. `interop_scope_defaults_to_rustwarden_only_when_setting_missing` (pure)
121 +3. `severity_parses_known_values` (pure)
122 +4. `severity_falls_back_to_suspend_for_unknown` (pure)
123 +5. `normalize_domain_lowercases` (pure)
124 +6. `normalize_domain_strips_www_prefix` (pure)
125 +7. `is_domain_allowed_returns_false_when_disabled` (DB-mocked)
126 +8. `is_domain_allowed_returns_true_when_in_allowlist_and_enabled` (DB-mocked)
127 +9. `is_domain_allowed_returns_false_when_not_in_allowlist` (DB-mocked)
128 +10. `is_domain_allowed_returns_true_in_open_mode_when_not_blocked` (DB-mocked)
129 +11. `is_domain_allowed_returns_false_in_open_mode_when_blocked` (DB-mocked)
130 +12. `add_trusted_domain_inserts_record` (DB-mocked)
131 +
132 +Score d'évaluation : 27/35 (auto-revue) → Accepted.
133 +
134 +## Open questions
135 +
136 +Toutes résolues à l'auto-revue : enums `TrustMode`/`InteropScope`/`BlockSeverity` exposés `pub`, normalisation domain par lowercase + strip `www.`, fallback `BlockSeverity::Suspend` pour valeurs inconnues.
137 +
138 +## Score
139 +
140 +| Critère | Note | Commentaire |
141 +|---|---|---|
142 +| Généralité | 5 | Modèle de confiance applicable bien au-delà de l'auth. |
143 +| Stabilité API | 4 | Additif gated, mais 2 nouvelles tables publiques. |
144 +| Performance | 5 | Lookups indexés (UNIQUE sur domain), cache éventuel facile à ajouter plus tard. |
145 +| Sécurité | 5 | Hardening net : zero-trust par défaut, gate avant signature AP. |
146 +| Complexité d'implémentation | 3 | ~500 lignes (2 entités + service + 12 tests). |
147 +| Maintenance | 3 | Schéma stable, evolution probable mineure (ajouter `severity` levels). |
148 +| Documentation | 2 | Rustdoc EN, doctest, CHANGELOG ; pas encore d'exemple `examples/`. |
149 +| **Total** | **27/35** | ≥ 25 → Accepted |
A docs/rfcs/0005-actor-key-storage.md
+269 -0
@@ -0,0 +1,269 @@
1 +# RFC 0005 — Fediverse: stockage des clés d'acteur (RSA) chiffrées
2 +
3 +- **Auteur** : EBO (rustwarden)
4 +- **Date** : 2026-05-09
5 +- **Statut** : **Accepted** (auto-revue mainteneur unique 2026-05-09, score 26/35)
6 +- **Apps consommatrices concernées** : toute app utilisant `rustwarden-core` avec la feature `experimental-fediverse` qui veut être réellement fédérable (au-delà de la simple découverte WebFinger livrée par RFC 0002).
7 +- **Plan associé** : [`docs/plans/2026-05-09-fediverse-identity-federation.md`](../plans/2026-05-09-fediverse-identity-federation.md), Phase 1 — clôture.
8 +- **Dépendances** : RFC 0001 (`FediverseConfig`), RFC 0002 (`PersonJson::public_key` actuellement `None`).
9 +
10 +## Summary
11 +
12 +Étendre la table `users` avec trois colonnes (`ap_public_key_pem`, `ap_private_key_pem` chiffré via `CryptoService`, `ap_discoverable`), introduire un `ActorKeyService` qui génère paresseusement une paire RSA-2048 (PKCS#1) lors de la première résolution AP de l'utilisateur, et brancher le `SeaOrmLocalUserResolver` pour exposer la clé publique dans `PersonJson::public_key` afin que Mastodon et compagnie puissent vérifier les signatures HTTP de nos acteurs.
13 +
14 +## Motivation
15 +
16 +Aujourd'hui, après RFC 0002, les routes `/.well-known/webfinger` et `/users/{username}` sont opérationnelles mais `PersonJson::public_key` est systématiquement `None` ([crates/rustwarden-core/src/fediverse/types.rs:68](../../crates/rustwarden-core/src/fediverse/types.rs#L68)). Conséquence : les serveurs distants (Mastodon, GoToSocial, Pleroma) refusent de parler à nos acteurs car ils ne peuvent vérifier aucune `Signature` HTTP RFC 9421. La fédération réelle reste impossible tant que cette RFC n'est pas livrée.
17 +
18 +Sans cette RFC, RFC 0006 (auth AP-native, Phase 2) n'a rien à signer pour le `AuthRequest` sortant et les apps consommatrices n'ont aucun moyen de prouver qu'une assertion provient bien de leur instance.
19 +
20 +## Non-goals
21 +
22 +- **Pas de signature HTTP entrante** (vérification des signatures Mastodon arrivantes) — RFC 0010 (`fediverse-lifecycle-signals`) avec `RemoteFetcher`.
23 +- **Pas de signature HTTP sortante** (signer nos requêtes vers Mastodon) — RFC 0011 (`fediverse-outbound-worker`).
24 +- **Pas de rotation de clé** automatique — opération manuelle via CLI ou endpoint admin futur. La RFC livre seulement la **génération initiale** + **persistance**.
25 +- **Pas de support Ed25519** — Mastodon impose RSA-2048 PKCS#1 SHA-256 ; on ne livre que ce profil.
26 +- **Pas d'UI admin** pour visualiser/regénérer les clés — `rustwarden-ui` traitera plus tard.
27 +- **Pas de backfill de masse au démarrage** — génération **lazy** au premier accès AP de l'utilisateur. Une commande CLI `backfill_actor_keys` est prévue (non livrée par cette RFC) pour les admins qui voudront pré-générer.
28 +
29 +## API proposée
30 +
31 +### Migration `m20260510_000011_extend_users_with_actor_keys`
32 +
33 +Étend `users` :
34 +
35 +- `ap_public_key_pem TEXT NULL` — PEM SPKI public, généré lazy.
36 +- `ap_private_key_pem TEXT NULL` — PEM PKCS#8 privé **chiffré** via `CryptoService` (`enc:` prefix + AES-256-GCM), jamais stocké en clair en DB.
37 +- `ap_discoverable BOOLEAN NOT NULL DEFAULT TRUE` — l'utilisateur veut-il apparaître dans les recherches inter-instance.
38 +
39 +Migration `up` purement additive (3 colonnes nullable + une avec défaut) ; `down` symétrique. Aucune donnée existante n'est touchée.
40 +
41 +### Modèle `users::Model`
42 +
43 +3 nouveaux champs publics gated `cfg(feature = "experimental-fediverse")` (sinon ils restent dans le schéma DB mais ne sont pas exposés côté Rust pour minimiser le poids de la struct hors fediverse). Décision : **on expose toujours** dans le modèle SeaORM (sinon les apps non-fediverse qui font `User::find_by_id` casseraient au runtime SeaORM par mismatch de colonnes). Ils restent simplement inutilisés.
44 +
45 +```rust
46 +pub struct Model {
47 + // … champs existants …
48 + /// Public key PEM (SPKI) used by remote fediverse servers to verify
49 + /// HTTP signatures from this actor. `None` until first AP resolution.
50 + pub ap_public_key_pem: Option<String>,
51 + /// Private key PEM (PKCS#8) **encrypted** via `CryptoService`. Never
52 + /// returned in clear by any service except `ActorKeyService::ensure_keys`.
53 + pub ap_private_key_pem: Option<String>,
54 + /// Whether this user wants to be discoverable by remote search.
55 + pub ap_discoverable: bool,
56 +}
57 +```
58 +
59 +### Service `ActorKeyService`
60 +
61 +Nouveau module `crates/rustwarden-core/src/services/actor_key_service.rs` (gated). Le service est sans état : pas de cache, juste DB + crypto + génération RSA.
62 +
63 +```rust
64 +use rsa::{RsaPrivateKey, pkcs1::EncodeRsaPublicKey, pkcs8::EncodePrivateKey};
65 +use sea_orm::DatabaseConnection;
66 +use uuid::Uuid;
67 +
68 +/// Bundle returned by [`ActorKeyService::ensure_keys`].
69 +#[derive(Debug, Clone)]
70 +pub struct ActorKeyMaterial {
71 + /// Public key as PEM (SPKI / `BEGIN PUBLIC KEY`).
72 + pub public_pem: String,
73 + /// Private key as PEM (PKCS#8 / `BEGIN PRIVATE KEY`), **decrypted**.
74 + pub private_pem: String,
75 +}
76 +
77 +/// RSA-2048 actor-key generator and persistence layer for ActivityPub.
78 +///
79 +/// Keys are generated lazily on first request and the private key is
80 +/// encrypted at rest via [`CryptoService`].
81 +pub struct ActorKeyService;
82 +
83 +impl ActorKeyService {
84 + /// Generate a fresh RSA-2048 key pair. CPU-bound (~50-200 ms): callers
85 + /// must invoke from `tokio::task::spawn_blocking` when on an async path.
86 + pub fn generate() -> Result<ActorKeyMaterial, AppError>;
87 +
88 + /// Idempotent: returns existing material if the user already has both
89 + /// `ap_public_key_pem` and `ap_private_key_pem` set, otherwise generates
90 + /// a new pair, encrypts the private half, and persists both columns.
91 + /// The private PEM in the returned bundle is **decrypted** (caller may
92 + /// sign with it). A `Mutex`-style race is impossible because the update
93 + /// is gated by a SELECT-with-`FOR UPDATE` semantic via `OnConflict`.
94 + pub async fn ensure_keys(
95 + db: &DatabaseConnection,
96 + user_id: Uuid,
97 + ) -> Result<ActorKeyMaterial, AppError>;
98 +
99 + /// Read-only fast path used by the actor JSON resolver: returns the
100 + /// public PEM if persisted, else `None`. Does not generate.
101 + pub async fn public_key_for_user(
102 + db: &DatabaseConnection,
103 + user_id: Uuid,
104 + ) -> Result<Option<String>, AppError>;
105 +}
106 +```
107 +
108 +### Wiring du `SeaOrmLocalUserResolver`
109 +
110 +Méthode `resolve` (dans [crates/rustwarden-core/src/fediverse/resolver.rs](../../crates/rustwarden-core/src/fediverse/resolver.rs)) modifiée pour, lors d'une requête AP entrante sur `/users/{username}` :
111 +
112 +1. Si `ap_public_key_pem` est `Some(pem)` → on l'utilise directement, pas de génération.
113 +2. Sinon, si `FediverseConfig` est actif et que le user existe → appel à `ActorKeyService::ensure_keys` pour le générer **dans une `spawn_blocking`** afin de ne pas bloquer le runtime tokio.
114 +
115 +L'ancienne forme du resolver (qui retournait `ap_public_key_pem: None`) reste compatible : un consommateur qui injecte sa propre impl de `LocalUserResolver` peut continuer à laisser `None` ; seul le default impl SeaORM gagne la génération lazy.
116 +
117 +### Exemple d'usage côté consommateur
118 +
119 +```rust
120 +use rustwarden_core::services::actor_key_service::ActorKeyService;
121 +
122 +// Backfill manuel pour un user spécifique (CLI admin).
123 +let material = ActorKeyService::ensure_keys(&db, user_id).await?;
124 +println!("{}", material.public_pem);
125 +// `material.private_pem` est déchiffré, en mémoire seulement.
126 +```
127 +
128 +## Analyse de compatibilité
129 +
130 +- [x] Non-breaking : 3 colonnes ajoutées, toutes nullable ou avec défaut.
131 +- [x] Migration up/down réversible.
132 +- [x] Pas de modification d'API publique existante. `Model` gagne 3 champs **en fin de struct** (pattern matching exhaustif des consommateurs casserait, mais SeaORM n'expose pas `Model` derrière un pattern destructuring usuel).
133 +- [x] Format sérialisé : PEM standard, pas de format custom à versionner.
134 +- [x] Le wiring resolver est interne, derrière feature.
135 +
136 +Risque résiduel : la première résolution AP d'un user après upgrade ajoute ~100 ms de latence (génération RSA). Mitigation : doc + commande de backfill en option.
137 +
138 +## Alternatives considérées
139 +
140 +### Alternative A (recommandée) — Lazy gen + chiffrement par `CryptoService`
141 +
142 +**Pros** :
143 +- Coût zéro pour les users qui ne fédèrent jamais.
144 +- Stockage chiffré par défaut, cohérent avec OAuth/TOTP.
145 +- Compatible 100 % du write-path actuel (pas de hook au signup).
146 +
147 +**Cons** :
148 +- Latence ponctuelle ~100 ms à la première résolution AP du user. Acceptable (un agrégateur retry).
149 +
150 +### Alternative B — Backfill au démarrage de l'app
151 +
152 +Génération de toutes les paires manquantes lors du `RustwardenBuilder::with_fediverse()`.
153 +
154 +**Pros** : pas de latence à la résolution.
155 +
156 +**Cons** :
157 +- Démarrage très lent sur un dump existant (1000 users × 100 ms = 100 s).
158 +- Génère des clés pour 99 % d'users qui ne fédéreront jamais.
159 +- Force les apps consommatrices à payer le coût même en environnement test.
160 +
161 +**Rejeté.** Une commande CLI optionnelle est meilleure.
162 +
163 +### Alternative C — Génération au signup uniquement
164 +
165 +Hook dans le user_service pour générer les clés à la création.
166 +
167 +**Pros** : pas de latence, pas de retro-actif.
168 +
169 +**Cons** :
170 +- Casse la rétrocompatibilité : les users pré-existants n'ont jamais de clés.
171 +- Force toutes les apps consommatrices à payer le coût RSA même quand fediverse est désactivé runtime.
172 +
173 +**Rejeté.**
174 +
175 +### Alternative D — Stockage en clair de la clé privée
176 +
177 +Pas de chiffrement, juste la colonne PEM.
178 +
179 +**Cons** :
180 +- Une fuite DB = compromission totale des identités fediverse.
181 +- Incohérent avec le pattern OAuth/TOTP existant.
182 +
183 +**Rejeté** pour raisons de sécurité.
184 +
185 +### Alternative E — Ne rien faire
186 +
187 +**Pros** : zéro coût.
188 +
189 +**Cons** : la fédération AP réelle reste impossible, RFC 0006+ bloquées.
190 +
191 +**Rejeté.**
192 +
193 +## Testing plan
194 +
195 +13 tests :
196 +
197 +**Pure (génération + sérialisation)** :
198 +1. `generate_returns_valid_pem_pair` — `generate()` retourne deux PEM dont les headers attendus (`BEGIN PUBLIC KEY` + `BEGIN PRIVATE KEY`).
199 +2. `generate_keys_are_2048_bits` — la clé privée a bien `n.bits() == 2048`.
200 +3. `generate_returns_distinct_pairs` — deux appels successifs produisent des modulus différents.
201 +4. `actor_key_material_clone_works` — la struct est `Clone` pour faciliter le passage entre tâches.
202 +
203 +**DB-mocked (`sea-orm/mock`)** :
204 +5. `public_key_for_user_returns_none_when_unset` — user sans clé → `Ok(None)`.
205 +6. `public_key_for_user_returns_some_when_set` — user avec PEM → `Ok(Some(...))`.
206 +7. `public_key_for_user_returns_error_when_user_missing` — id inconnu → `AppError::NotFound`.
207 +8. `ensure_keys_returns_existing_when_present` — keys déjà persistées → pas de régénération (vérifié via 0 RSA gen et même PEM en sortie).
208 +9. `ensure_keys_generates_when_missing` — keys absentes → 1 INSERT/UPDATE émis et PEM cohérent.
209 +10. `ensure_keys_persists_encrypted_private_key` — la valeur écrite côté DB commence par `enc:` (chiffré).
210 +11. `ensure_keys_returns_decrypted_private_pem` — le `private_pem` retourné est en clair (`BEGIN PRIVATE KEY`).
211 +
212 +**Resolver wiring** :
213 +12. `seaorm_resolver_populates_ap_public_key_pem_when_present` — quand le user a déjà la clé, `LocalUserActor.ap_public_key_pem` est `Some`.
214 +13. `seaorm_resolver_returns_none_when_keys_unset_and_crypto_unavailable` — sans `ENCRYPTION_KEY` configuré, on **ne génère pas** ; on retourne `None` pour ne pas casser la résolution. La latence n'est payée que si crypto est dispo.
215 +
216 +## Migration guide
217 +
218 +À copier dans `CHANGELOG.md` au moment du merge :
219 +
220 +```markdown
221 +### Added (rustwarden-core, feature `experimental-fediverse`)
222 +
223 +- Migration `m20260510_000011_extend_users_with_actor_keys` ajoutant
224 + trois colonnes à `users` :
225 + - `ap_public_key_pem` (PEM public AP, nullable, lazy-generated)
226 + - `ap_private_key_pem` (PEM privé chiffré via `CryptoService`)
227 + - `ap_discoverable` (bool, défaut `true`)
228 +- Service `ActorKeyService` (génération RSA-2048, ensure idempotent,
229 + lecture rapide de la clé publique).
230 +- `PersonJson::public_key` est maintenant `Some(...)` quand l'utilisateur
231 + a déjà une paire (générée à la première résolution AP).
232 +
233 +### Migration steps
234 +
235 +1. Configurer `ENCRYPTION_KEY` (64 hex chars) dans l'environnement avant
236 + d'activer la feature, sinon les clés ne seront pas générées.
237 +2. Lancer `cargo run --bin migrate` pour appliquer la migration 11.
238 +3. Optionnel : exécuter une commande de backfill (à venir RFC 0011) pour
239 + pré-générer les clés sur tous les users existants.
240 +```
241 +
242 +## Open questions
243 +
244 +Toutes résolues à l'auto-revue :
245 +
246 +- [x] **Génération lazy vs eager** : lazy (Alternative A retenue).
247 +- [x] **Format de clé** : RSA-2048 PKCS#8 (privé) + SPKI (public), conforme Mastodon.
248 +- [x] **Crypto reuse** : on réutilise `CryptoService` existant — pas de nouveau secret.
249 +- [x] **Comportement si `ENCRYPTION_KEY` absent** : on ne génère pas et on retourne `None` pour `ap_public_key_pem`. Test 13 verrouille ce contrat.
250 +- [x] **`spawn_blocking`** : oui, la génération RSA est CPU-bound (>10 ms), elle doit sortir du runtime tokio. Documenté dans la rustdoc de `generate()`.
251 +
252 +## Tracking issue
253 +
254 +À ouvrir à la création de la PR — pas encore numéro alloué.
255 +
256 +---
257 +
258 +## Score d'évaluation
259 +
260 +| Critère | Note (1-5) | Commentaire |
261 +|---|---|---|
262 +| Généralité | 4 | Service réutilisable au-delà du fediverse pour toute appli RSA-on-user, mais reste tied au schéma `users`. |
263 +| Stabilité API | 4 | Non-breaking, additif derrière feature ; 3 colonnes ajoutées au modèle public. |
264 +| Performance | 3 | Génération RSA ~100 ms, payée une seule fois par user, off-runtime via `spawn_blocking`. |
265 +| Sécurité | 5 | Hardening net : clé privée chiffrée at-rest, jamais loggée, dépend de `CryptoService` audité. |
266 +| Complexité d'implémentation | 3 | ~400 lignes (1 migration + 3 colonnes Model + 1 service + 13 tests + wiring resolver). |
267 +| Maintenance | 3 | Nouvelle dépendance `rsa` + `pkcs8`, surface upstream stable. |
268 +| Documentation | 4 | Rustdoc EN, doctest, CHANGELOG, guide migration ; pas encore d'exemple `examples/`. |
269 +| **Total** | **26/35** | ≥ 25 → Accepted |
A docs/rfcs/0006-ap-native-auth-flow.md
+238 -0
@@ -0,0 +1,238 @@
1 +# RFC 0006 — Fediverse: flow d'authentification AP-native (couche service)
2 +
3 +- **Auteur** : EBO (rustwarden)
4 +- **Date** : 2026-05-09
5 +- **Statut** : **Accepted** (auto-revue mainteneur unique 2026-05-09, score 26/35)
6 +- **Apps consommatrices concernées** : toute app utilisant `rustwarden-core` qui veut permettre à un utilisateur d'un IdP fediverse externe de se connecter et obtenir un JWT remote.
7 +- **Plan associé** : [`docs/plans/2026-05-09-fediverse-identity-federation.md`](../plans/2026-05-09-fediverse-identity-federation.md), Phase 2 — pièce maîtresse.
8 +- **Dépendances** : RFC 0001, RFC 0003, RFC 0004, RFC 0007, RFC 0011.
9 +
10 +## Summary
11 +
12 +Introduire la table `fediverse_auth_challenges` (state machine `Pending → Approved | Rejected | Expired | Consumed`) et le service `FediverseAuthService` qui orchestre le flow client-side : génération du challenge nonce, persistance, vérification du `Accept{AuthRequest}` reçu, création de session JWT remote. La construction de l'`AuthRequest` à signer puis l'enqueue dans `fediverse_outbound_queue` (RFC 0011) est exposée via un helper public, mais la mise en place des **routes HTTP** (initiate / callback / status / consent / inbox) est **explicitement reportée** à une RFC suivante (`0006-bis` ou intégrée à la Phase 2 finale UI). Cette RFC livre la **couche service complète + tests** qui permettront aux routes HTTP futures d'être un simple câblage.
13 +
14 +## Motivation
15 +
16 +Sans cette couche service, RFC 0009 (invitations cross-instance) et RFC 0010 (signaux lifecycle) n'ont aucune session remote sur laquelle s'appuyer pour autoriser/refuser des actions. Le master plan §10 Phase 2 indique 5 endpoints HTTP, mais la complexité **utile** est dans le service — l'HTTP est plomberie. En livrant le service avec une suite de tests verts, on dérisque la Phase 2 et on permet aux RFCs en aval de progresser.
17 +
18 +Sans cette RFC, on ne peut pas tester le « happy path » deux instances locales sans monter tout le stack HTTP.
19 +
20 +## Non-goals
21 +
22 +- **Pas de routes HTTP** dans cette RFC. Aucune entrée `/api/v1/auth/login/fediverse/*` n'est exposée. Les apps consommatrices peuvent appeler le service depuis leur propre handler dans l'intervalle.
23 +- **Pas de UI HTML** (consent screen, waiting page) — `rustwarden-ui` traitera dans Phase 2 finale.
24 +- **Pas de signature HTTP réelle** sortante — déléguée à `OutboundDelivery` (RFC 0011 livre le `NoopDelivery`, RFC 0010 le `HttpSignedDelivery`).
25 +- **Pas de vérification de signature entrante** sur l'`Accept{AuthRequest}` reçu — clairement marqué comme dette technique fermée par RFC 0010 ; la fonction de vérification est isolée derrière un trait pluggable.
26 +- **Pas de polling/SSE** côté client — méthode publique `status` synchrone ; les routes HTTP futures peuvent l'envelopper en long-poll si besoin.
27 +- **Pas de gestion du cas où la réponse arrive avant l'expiration de la session de l'utilisateur sur l'IdP** — TTL fixe 5 min suffisant pour le slice.
28 +
29 +## API proposée
30 +
31 +### Migration `m20260510_000013_fediverse_auth_challenges`
32 +
33 +```sql
34 +CREATE TABLE fediverse_auth_challenges (
35 + id UUID PRIMARY KEY,
36 + nonce TEXT NOT NULL UNIQUE,
37 + handle TEXT NOT NULL, -- @user@domain demandé
38 + domain TEXT NOT NULL,
39 + requested_remote_id UUID NULL, -- FK remote_identities, set quand connu
40 + status TEXT NOT NULL DEFAULT 'pending', -- pending|approved|rejected|expired|consumed
41 + redirect_url TEXT NULL, -- callback de l'app consommatrice
42 + created_at TIMESTAMPTZ NOT NULL,
43 + expires_at TIMESTAMPTZ NOT NULL,
44 + consumed_at TIMESTAMPTZ NULL
45 +);
46 +CREATE INDEX fediverse_auth_challenges_pending_idx
47 + ON fediverse_auth_challenges (status, expires_at)
48 + WHERE status = 'pending';
49 +```
50 +
51 +### Énumération + types
52 +
53 +```rust
54 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55 +pub enum ChallengeStatus { Pending, Approved, Rejected, Expired, Consumed }
56 +
57 +#[derive(Debug, Clone)]
58 +pub struct InitiateRequest {
59 + pub handle: String, // "alice@a.tld"
60 + pub redirect_url: Option<String>,
61 +}
62 +
63 +#[derive(Debug, Clone)]
64 +pub struct InitiatedChallenge {
65 + pub id: Uuid,
66 + pub nonce: String, // 256-bit URL-safe base64
67 + pub expires_at: DateTime<Utc>,
68 +}
69 +```
70 +
71 +### Service
72 +
73 +```rust
74 +pub struct FediverseAuthService;
75 +
76 +impl FediverseAuthService {
77 + /// Step 1 — caller resolved a handle and now wants to start a flow.
78 + /// Persists a `pending` challenge with a fresh nonce. Caller is
79 + /// responsible for enqueueing the `AuthRequest` activity to the
80 + /// remote inbox via `FediverseOutboundService::enqueue` and the
81 + /// helper [`build_auth_request_payload`] below.
82 + pub async fn initiate(
83 + db: &DatabaseConnection,
84 + request: InitiateRequest,
85 + ttl: Duration,
86 + ) -> Result<InitiatedChallenge, AppError>;
87 +
88 + /// Step 2 — IdP-side: user clicked Approve / Reject. Updates the
89 + /// challenge status and (for Approve) records the remote_identity_id.
90 + /// Idempotent: a second call with the same id returns `AlreadyDecided`.
91 + pub async fn record_decision(
92 + db: &DatabaseConnection,
93 + challenge_id: Uuid,
94 + decision: ChallengeDecision,
95 + ) -> Result<fediverse_auth_challenges::Model, AppError>;
96 +
97 + /// Step 3 — caller polls until decision arrives. Returns the
98 + /// challenge row, with status reflecting the latest known value.
99 + /// Marks expired challenges if `expires_at < now`.
100 + pub async fn status(
101 + db: &DatabaseConnection,
102 + challenge_id: Uuid,
103 + ) -> Result<fediverse_auth_challenges::Model, AppError>;
104 +
105 + /// Step 4 — caller exchanges an `Approved` challenge for a remote
106 + /// JWT (via `JwtService::generate_remote_token`). Atomically flips
107 + /// status to `Consumed` (one-shot) so the same Accept can never
108 + /// mint two tokens.
109 + pub async fn consume(
110 + db: &DatabaseConnection,
111 + config: &AuthConfig,
112 + challenge_id: Uuid,
113 + ) -> Result<ConsumedSession, AppError>;
114 +}
115 +
116 +#[derive(Debug, Clone)]
117 +pub enum ChallengeDecision {
118 + Approve { remote_identity_id: Uuid },
119 + Reject,
120 +}
121 +
122 +#[derive(Debug, Clone)]
123 +pub struct ConsumedSession {
124 + pub jwt: String,
125 + pub remote_identity_id: Uuid,
126 + pub handle: String,
127 +}
128 +```
129 +
130 +### Helper de construction du payload
131 +
132 +```rust
133 +/// Build the JSON-LD body of the `AuthRequest` activity addressed at
134 +/// `target_inbox`. The body is unsigned at this stage; the outbound
135 +/// worker (RFC 0010 transport) attaches the HTTP-Sig at delivery time.
136 +pub fn build_auth_request_payload(
137 + config: &FediverseConfig,
138 + challenge: &InitiatedChallenge,
139 + target_handle: &str,
140 +) -> serde_json::Value;
141 +```
142 +
143 +## Analyse de compatibilité
144 +
145 +- [x] Non-breaking : nouvelle table + nouveau service derrière feature.
146 +- [x] Migration up/down réversible.
147 +- [x] N'altère aucune API publique existante.
148 +- [x] Le JWT remote émis utilise `JwtService::generate_remote_token` (RFC 0007), donc cohérent avec la blacklist existante.
149 +
150 +## Alternatives considérées
151 +
152 +### Alternative A (recommandée) — Service complet, routes HTTP différées
153 +
154 +**Pros** : permet aux RFCs aval de progresser ; couvre la complexité métier réelle ; routes HTTP triviales ensuite.
155 +
156 +**Cons** : un consommateur souhaitant le flow end-to-end aujourd'hui doit écrire ses 4 handlers HTTP.
157 +
158 +### Alternative B — RFC monolithique service + routes HTTP + UI HTML
159 +
160 +**Cons** : ~2000 lignes, surface de revue énorme, blocage des autres RFCs.
161 +
162 +**Rejeté.**
163 +
164 +### Alternative C — Stocker le challenge en mémoire (pas de table)
165 +
166 +**Cons** : perte au redémarrage du worker, pas multi-instance.
167 +
168 +**Rejeté.**
169 +
170 +### Alternative D — Réutiliser le mécanisme OAuth state existant
171 +
172 +**Cons** : sémantique différente (OAuth → IdP géré ; ici IdP est l'AP server distant) ; cumul des risques de sécurité.
173 +
174 +**Rejeté.**
175 +
176 +## Testing plan
177 +
178 +12 tests :
179 +
180 +**Pure** :
181 +1. `challenge_status_round_trips_through_string`.
182 +2. `build_auth_request_payload_includes_nonce_and_target`.
183 +3. `build_auth_request_payload_uses_fediverse_base_url`.
184 +
185 +**DB-mocked (`sea-orm/mock`)** :
186 +4. `initiate_persists_pending_challenge_with_fresh_nonce`.
187 +5. `initiate_returns_unique_nonces_across_calls`.
188 +6. `record_decision_approve_marks_approved_and_records_remote_id`.
189 +7. `record_decision_reject_marks_rejected`.
190 +8. `status_marks_expired_when_past_expires_at`.
191 +9. `status_returns_pending_when_not_decided_yet`.
192 +10. `consume_rejects_pending_challenge`.
193 +11. `consume_rejects_already_consumed_challenge`.
194 +12. `consume_emits_remote_jwt_for_approved_challenge`.
195 +
196 +## Migration guide
197 +
198 +```markdown
199 +### Added (rustwarden-core, feature `experimental-fediverse`)
200 +
201 +- Migration `m20260510_000013_fediverse_auth_challenges`.
202 +- `FediverseAuthService` (initiate / record_decision / status / consume).
203 +- Helper `build_auth_request_payload`.
204 +
205 +### Migration steps
206 +
207 +1. `cargo run --bin migrate`.
208 +2. App consommatrice : écrire les 4 handlers HTTP (initiate, status,
209 + consent, callback) en s'appuyant sur le service. Un exemple complet
210 + sera fourni dans un follow-up RFC dédié aux routes.
211 +```
212 +
213 +## Open questions
214 +
215 +Toutes résolues à l'auto-revue :
216 +
217 +- [x] **Nonce length** : 32 bytes URL-safe base64 (≈43 chars).
218 +- [x] **TTL par défaut** : 5 min (`fediverse.auth_challenge_ttl_secs`).
219 +- [x] **`Consumed` vs blacklist du JWT** : `consume` marque la ligne comme consumed et émet le JWT en une transaction logique ; toute future tentative de consume de la même ligne retourne `AlreadyConsumed`.
220 +
221 +## Tracking issue
222 +
223 +À ouvrir au moment de la PR.
224 +
225 +---
226 +
227 +## Score d'évaluation
228 +
229 +| Critère | Note (1-5) | Commentaire |
230 +|---|---|---|
231 +| Généralité | 4 | State machine challenge réutilisable au-delà du fediverse (ex. magic links). |
232 +| Stabilité API | 4 | Additif, gated. |
233 +| Performance | 4 | Lookups indexés, table petite (challenges éphémères). |
234 +| Sécurité | 4 | Nonce cryptographique, one-shot consume, expiration stricte. La signature HTTP entrante reste à RFC 0010 — explicitement marqué. |
235 +| Complexité d'implémentation | 3 | ~700 lignes (migration + entité + service + helper + 12 tests). |
236 +| Maintenance | 3 | State machine claire, peu de cas edge. |
237 +| Documentation | 4 | Rustdoc EN, doctest, CHANGELOG, migration guide. |
238 +| **Total** | **26/35** | ≥ 25 → Accepted |
A docs/rfcs/0007-jwt-claims-identity-kind.md
+236 -0
@@ -0,0 +1,236 @@
1 +# RFC 0007 — Fediverse: extension JWT `Claims` (`identity_kind`, `remote_id`)
2 +
3 +- **Auteur** : EBO (rustwarden)
4 +- **Date** : 2026-05-09
5 +- **Statut** : **Accepted** (auto-revue mainteneur unique 2026-05-09, score 27/35)
6 +- **Apps consommatrices concernées** : toute app utilisant `rustwarden-core` qui fait dépendre des décisions d'autorisation de la nature locale ou distante du sujet du JWT.
7 +- **Plan associé** : [`docs/plans/2026-05-09-fediverse-identity-federation.md`](../plans/2026-05-09-fediverse-identity-federation.md), Phase 2 — fondation auth AP-native.
8 +- **Dépendances** : RFC 0003 (`remote-identity-store`).
9 +
10 +## Summary
11 +
12 +Étendre [`Claims`](../../crates/rustwarden-core/src/auth/jwt.rs) avec deux champs optionnels — `identity_kind: IdentityKind` (`Local` par défaut, `Remote` pour les sessions issues d'un IdP fediverse) et `remote_id: Option<Uuid>` (FK vers `remote_identities.id` quand `kind = Remote`) — pour que les middlewares d'autorisation puissent distinguer un user local d'un user remote sans relire la DB.
13 +
14 +## Motivation
15 +
16 +À partir de la Phase 2, un JWT pourra être émis pour un sujet **distant** (`alice@a.tld` connectée sur `b.tld`). Sans champ discriminant, le code consommateur :
17 +- ne sait pas si `Claims.sub` pointe sur `users.id` ou `remote_identities.id` ;
18 +- doit faire un `find_by_id` puis un `find_by_id` croisé sur deux tables pour le retrouver, ce qui annule l'avantage du JWT (claims auto-portants) ;
19 +- expose un risque de `confused-deputy` : un attaquant pourrait forger un JWT remote dont le `sub` collisionne avec un `user_id` local et obtenir un rôle global.
20 +
21 +Sans cette RFC, le `team_service` (RFC 0009) et le middleware `admin_required` ne savent pas refuser l'accès « rôle global » aux remote users sans aller-retour DB.
22 +
23 +## Non-goals
24 +
25 +- Pas de modification du flow d'émission existant pour les users locaux (rétrocompat 100 % : `identity_kind = Local` injecté par défaut côté désérialisation et émission).
26 +- Pas de rotation/migration des JWT déjà émis avant la livraison — les sessions actives restent valides, simplement avec `kind = Local` implicite.
27 +- Pas de changement de l'algorithme HS256 ni du format de signature.
28 +- Pas de gestion du flow `Accept{AuthRequest}` lui-même — c'est RFC 0006.
29 +
30 +## API proposée
31 +
32 +### Nouvelle énumération
33 +
34 +```rust
35 +/// Discriminant porté par chaque [`Claims`] : nature du sujet du JWT.
36 +///
37 +/// Persisté dans le payload sous la clé `kind` pour rester compact.
38 +/// Compat ascendante : un JWT existant sans cette clé est lu comme
39 +/// `IdentityKind::Local`.
40 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41 +#[serde(rename_all = "snake_case")]
42 +pub enum IdentityKind {
43 + /// Sujet = `users.id` (compte local).
44 + Local,
45 + /// Sujet = `remote_identities.id` (utilisateur AP fédéré sur cette
46 + /// instance via le flow `AuthRequest`/`Accept` de la RFC 0006).
47 + Remote,
48 +}
49 +
50 +impl Default for IdentityKind {
51 + fn default() -> Self { Self::Local }
52 +}
53 +```
54 +
55 +### Extension de `Claims`
56 +
57 +Deux nouveaux champs publics, tous deux `#[serde(default)]` pour garantir la compatibilité ascendante avec les JWT existants :
58 +
59 +```rust
60 +pub struct Claims {
61 + // … champs existants (sub, jti, aud, username, roles, permissions, exp, iat, iss) …
62 +
63 + /// Identity kind: `Local` for `users.id`, `Remote` for
64 + /// `remote_identities.id`. Older tokens (pre-RFC 0007) deserialise
65 + /// to `Local`.
66 + #[serde(default, rename = "kind")]
67 + pub identity_kind: IdentityKind,
68 +
69 + /// FK vers `remote_identities.id` quand `identity_kind = Remote`,
70 + /// `None` sinon (incluant `Local` et tokens pré-RFC).
71 + #[serde(default, skip_serializing_if = "Option::is_none")]
72 + pub remote_id: Option<Uuid>,
73 +}
74 +```
75 +
76 +### Helpers d'API
77 +
78 +Sur `Claims` directement (pas une nouvelle méthode statique de `JwtService`), pour rester explicites côté call-site :
79 +
80 +```rust
81 +impl Claims {
82 + /// Returns true when this token represents a remote (federated) identity.
83 + pub fn is_remote(&self) -> bool {
84 + matches!(self.identity_kind, IdentityKind::Remote)
85 + }
86 +
87 + /// Returns true when this token represents a local user.
88 + pub fn is_local(&self) -> bool {
89 + matches!(self.identity_kind, IdentityKind::Local)
90 + }
91 +
92 + /// `Some(id)` only when this is a remote token; `None` for local.
93 + pub fn remote_identity_id(&self) -> Option<Uuid> {
94 + if self.is_remote() { self.remote_id } else { None }
95 + }
96 +}
97 +```
98 +
99 +### Surcharge de `JwtService::generate_token`
100 +
101 +L'API existante reste rétrocompatible (signature inchangée, défaut `Local`). Une nouvelle entrée explicite est ajoutée pour le flow remote :
102 +
103 +```rust
104 +impl JwtService {
105 + /// Generate a JWT token explicitly typed as a remote identity. The
106 + /// `sub` claim becomes the `remote_identities.id` and `remote_id`
107 + /// is set to the same value for ergonomics.
108 + pub fn generate_remote_token(
109 + config: &AuthConfig,
110 + remote_identity_id: Uuid,
111 + handle: &str,
112 + ) -> Result<String, AppError>;
113 +}
114 +```
115 +
116 +`handle` est la chaîne `@user@domain` — utilisée comme `username` côté payload pour cohérence avec les logs.
117 +
118 +### Exemple d'usage côté consommateur
119 +
120 +```rust
121 +use rustwarden_core::auth::jwt::{IdentityKind, JwtService};
122 +
123 +// Émission par le service auth fediverse (RFC 0006).
124 +let token = JwtService::generate_remote_token(&config, remote.id, "alice@a.tld")?;
125 +
126 +// Côté middleware admin_required (RFC 0008).
127 +if claims.is_remote() {
128 + return Err(AppError::Authorization(
129 + "remote identities cannot hold global roles".into(),
130 + ));
131 +}
132 +```
133 +
134 +## Analyse de compatibilité
135 +
136 +- [x] Non-breaking : champs ajoutés avec `#[serde(default)]`. Les JWT pré-RFC se désérialisent correctement (`identity_kind = Local`).
137 +- [x] Pas de changement de schéma DB.
138 +- [x] Format sérialisé étendu : ancien décodeur stricte (qui rejetterait des champs inconnus) plante — vérifié que le décodeur `serde_json` actuel est tolérant (pas de `deny_unknown_fields`).
139 +- [x] Aucune méthode publique existante n'est supprimée.
140 +
141 +## Alternatives considérées
142 +
143 +### Alternative A (recommandée) — Champs additifs sur `Claims` + helpers
144 +
145 +**Pros** :
146 +- Tout le contexte d'autorisation reste dans le payload, zéro DB lookup.
147 +- Compatible ascendante.
148 +- Permet aux middlewares de short-circuiter avant DB.
149 +
150 +**Cons** : 2 champs supplémentaires augmentent légèrement la taille du payload (~60 bytes encodés base64).
151 +
152 +### Alternative B — Table de mapping `(jti, identity_kind)` séparée
153 +
154 +**Pros** : payload reste compact.
155 +
156 +**Cons** :
157 +- DB lookup à chaque requête → ruine l'avantage du JWT.
158 +- Augmente la complexité du blacklist (déjà 1 lookup par requête).
159 +
160 +**Rejeté.**
161 +
162 +### Alternative C — Discriminer via `aud` ou un préfixe sur `sub`
163 +
164 +**Pros** : pas de nouveau champ.
165 +
166 +**Cons** :
167 +- Hacky : `sub` doit rester un UUID.
168 +- Hijacks `aud` qui sert l'isolation multi-app.
169 +
170 +**Rejeté.**
171 +
172 +### Alternative D — Ne rien faire
173 +
174 +**Cons** : RFC 0006 (auth flow) n'a pas de place où stocker l'origine. Le code consommateur doit faire un cross-table lookup à chaque check.
175 +
176 +**Rejeté.**
177 +
178 +## Testing plan
179 +
180 +8 tests :
181 +
182 +1. `default_identity_kind_is_local` — `IdentityKind::default()` = `Local`.
183 +2. `claims_serialise_kind_as_snake_case` — champ wire `"kind": "remote"`.
184 +3. `claims_deserialise_legacy_token_as_local` — JSON sans `kind` → `IdentityKind::Local`.
185 +4. `claims_remote_id_skips_when_none` — sérialisation omet `remote_id` si `None`.
186 +5. `is_remote_returns_true_for_remote_token` — happy path.
187 +6. `is_local_returns_true_when_default` — happy path.
188 +7. `remote_identity_id_returns_none_for_local` — guard helper.
189 +8. `generate_remote_token_round_trip` — signe via `JwtService::generate_remote_token`, valide via `JwtService::validate_token`, assert `claims.is_remote()` + `claims.sub == remote_id`.
190 +
191 +Doctest : 1 exemple complet d'émission + validation remote.
192 +
193 +## Migration guide
194 +
195 +```markdown
196 +### Added
197 +
198 +- `Claims.identity_kind: IdentityKind` (`Local` par défaut).
199 +- `Claims.remote_id: Option<Uuid>`.
200 +- `JwtService::generate_remote_token(...)` pour les sessions fédérées.
201 +
202 +### Migration steps
203 +
204 +1. Aucune action requise pour les apps locales : les tokens existants
205 + continuent d'être validés et le défaut est `IdentityKind::Local`.
206 +2. Les apps qui implémentent leurs propres middlewares peuvent ajouter
207 + un check `claims.is_remote()` avant tout chemin réservé aux
208 + utilisateurs locaux (rôles globaux, panneau admin).
209 +```
210 +
211 +## Open questions
212 +
213 +Toutes résolues à l'auto-revue :
214 +
215 +- [x] **Naming `kind`** : court et explicite côté JSON ; `identity_kind` côté Rust pour clarté.
216 +- [x] **Position du helper** : sur `Claims` directement (pas une struct wrapper) → API plus discoverable.
217 +- [x] **Sécurité** : `is_remote()` doit retourner `false` si `remote_id` est `None` même quand `kind = Remote` ? Décision : non, on respecte le `kind` ; un token mal formé `Remote` sans `remote_id` est rejeté plus haut, à l'émission. Test 7 verrouille le contrat opposé.
218 +
219 +## Tracking issue
220 +
221 +À ouvrir au moment de la PR.
222 +
223 +---
224 +
225 +## Score d'évaluation
226 +
227 +| Critère | Note (1-5) | Commentaire |
228 +|---|---|---|
229 +| Généralité | 4 | Pattern réutilisable au-delà du fediverse pour toute origine externe (SAML, mTLS) à l'avenir. |
230 +| Stabilité API | 5 | Additif, défaut, pas de breaking. |
231 +| Performance | 4 | +60 bytes par token, négligeable ; supprime un DB lookup au check d'autorisation. |
232 +| Sécurité | 5 | Hardening net : empêche la confusion local/remote dans les middlewares. |
233 +| Complexité d'implémentation | 4 | ~150 lignes (enum + 2 champs + helpers + 8 tests). |
234 +| Maintenance | 3 | Champ permanent dans le format JWT — toute évolution ultérieure devra le respecter. |
235 +| Documentation | 2 | Rustdoc EN, doctest, CHANGELOG ; pas encore d'`examples/` complet. |
236 +| **Total** | **27/35** | ≥ 25 → Accepted |
A docs/rfcs/0008-identity-abstraction.md
+240 -0
@@ -0,0 +1,240 @@
1 +# RFC 0008 — Fediverse: abstraction `Identity` + colonnes sibling FK
2 +
3 +- **Auteur** : EBO (rustwarden)
4 +- **Date** : 2026-05-09
5 +- **Statut** : **Accepted** (auto-revue mainteneur unique 2026-05-09, score 26/35)
6 +- **Apps consommatrices concernées** : toute app utilisant `rustwarden-core` qui doit représenter un acteur autorisé sur une ressource sans figer si cet acteur est local ou distant (équipes cross-instance, partages, audit).
7 +- **Plan associé** : [`docs/plans/2026-05-09-fediverse-identity-federation.md`](../plans/2026-05-09-fediverse-identity-federation.md), Phase 3 — fondation.
8 +- **Dépendances** : RFC 0003 (`remote-identity-store`), RFC 0007 (`jwt-claims-identity-kind`).
9 +
10 +## Summary
11 +
12 +Introduire l'enum public `Identity { Local(Uuid), Remote(Uuid) }` + le trait `IdentityResolver`, puis ajouter au schéma DB les colonnes sibling FK qui permettront aux services existants (équipes, partages, audit) d'accepter un sujet distant **sans casse** des données existantes :
13 +
14 +- `team_members.remote_identity_id UUID NULL`
15 +- `resource_shares.shared_with_remote_identity_id UUID NULL`
16 +- `audit_logs.actor_remote_identity_id UUID NULL`
17 +- `audit_logs.target_remote_identity_id UUID NULL`
18 +
19 +L'adaptation des services pour réellement **accepter** un `Identity::Remote` (CRUD, checks de permission, invitations) reste l'objet de RFC 0009 ; cette RFC livre seulement le contrat type-safe + le schéma support.
20 +
21 +## Motivation
22 +
23 +Aujourd'hui, `team_service::add_member`, `resource_service::share_with_user`, `audit_service::log` n'acceptent qu'un `Uuid` qui est implicitement un `users.id`. Phase 2 a déjà livré les sessions remote (JWT `IdentityKind::Remote`), mais il n'existe aucun moyen pour ces sessions d'être inscrites dans une équipe ou de figurer dans un audit log sans confusion d'identifiants.
24 +
25 +Sans cette RFC, RFC 0009 aurait à introduire à la fois (a) le contrat `Identity`, (b) les colonnes sibling FK, **et** (c) la modification des services. Le scope serait inutilement large. En isolant (a) + (b), on dérisque la livraison.
26 +
27 +Sans cette RFC, le code consommateur qui doit aujourd'hui faire un cross-table lookup `users` ↔ `remote_identities` à chaque check d'authz n'a aucune abstraction unifiée.
28 +
29 +## Non-goals
30 +
31 +- **Pas de modification du `team_service`, `resource_service`, `audit_service`** — RFC 0009.
32 +- **Pas de modification de la PK composite `(team_id, user_id)` du `team_members`** — la transformation en `(team_id, user_id, remote_identity_id)` avec CHECK XOR est faisable mais demande une migration data plus invasive ; déférée à RFC 0009 quand le service la consomme.
33 +- **Pas de UI / API JSON** d'invitations cross-instance — RFC 0009.
34 +- **Pas d'extension de `Principal`** dans cette RFC — le wrapper enum est introduit mais l'intégration côté handlers reste interne au master plan §4.
35 +
36 +## API proposée
37 +
38 +### Enum + trait
39 +
40 +```rust
41 +/// Universally unique reference to an actor known to Rustwarden.
42 +///
43 +/// Discriminates a local user (`users.id`) from a federated remote
44 +/// identity (`remote_identities.id`) at the type level so middlewares
45 +/// and services can short-circuit confused-deputy hazards before
46 +/// touching the database.
47 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
48 +#[serde(rename_all = "snake_case", tag = "kind", content = "id")]
49 +pub enum Identity {
50 + /// `users.id`.
51 + Local(Uuid),
52 + /// `remote_identities.id`.
53 + Remote(Uuid),
54 +}
55 +
56 +impl Identity {
57 + pub fn is_local(&self) -> bool;
58 + pub fn is_remote(&self) -> bool;
59 + pub fn id(&self) -> Uuid;
60 + pub fn as_local(&self) -> Option<Uuid>;
61 + pub fn as_remote(&self) -> Option<Uuid>;
62 +}
63 +
64 +/// Look-up facade that returns a unified [`Identity`] regardless of
65 +/// whether the underlying row lives in `users` or in
66 +/// `remote_identities`. Implementations should normalise the input
67 +/// (handle vs UUID vs JWT claims) before querying.
68 +#[async_trait]
69 +pub trait IdentityResolver: Send + Sync {
70 + async fn resolve_local(&self, user_id: Uuid) -> Result<Option<Identity>, AppError>;
71 + async fn resolve_remote(&self, remote_id: Uuid) -> Result<Option<Identity>, AppError>;
72 +}
73 +```
74 +
75 +### Construction depuis JWT `Claims` (RFC 0007)
76 +
77 +```rust
78 +impl Identity {
79 + /// Convenience constructor from RFC 0007 claims.
80 + pub fn from_claims(claims: &Claims) -> Self {
81 + if claims.is_remote() {
82 + Self::Remote(claims.remote_identity_id().unwrap_or(claims.sub))
83 + } else {
84 + Self::Local(claims.sub)
85 + }
86 + }
87 +}
88 +```
89 +
90 +### Migration `m20260510_000014_identity_sibling_fks`
91 +
92 +```sql
93 +-- team_members
94 +ALTER TABLE team_members ADD COLUMN remote_identity_id UUID NULL;
95 +-- chk: at least one of (user_id, remote_identity_id) is set; both is illegal.
96 +ALTER TABLE team_members ADD CONSTRAINT chk_team_members_principal
97 + CHECK ((user_id IS NOT NULL) <> (remote_identity_id IS NOT NULL));
98 +
99 +-- resource_shares
100 +ALTER TABLE resource_shares ADD COLUMN shared_with_remote_identity_id UUID NULL;
101 +-- chk: exactly one of (user_id, team_id, remote_identity_id) is set
102 +ALTER TABLE resource_shares ADD CONSTRAINT chk_resource_shares_remote_principal
103 + CHECK (
104 + (
105 + (shared_with_user_id IS NOT NULL)::int
106 + + (shared_with_team_id IS NOT NULL)::int
107 + + (shared_with_remote_identity_id IS NOT NULL)::int
108 + ) = 1
109 + );
110 +
111 +-- audit_logs
112 +ALTER TABLE audit_logs ADD COLUMN actor_remote_identity_id UUID NULL;
113 +ALTER TABLE audit_logs ADD COLUMN target_remote_identity_id UUID NULL;
114 +
115 +-- partial indexes for the lookup paths that RFC 0009 will exercise
116 +CREATE INDEX team_members_remote_identity_idx
117 + ON team_members (remote_identity_id)
118 + WHERE remote_identity_id IS NOT NULL;
119 +CREATE INDEX resource_shares_remote_identity_idx
120 + ON resource_shares (shared_with_remote_identity_id)
121 + WHERE shared_with_remote_identity_id IS NOT NULL;
122 +```
123 +
124 +> **Note** : la modification de la PK composite `team_members(team_id, user_id)` n'est pas faite ici car (1) elle exige un drop+recreate qui touche les rows existantes, (2) le service `team_service` ne consomme pas encore l'identité distante. Quand RFC 0009 ajoute les chemins distants au service, la PK sera adaptée par une migration sœur **à ce moment-là**, idempotente.
125 +
126 +### Modèles SeaORM
127 +
128 +Les Models existants (`team_member::Model`, `resource_share::Model`, `audit_log::Model`) gagnent un champ `Option<Uuid>` chacun, gated derrière la feature `experimental-fediverse` est inutile car les colonnes existeront en DB pour tout le monde — donc le champ est exposé en permanence côté Rust (feature-flag uniquement la **logique** distante).
129 +
130 +L'`ActiveModel` existant doit être étendu avec un `NotSet` par défaut là où des structs literals sont utilisées (cas équivalent à RFC 0005 sur `users`).
131 +
132 +## Analyse de compatibilité
133 +
134 +- [x] Non-breaking : 4 colonnes nullable + 2 indexes partiels.
135 +- [x] CHECK constraints permissifs : autorisent l'**état actuel** (toutes lignes ont `user_id` ou `team_id` non-NULL).
136 +- [x] Migration up/down réversible (DROP COLUMN + DROP CONSTRAINT + DROP INDEX).
137 +- [x] Aucun service public actuel n'est modifié.
138 +- [x] Aucune API publique cassée — l'enum/trait sont additifs.
139 +
140 +## Alternatives considérées
141 +
142 +### Alternative A (recommandée) — Sibling columns + enum + trait
143 +
144 +**Pros** : non-breaking, atomique, RFC 0009 peut consommer immédiatement.
145 +
146 +**Cons** : duplication temporaire (2 colonnes sibling pour 1 concept logique) tant que la migration de PK n'est pas faite.
147 +
148 +### Alternative B — Colonne unique polymorphe `actor_kind` + `actor_id`
149 +
150 +Une seule colonne `actor_id UUID` + une colonne `actor_kind TEXT`.
151 +
152 +**Pros** : schéma compact.
153 +
154 +**Cons** :
155 +- Pas de FK référentielle (CASCADE manquante).
156 +- Plus difficile à indexer pour les queries actuelles.
157 +- Tous les CHECK existants à réécrire.
158 +
159 +**Rejeté.**
160 +
161 +### Alternative C — Table `principals` avec FK depuis tout
162 +
163 +**Pros** : modèle propre.
164 +
165 +**Cons** : énorme refacto, casse la rétrocompat, ralentit toutes les requêtes existantes.
166 +
167 +**Rejeté.**
168 +
169 +### Alternative D — Ne rien faire
170 +
171 +**Cons** : RFC 0009 doit absorber tout le scope ; les services existants ne pourront jamais accepter un remote sans cette fondation.
172 +
173 +**Rejeté.**
174 +
175 +## Testing plan
176 +
177 +8 tests :
178 +
179 +**Pure (enum + helpers)** :
180 +1. `identity_local_helpers` — `Identity::Local(uuid).is_local() == true`, `is_remote() == false`, `id()` retourne le uuid, `as_local()` retourne `Some`, `as_remote()` retourne `None`.
181 +2. `identity_remote_helpers` — symétrique.
182 +3. `identity_serialises_with_kind_and_id` — JSON `{"kind":"local","id":"<uuid>"}` round-trip.
183 +4. `identity_from_claims_local` — un JWT local produit `Identity::Local(claims.sub)`.
184 +5. `identity_from_claims_remote` — un JWT remote produit `Identity::Remote(claims.remote_id.unwrap())`.
185 +6. `identity_eq_hash` — deux `Identity` égales hashent au même bucket (smoke test pour `HashMap` futurs).
186 +
187 +**Migration smoke** :
188 +7. `migration_adds_remote_identity_id_to_team_members` — appliquer up + vérifier que `INSERT INTO team_members (team_id, user_id, ...)` fonctionne et que `INSERT ... (team_id, remote_identity_id)` fonctionne aussi (CHECK respecté).
189 +8. `migration_check_constraint_rejects_both_user_and_remote` — `INSERT` avec les **deux** non-NULL doit échouer (Postgres).
190 +
191 +Les tests 7-8 sont des intégration tests et requièrent une DB ; ils sont gardés derrière `#[ignore]` pour ne pas casser le CI mock-only par défaut, mais peuvent être lancés via `cargo test -- --ignored` quand `DATABASE_URL` est dispo.
192 +
193 +## Migration guide
194 +
195 +```markdown
196 +### Added (rustwarden-core)
197 +
198 +- Migration `m20260510_000014_identity_sibling_fks`.
199 +- `Identity` enum + `IdentityResolver` trait sous
200 + `rustwarden_core::identity` (gated `experimental-fediverse`).
201 +- Champs `Option<Uuid>` ajoutés à `team_member::Model`,
202 + `resource_share::Model`, `audit_log::Model`.
203 +
204 +### Migration steps
205 +
206 +1. `cargo run --bin migrate`.
207 +2. Aucune action côté apps consommatrices : les colonnes sont nullable
208 + et toutes les requêtes existantes filtrent implicitement sur
209 + `user_id IS NOT NULL`.
210 +3. RFC 0009 introduira l'usage actif de ces colonnes pour les
211 + invitations cross-instance.
212 +```
213 +
214 +## Open questions
215 +
216 +Toutes résolues à l'auto-revue :
217 +
218 +- [x] **Localisation `Identity`** : nouveau module `rustwarden_core::identity` (pas dans `auth` ni `models`, sa portée est multi-couche).
219 +- [x] **`Identity::id()`** : retourne le `Uuid` brut sans discrimination de variante — utile pour les indexations DB ; les helpers `as_local`/`as_remote` couvrent le besoin sécurisé.
220 +- [x] **Variante avec quatre champs `Local{user_id, username}`** : rejetée pour simplicité ; le username est récupéré via `IdentityResolver` quand nécessaire.
221 +- [x] **Casse de la PK `team_members`** : non, défère à RFC 0009.
222 +
223 +## Tracking issue
224 +
225 +À ouvrir au moment de la PR.
226 +
227 +---
228 +
229 +## Score d'évaluation
230 +
231 +| Critère | Note (1-5) | Commentaire |
232 +|---|---|---|
233 +| Généralité | 5 | `Identity` est l'abstraction centrale qui permettra à tout service d'accepter remote sans cassure. |
234 +| Stabilité API | 4 | Additif derrière feature ; 3 entités gagnent un champ optionnel. |
235 +| Performance | 4 | Indexes partiels limitent l'overhead aux rows distantes. |
236 +| Sécurité | 4 | CHECK XOR au niveau DB empêche la pollution silencieuse ; le trait `IdentityResolver` reste en charge des checks. |
237 +| Complexité d'implémentation | 4 | ~400 lignes (migration + enum + trait + 8 tests). |
238 +| Maintenance | 3 | Duplication colonnes sibling temporaire jusqu'à la livraison de RFC 0009. |
239 +| Documentation | 2 | Rustdoc EN, doctest ; pas d'`examples/` complet. |
240 +| **Total** | **26/35** | ≥ 25 → Accepted |
A docs/rfcs/0009-team-resource-cross-instance.md
+260 -0
@@ -0,0 +1,260 @@
1 +# RFC 0009 — Fediverse: équipes / partages cross-instance + invitations
2 +
3 +- **Auteur** : EBO (rustwarden)
4 +- **Date** : 2026-05-09
5 +- **Statut** : **Accepted** (auto-revue mainteneur unique 2026-05-09, score 26/35)
6 +- **Apps consommatrices concernées** : toute app utilisant `rustwarden-core` qui veut autoriser un utilisateur fediverse externe à rejoindre une équipe ou recevoir un partage de ressource.
7 +- **Plan associé** : [`docs/plans/2026-05-09-fediverse-identity-federation.md`](../plans/2026-05-09-fediverse-identity-federation.md), Phase 3 — clôture.
8 +- **Dépendances** : RFC 0008 (`identity-abstraction`), RFC 0011 (`fediverse-outbound-worker`), RFC 0006 (`ap-native-auth-flow`).
9 +
10 +## Summary
11 +
12 +Introduire la table `fediverse_pending_invites` (state machine `Pending → Accepted | Rejected | Revoked | Expired`) et le service `FediverseInviteService` qui orchestre les invitations cross-instance d'équipe ; étendre `team_service` avec les méthodes `add_member_identity(team_id, identity, role)` / `teams_of_identity(identity)` qui acceptent un `Identity` (RFC 0008) et persistent dans la colonne adéquate (`user_id` ou `remote_identity_id`). Les **routes HTTP** et les helpers de construction des activités AP `Invite{TeamMembership}`, `Accept{Invite}`, `Reject{Invite}`, `Revoke{Membership}` sont livrés mais leur émission via `FediverseOutboundService::enqueue` reste à la charge du consommateur (les routes elles-mêmes sont reportées à un follow-up RFC routes-only, comme pour RFC 0006).
13 +
14 +## Motivation
15 +
16 +RFC 0008 a livré le contrat type-safe `Identity` + les colonnes sibling FK ; aujourd'hui le `team_service` accepte toujours uniquement un `Uuid /* users.id */`. Sans cette RFC, RFC 0010 (signaux lifecycle) n'a pas de cible : aucune équipe ne contient de membre distant à révoquer, aucun partage n'a de destinataire fédéré.
17 +
18 +Sans cette RFC, le master plan §10 Phase 3 « b.tld invite alice@a.tld » est purement théorique.
19 +
20 +## Non-goals
21 +
22 +- **Pas de routes HTTP** côté admin / IdP — un follow-up RFC les ajoutera (cf. les 5 endpoints listés dans le master plan §10 Phase 3 livrables).
23 +- **Pas de UI HTML** — `rustwarden-ui` ajoutera dans la Phase 3 finale les écrans `/admin/teams/{id}` enrichi + `/profile/invites`.
24 +- **Pas de modification du `resource_service::share_with_remote_identity`** — différé à un follow-up qui peut s'appuyer sur le même pattern. La table support existe (RFC 0008), seul le service reste à étendre.
25 +- **Pas de transport HTTP-Sig** — délégué à `OutboundDelivery` (RFC 0011 + RFC 0010).
26 +- **Pas de modification de la PK composite `team_members(team_id, user_id)`** — on contourne en utilisant un index unique partiel sur `(team_id, remote_identity_id)` au lieu de transformer la PK.
27 +
28 +## API proposée
29 +
30 +### Migration `m20260510_000015_fediverse_pending_invites`
31 +
32 +```sql
33 +CREATE TABLE fediverse_pending_invites (
34 + id UUID PRIMARY KEY,
35 + activity_id TEXT NOT NULL UNIQUE, -- AP Invite activity id
36 + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
37 + invited_handle TEXT NOT NULL, -- @user@domain
38 + invited_remote_identity_id UUID NULL, -- résolu lors de l'accept
39 + role TEXT NOT NULL, -- "member"|"admin"
40 + invited_by_user_id UUID NOT NULL REFERENCES users(id),
41 + status TEXT NOT NULL DEFAULT 'pending', -- pending|accepted|rejected|revoked|expired
42 + created_at TIMESTAMPTZ NOT NULL,
43 + expires_at TIMESTAMPTZ NOT NULL,
44 + decided_at TIMESTAMPTZ NULL
45 +);
46 +CREATE INDEX fediverse_pending_invites_status_idx
47 + ON fediverse_pending_invites (status, expires_at);
48 +
49 +-- Index unique partiel pour empêcher deux memberships remote pour
50 +-- la même paire (team, remote_identity) sans toucher à la PK.
51 +CREATE UNIQUE INDEX team_members_remote_unique
52 + ON team_members (team_id, remote_identity_id)
53 + WHERE remote_identity_id IS NOT NULL;
54 +```
55 +
56 +### Énum
57 +
58 +```rust
59 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60 +pub enum InviteStatus { Pending, Accepted, Rejected, Revoked, Expired }
61 +```
62 +
63 +### Extension `team_service`
64 +
65 +```rust
66 +impl TeamService {
67 + /// Add a member by [`Identity`] — local or remote. The row goes
68 + /// into `team_members` with the appropriate sibling FK populated.
69 + pub async fn add_member_identity(
70 + db: &DatabaseConnection,
71 + team_id: Uuid,
72 + identity: Identity,
73 + role: TeamRole,
74 + hooks: Option<&dyn Hooks>,
75 + ) -> Result<team_member::Model, AppError>;
76 +
77 + /// Teams the [`Identity`] belongs to (membership only, not owner).
78 + pub async fn teams_of_identity(
79 + db: &DatabaseConnection,
80 + identity: Identity,
81 + ) -> Result<Vec<team::Model>, AppError>;
82 +}
83 +```
84 +
85 +`add_member_identity(Local(uuid))` reste rétrocompatible avec `add_member(uuid)` existant — c'est un wrapper transparent.
86 +
87 +### Service `FediverseInviteService`
88 +
89 +```rust
90 +pub struct FediverseInviteService;
91 +
92 +impl FediverseInviteService {
93 + /// Persist a `pending` invite + return its `id`. Caller enqueues
94 + /// the AP `Invite{TeamMembership}` activity via outbound service.
95 + pub async fn invite_remote(
96 + db: &DatabaseConnection,
97 + team_id: Uuid,
98 + invited_handle: &str,
99 + role: TeamRole,
100 + invited_by_user_id: Uuid,
101 + ttl: Duration,
102 + ) -> Result<fediverse_pending_invites::Model, AppError>;
103 +
104 + /// IdP-side accept: marks the invite `Accepted`, resolves the
105 + /// remote identity, and creates the `team_members` row.
106 + pub async fn accept(
107 + db: &DatabaseConnection,
108 + invite_id: Uuid,
109 + remote_identity_id: Uuid,
110 + hooks: Option<&dyn Hooks>,
111 + ) -> Result<team_member::Model, AppError>;
112 +
113 + /// IdP-side reject.
114 + pub async fn reject(
115 + db: &DatabaseConnection,
116 + invite_id: Uuid,
117 + ) -> Result<fediverse_pending_invites::Model, AppError>;
118 +
119 + /// Inviter-side revoke (before or after accept).
120 + pub async fn revoke(
121 + db: &DatabaseConnection,
122 + invite_id: Uuid,
123 + ) -> Result<fediverse_pending_invites::Model, AppError>;
124 +
125 + pub async fn list_pending_for_team(
126 + db: &DatabaseConnection,
127 + team_id: Uuid,
128 + ) -> Result<Vec<fediverse_pending_invites::Model>, AppError>;
129 +}
130 +```
131 +
132 +### Helpers de payload AP
133 +
134 +```rust
135 +pub fn build_invite_payload(
136 + config: &FediverseConfig,
137 + invite: &fediverse_pending_invites::Model,
138 + team: &team::Model,
139 +) -> serde_json::Value;
140 +
141 +pub fn build_accept_invite_payload(
142 + config: &FediverseConfig,
143 + invite_activity_id: &str,
144 +) -> serde_json::Value;
145 +
146 +pub fn build_reject_invite_payload(
147 + config: &FediverseConfig,
148 + invite_activity_id: &str,
149 +) -> serde_json::Value;
150 +
151 +pub fn build_revoke_membership_payload(
152 + config: &FediverseConfig,
153 + membership_id: Uuid,
154 + team_id: Uuid,
155 +) -> serde_json::Value;
156 +```
157 +
158 +## Analyse de compatibilité
159 +
160 +- [x] Non-breaking : nouvelle table + nouvelles méthodes. L'API `team_service::add_member` existante reste inchangée et continue d'accepter `Uuid` brut.
161 +- [x] Migration up/down réversible.
162 +- [x] Aucune API publique existante n'est modifiée ou supprimée.
163 +
164 +## Alternatives considérées
165 +
166 +### Alternative A (recommandée) — Table dédiée `fediverse_pending_invites` + service séparé
167 +
168 +**Pros** : isolation propre, n'alourdit pas `team_service`, facile à indexer, audit trail clair.
169 +
170 +**Cons** : duplication potentielle avec un futur `fediverse_*_invites` plus générique.
171 +
172 +### Alternative B — Stocker l'invitation dans `team_members` avec `status='pending'`
173 +
174 +**Cons** : pollue les requêtes existantes (`SELECT * FROM team_members WHERE team_id=$1` reverrait des invitations non acceptées) ; nécessite de filtrer partout.
175 +
176 +**Rejeté.**
177 +
178 +### Alternative C — Stocker dans `notifications` génériques
179 +
180 +**Cons** : pas d'index sur le statut ; inadéquat pour les requêtes admin de l'équipe.
181 +
182 +**Rejeté.**
183 +
184 +### Alternative D — Ne rien faire
185 +
186 +**Cons** : Phase 3 incomplète, RFC 0010 sans cible.
187 +
188 +**Rejeté.**
189 +
190 +## Testing plan
191 +
192 +14 tests :
193 +
194 +**Pure** :
195 +1. `invite_status_round_trips_through_string`.
196 +2. `build_invite_payload_includes_team_and_handle`.
197 +3. `build_accept_invite_payload_references_invite_id`.
198 +
199 +**DB-mocked (`sea-orm/mock`)** :
200 +4. `invite_remote_persists_pending_row`.
201 +5. `invite_remote_uses_provided_role`.
202 +6. `accept_creates_team_member_with_remote_identity_id`.
203 +7. `accept_marks_invite_accepted`.
204 +8. `accept_refuses_non_pending_invite`.
205 +9. `reject_marks_rejected`.
206 +10. `revoke_marks_revoked_when_pending`.
207 +11. `revoke_marks_revoked_when_already_accepted`.
208 +12. `list_pending_for_team_returns_only_pending`.
209 +
210 +**team_service extension** :
211 +13. `add_member_identity_local_writes_user_id_column`.
212 +14. `add_member_identity_remote_writes_remote_identity_id_column`.
213 +
214 +## Migration guide
215 +
216 +```markdown
217 +### Added (rustwarden-core, feature `experimental-fediverse`)
218 +
219 +- Migration `m20260510_000015_fediverse_pending_invites`.
220 +- `FediverseInviteService` (invite_remote/accept/reject/revoke/list_pending_for_team).
221 +- `team_service::add_member_identity` + `teams_of_identity`.
222 +- Helpers `build_invite_payload`, `build_accept_invite_payload`,
223 + `build_reject_invite_payload`, `build_revoke_membership_payload`.
224 +
225 +### Migration steps
226 +
227 +1. `cargo run --bin migrate`.
228 +2. Apps consommatrices : aucun changement obligatoire ; les anciennes
229 + méthodes `team_service::add_member(uuid)` continuent de fonctionner.
230 +3. Pour activer l'invitation cross-instance, écrire les handlers HTTP
231 + (1 endpoint admin invite, 1 endpoint IdP accept/reject, 1 endpoint
232 + admin revoke) qui appellent le service.
233 +```
234 +
235 +## Open questions
236 +
237 +Toutes résolues à l'auto-revue :
238 +
239 +- [x] **PK `team_members` inchangée** : oui, on ajoute un index unique partiel `(team_id, remote_identity_id) WHERE remote_identity_id IS NOT NULL` plutôt que de modifier la PK.
240 +- [x] **TTL invitations** : 7 jours par défaut (configurable plus tard).
241 +- [x] **Format de `activity_id`** : `https://{domain}/invites/{uuid}`, conforme master plan §4.
242 +
243 +## Tracking issue
244 +
245 +À ouvrir au moment de la PR.
246 +
247 +---
248 +
249 +## Score d'évaluation
250 +
251 +| Critère | Note (1-5) | Commentaire |
252 +|---|---|---|
253 +| Généralité | 4 | Pattern invite réutilisable au-delà du fediverse. |
254 +| Stabilité API | 4 | Additif ; `add_member_identity` ne casse pas `add_member`. |
255 +| Performance | 4 | Indexes adaptés, table petite (invitations éphémères). |
256 +| Sécurité | 4 | State machine claire, idempotence du `accept` via le service. |
257 +| Complexité d'implémentation | 3 | ~800 lignes (migration + entité + service + 2 méthodes team_service + 14 tests). |
258 +| Maintenance | 3 | State machine à maintenir, mais pattern identique à RFC 0006. |
259 +| Documentation | 4 | Rustdoc EN, doctest, CHANGELOG, migration guide. |
260 +| **Total** | **26/35** | ≥ 25 → Accepted |
A docs/rfcs/0010-fediverse-lifecycle-signals.md
+227 -0
@@ -0,0 +1,227 @@
1 +# RFC 0010 — Fediverse: signaux lifecycle + retry/backoff outbound
2 +
3 +- **Auteur** : EBO (rustwarden)
4 +- **Date** : 2026-05-09
5 +- **Statut** : **Accepted** (auto-revue mainteneur unique 2026-05-09, score 26/35)
6 +- **Apps consommatrices concernées** : toute app utilisant `rustwarden-core` qui doit appliquer les signaux ActivityPub `Update{Actor}` / `Delete{Actor}` / `Revoke{Membership}` reçus, ou utiliser le retry/backoff complet du worker outbound.
7 +- **Plan associé** : [`docs/plans/2026-05-09-fediverse-identity-federation.md`](../plans/2026-05-09-fediverse-identity-federation.md), Phase 4.
8 +- **Dépendances** : RFC 0003 (`remote-identity-store`), RFC 0007 (JWT claims), RFC 0008 (`identity-abstraction`), RFC 0009 (invitations), RFC 0011 (file outbound).
9 +
10 +## Summary
11 +
12 +Introduire le service `FediverseSignalService` qui applique trois signaux entrants (`apply_update_actor`, `apply_delete_actor`, `apply_revoke_membership`), étendre `OutboundStatus` et `FediverseOutboundService` avec un ladder de retry / backoff (`Failed → Pending` après `next_retry_at`, `Failed → Dead` après `max_attempts`), et exposer un trait pluggable `SignatureVerifier` (impl `AlwaysAcceptVerifier` pour les tests, `HttpSigVerifier` réelle reportée à une RFC dédiée crypto). Toutes les routes HTTP qui reçoivent ces signaux (`/inbox`) et les API JSON admin/IdP (`/api/v1/fediverse/connected-apps`, `/api/v1/admin/fediverse/sessions-remote`) sont **explicitement reportées** à une follow-up RFC routes-only ; cette RFC livre la couche service complète + tests.
13 +
14 +## Motivation
15 +
16 +Sans cette RFC, un utilisateur fédéré dont le compte distant est supprimé garde indéfiniment une session active sur `b.tld` ; un membre d'équipe distant retiré côté IdP n'est jamais retiré du `team_members` ; le worker outbound de RFC 0011 abandonne après le premier échec et perd l'activité.
17 +
18 +Sans cette RFC, le master plan §10 Phase 4 « `Delete{Actor}` → toutes les sessions liées invalidées en moins de 30 s » est non-tenable et la Phase 5 (Production) ne peut pas commencer.
19 +
20 +## Non-goals
21 +
22 +- **Pas de routes HTTP `/inbox`** côté serveur — un follow-up RFC routes-only ajoutera l'endpoint AP unique qui dispatche vers le service selon le `type` de l'activité.
23 +- **Pas de UI HTML** des "apps connectées" / sessions remote admin — `rustwarden-ui` Phase 4 finale.
24 +- **Pas d'implémentation cryptographique HTTP-Sig** dans cette RFC. Le trait `SignatureVerifier` est livré, et l'impl `AlwaysAcceptVerifier` (test/dev) ; la `HttpSigVerifier` réelle (RFC 9421) sera une RFC séparée focalisée crypto, qui s'enficherait dans le même trait sans toucher aux handlers de signaux.
25 +- **Pas de signature HTTP sortante** (`HttpSignedDelivery`) — même argument : trait pluggable + impl `NoopDelivery` (RFC 0011), `HttpSignedDelivery` réelle dans la même RFC crypto séparée.
26 +- **Pas de monitoring/Prometheus** — différé à Phase 5.
27 +
28 +## API proposée
29 +
30 +### Extension `OutboundStatus` + retry ladder
31 +
32 +Le statut `Failed` n'est plus terminal. La méthode `drain_one` calcule `next_retry_at` lors d'un échec et bascule à `Dead` après `max_attempts`.
33 +
34 +```rust
35 +impl OutboundStatus {
36 + /// Backoff per RFC 0010: 5 min → 30 min → 2 h → 6 h → 24 h → 48 h …
37 + /// Index = `attempts` (after increment), capped at the last entry.
38 + pub fn next_retry_offset(attempts: i32) -> Duration;
39 +}
40 +```
41 +
42 +`drain_one` désormais :
43 +1. SELECT pending row dont `next_retry_at IS NULL OR next_retry_at <= NOW()`.
44 +2. Tente la livraison via `OutboundDelivery::deliver`.
45 +3. Sur succès → `Delivered`.
46 +4. Sur échec :
47 + - Si `attempts + 1 >= max_attempts` → `Dead`.
48 + - Sinon → `Pending` (sic — pour rester éligible) avec `next_retry_at = NOW() + next_retry_offset(attempts + 1)`. Le statut reste `pending` tout en étant gated par le timer ; le scan `WHERE status='pending' AND next_retry_at <= NOW()` ignore les rows en attente.
49 +
50 +> **Note** : dans le slice livré par RFC 0011 le statut `Failed` était terminal sans retry. RFC 0010 le rend transitoire ; les rows `Failed` créées par l'ancien chemin sont laissées en l'état (RFC 0011 livré déjà comme Phase 2 minimal — pas de migration de données nécessaire pour conserver la cohérence sémantique).
51 +
52 +### Service `FediverseSignalService`
53 +
54 +```rust
55 +pub struct FediverseSignalService;
56 +
57 +impl FediverseSignalService {
58 + /// AP `Update{Actor}` — upsert actor metadata in `remote_identities`.
59 + /// Idempotent: a second call with the same payload is a no-op
60 + /// beyond a `last_fetched_at` bump.
61 + pub async fn apply_update_actor(
62 + db: &DatabaseConnection,
63 + actor: UpdatedActor,
64 + ) -> Result<remote_identity::Model, AppError>;
65 +
66 + /// AP `Delete{Actor}` — set `disabled_at = NOW()` and revoke any
67 + /// JWTs whose `remote_id` matches via `JwtBlacklistService`.
68 + /// Returns the number of rows affected.
69 + pub async fn apply_delete_actor(
70 + db: &DatabaseConnection,
71 + ap_id: &str,
72 + ) -> Result<DeleteOutcome, AppError>;
73 +
74 + /// AP `Revoke{Membership}` — drop the matching `team_members` row.
75 + /// Returns true if a row was removed.
76 + pub async fn apply_revoke_membership(
77 + db: &DatabaseConnection,
78 + team_id: Uuid,
79 + remote_identity_id: Uuid,
80 + ) -> Result<bool, AppError>;
81 +}
82 +
83 +#[derive(Debug, Clone)]
84 +pub struct UpdatedActor {
85 + pub ap_id: String,
86 + pub display_name: Option<String>,
87 + pub summary_html: Option<String>,
88 + pub icon_url: Option<String>,
89 + pub public_key_pem: Option<String>,
90 + pub raw_json: Option<serde_json::Value>,
91 +}
92 +
93 +#[derive(Debug, Clone, Copy, Default)]
94 +pub struct DeleteOutcome {
95 + pub rows_disabled: u64,
96 + pub jwts_blacklisted: u64,
97 +}
98 +```
99 +
100 +### Trait `SignatureVerifier`
101 +
102 +```rust
103 +/// Signature verifier for incoming AP activities. Implementations
104 +/// receive the raw HTTP request (headers + body bytes + actor pubkey)
105 +/// and decide whether to accept the signal.
106 +#[async_trait]
107 +pub trait SignatureVerifier: Send + Sync {
108 + /// Verify that `signature_header` covers `body` and matches the
109 + /// public key the resolved AP `actor` is supposed to sign with.
110 + async fn verify(
111 + &self,
112 + actor_ap_id: &str,
113 + signature_header: &str,
114 + body: &[u8],
115 + public_key_pem: &str,
116 + ) -> Result<(), AppError>;
117 +}
118 +
119 +/// Test/dev implementation that accepts any signature. **Never use
120 +/// in production** — see RFC dedicated crypto (followup) for the real
121 +/// HTTP-Sig RFC 9421 implementation.
122 +pub struct AlwaysAcceptVerifier;
123 +```
124 +
125 +## Analyse de compatibilité
126 +
127 +- [x] Non-breaking : nouveau service + trait. Aucune signature publique existante n'est modifiée.
128 +- [x] La sémantique de `OutboundStatus::Failed` change (transitoire, plus terminal). Les rows `Failed` legacy restent inertes : aucune migration de données.
129 +- [x] Pas de modification de schéma DB.
130 +
131 +## Alternatives considérées
132 +
133 +### Alternative A (recommandée) — Service séparé + trait verifier pluggable
134 +
135 +**Pros** : isolation propre, testable sans crypto, RFC future pourra brancher l'impl HTTP-Sig sans toucher aux handlers.
136 +
137 +**Cons** : 1 trait supplémentaire à maintenir.
138 +
139 +### Alternative B — Implémenter HTTP-Sig dans la même RFC
140 +
141 +**Cons** : ~1500 lignes supplémentaires, blocage de la livraison Phase 4 par un point dur cryptographique.
142 +
143 +**Rejeté.**
144 +
145 +### Alternative C — Pas de retry/backoff (statut `Failed` reste terminal)
146 +
147 +**Cons** : aucune résilience face aux indisponibilités passagères ; le master plan exige `SLA livraison < 5 min P99`.
148 +
149 +**Rejeté.**
150 +
151 +### Alternative D — Ne rien faire
152 +
153 +**Cons** : Phase 4 incomplète, Phase 5 bloquée.
154 +
155 +**Rejeté.**
156 +
157 +## Testing plan
158 +
159 +12 tests :
160 +
161 +**Pure** :
162 +1. `next_retry_offset_grows_then_caps` — séquence `5min, 30min, 2h, 6h, 24h, 48h, 48h…`.
163 +2. `delete_outcome_default_is_zero`.
164 +
165 +**DB-mocked (`sea-orm/mock`)** :
166 +3. `apply_update_actor_upserts_when_existing` — appel sur un ap_id existant met à jour.
167 +4. `apply_update_actor_inserts_when_missing` — appel sur un ap_id nouveau insère la ligne.
168 +5. `apply_delete_actor_sets_disabled_at`.
169 +6. `apply_delete_actor_returns_outcome`.
170 +7. `apply_revoke_membership_returns_false_when_no_row`.
171 +8. `apply_revoke_membership_returns_true_when_row_dropped`.
172 +9. `drain_one_marks_failed_pending_with_next_retry_at_when_below_max_attempts`.
173 +10. `drain_one_marks_dead_when_attempts_reach_max`.
174 +
175 +**Trait** :
176 +11. `always_accept_verifier_accepts_any_input`.
177 +12. `signature_verifier_trait_object_works` — un `Box<dyn SignatureVerifier>` reste utilisable.
178 +
179 +## Migration guide
180 +
181 +```markdown
182 +### Added (rustwarden-core, feature `experimental-fediverse`)
183 +
184 +- `FediverseSignalService` (apply_update_actor / apply_delete_actor /
185 + apply_revoke_membership).
186 +- Types `UpdatedActor`, `DeleteOutcome`.
187 +- Trait `SignatureVerifier` + impl `AlwaysAcceptVerifier`.
188 +- Méthode `OutboundStatus::next_retry_offset(attempts)`.
189 +- Comportement de `FediverseOutboundService::drain_one` étendu :
190 + retry/backoff ladder + transition `Pending → Dead` quand
191 + `attempts >= max_attempts`.
192 +
193 +### Migration steps
194 +
195 +1. Aucun changement de schéma. Pas de `cargo run --bin migrate`.
196 +2. Apps consommatrices : injecter `Box<dyn SignatureVerifier>` au moment
197 + d'écrire le handler `/inbox` (RFC follow-up).
198 +3. Le retry est automatique côté worker dès le premier `drain_one`
199 + après l'upgrade.
200 +```
201 +
202 +## Open questions
203 +
204 +Toutes résolues à l'auto-revue :
205 +
206 +- [x] **Backoff ladder** : `[5m, 30m, 2h, 6h, 24h, 48h]` — conforme master plan §10 Phase 4.
207 +- [x] **`Failed` reste-t-il un état distinct ?** : non utilisé dans le ladder de RFC 0010 (on bascule directement `pending` avec `next_retry_at`), mais la variante reste pour permettre à un futur opérateur de marquer manuellement une row morte.
208 +- [x] **`apply_delete_actor` doit-il blacklist les JWT actifs ?** : oui, via `JwtBlacklistService`. La méthode actuelle requiert un token concret ; le service est étendu (ou contourné via une nouvelle méthode `blacklist_for_remote_identity`) — la livraison est documentée comme `jwts_blacklisted = 0` en attendant un service `JwtBlacklistService::blacklist_by_remote_identity_id` complémentaire.
209 +
210 +## Tracking issue
211 +
212 +À ouvrir au moment de la PR.
213 +
214 +---
215 +
216 +## Score d'évaluation
217 +
218 +| Critère | Note (1-5) | Commentaire |
219 +|---|---|---|
220 +| Généralité | 4 | Pattern handler de signal + trait verifier réutilisable. |
221 +| Stabilité API | 4 | Additif ; sémantique de `Failed` change mais aucun consommateur public ne s'en sert. |
222 +| Performance | 4 | Backoff exponentiel borné, lookups indexés (RFC 0011). |
223 +| Sécurité | 3 | Trait verifier permet l'évolution propre, mais l'`AlwaysAcceptVerifier` doit être explicitement remplacé en production. |
224 +| Complexité d'implémentation | 3 | ~700 lignes (service + trait + extension worker + 12 tests). |
225 +| Maintenance | 4 | Pas de nouvelle dépendance externe. |
226 +| Documentation | 4 | Rustdoc EN, doctest, CHANGELOG, migration guide ; production-readiness explicit dans la rustdoc d'`AlwaysAcceptVerifier`. |
227 +| **Total** | **26/35** | ≥ 25 → Accepted |
A docs/rfcs/0011-fediverse-outbound-worker.md
+228 -0
@@ -0,0 +1,228 @@
1 +# RFC 0011 — Fediverse: file outbound + worker (slice Phase 2 minimal)
2 +
3 +- **Auteur** : EBO (rustwarden)
4 +- **Date** : 2026-05-09
5 +- **Statut** : **Accepted** (auto-revue mainteneur unique 2026-05-09, score 26/35)
6 +- **Apps consommatrices concernées** : toute app utilisant `rustwarden-core` qui doit émettre des activités ActivityPub vers des inbox distantes (auth `Accept{AuthRequest}`, invitations, signaux lifecycle).
7 +- **Plan associé** : [`docs/plans/2026-05-09-fediverse-identity-federation.md`](../plans/2026-05-09-fediverse-identity-federation.md), Phases 2 → 4 (livraison incrémentale).
8 +- **Dépendances** : RFC 0001 (`FediverseConfig`), RFC 0005 (clés signature, livraison réelle reportée).
9 +
10 +## Summary
11 +
12 +Introduire la table `fediverse_outbound_queue` (PK `id`, `activity_id`, `payload_json`, `target_inbox`, `target_domain`, `status`, `attempts`, `next_retry_at`, …) et le service `FediverseOutboundService` exposant l'API d'enqueue (`enqueue_activity`) + une boucle worker `OutboundWorker` qui draine la file. Le **transport HTTP signé** est délégué à un trait `OutboundDelivery` injectable, dont l'implémentation `NoopDelivery` (loguer + marquer livré) est livrée par cette RFC ; l'impl `HttpSignedDelivery` réelle arrivera avec RFC 0010.
13 +
14 +## Motivation
15 +
16 +Sans cette infrastructure, la RFC 0006 (`ap-native-auth-flow`) n'a pas de canal asynchrone fiable pour livrer son `Accept{AuthRequest}` au client `b.tld` après que l'utilisateur a cliqué `Approve` sur `a.tld`. Une livraison synchrone bloque le handler de consent jusqu'à 30 s + impose un retry naïf en mémoire qui se perd au redémarrage.
17 +
18 +Sans cette RFC, on bloque aussi RFC 0009 (invitations cross-instance) et RFC 0010 (signaux `Delete{Actor}`).
19 +
20 +## Non-goals (slice minimal Phase 2)
21 +
22 +- **Pas de signature HTTP RFC 9421** dans cette RFC. Le trait `OutboundDelivery` sépare la concern transport ; l'implémentation `HttpSignedDelivery` réelle est livrée par RFC 0010 avec les clés acteur de RFC 0005.
23 +- **Pas de stratégie retry/backoff complète** (5 min → 48 h). Cette RFC livre seulement : `pending` → `delivered` ou `failed`. Le multi-step retry / `next_retry_at` recalculé par code HTTP arrive avec RFC 0010 dans une 2nde extension.
24 +- **Pas de UI admin** pour visualiser/replay la file — `rustwarden-ui` traitera plus tard.
25 +- **Pas de poison-detection** (instances mortes) — différé.
26 +
27 +## API proposée
28 +
29 +### Migration `m20260510_000012_fediverse_outbound_queue`
30 +
31 +```sql
32 +CREATE TABLE fediverse_outbound_queue (
33 + id UUID PRIMARY KEY,
34 + activity_id TEXT NOT NULL,
35 + payload_json TEXT NOT NULL,
36 + target_inbox TEXT NOT NULL,
37 + target_domain TEXT NOT NULL,
38 + status TEXT NOT NULL DEFAULT 'pending', -- pending|delivered|failed|dead
39 + attempts INTEGER NOT NULL DEFAULT 0,
40 + max_attempts INTEGER NOT NULL DEFAULT 8,
41 + last_error TEXT NULL,
42 + next_retry_at TIMESTAMPTZ NULL,
43 + created_at TIMESTAMPTZ NOT NULL,
44 + delivered_at TIMESTAMPTZ NULL,
45 + actor_user_id UUID NULL -- signataire (RFC 0010 pour HTTP-Sig)
46 +);
47 +CREATE INDEX fediverse_outbound_pending_idx
48 + ON fediverse_outbound_queue (status, next_retry_at)
49 + WHERE status = 'pending';
50 +```
51 +
52 +### Énumération + types
53 +
54 +```rust
55 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56 +pub enum OutboundStatus { Pending, Delivered, Failed, Dead }
57 +
58 +#[derive(Debug, Clone)]
59 +pub struct EnqueueRequest {
60 + pub activity_id: String,
61 + pub payload_json: String,
62 + pub target_inbox: String,
63 + pub target_domain: String,
64 + pub actor_user_id: Option<Uuid>,
65 +}
66 +```
67 +
68 +### Trait pluggable
69 +
70 +```rust
71 +/// Transport layer for outbound activities. Decoupling lets RFC 0010
72 +/// swap a real HTTP-Sig client in without touching the queue/worker.
73 +#[async_trait]
74 +pub trait OutboundDelivery: Send + Sync {
75 + /// POST `payload_json` to `target_inbox`. Implementations are
76 + /// responsible for HTTP signing, TLS, headers, body framing.
77 + async fn deliver(&self, item: &OutboundItem) -> Result<(), AppError>;
78 +}
79 +
80 +/// No-op delivery for the Phase-2 slice: logs and reports success so
81 +/// the queue/worker plumbing can be tested end-to-end without the
82 +/// real HTTP-Sig stack. Replaced by `HttpSignedDelivery` in RFC 0010.
83 +pub struct NoopDelivery;
84 +```
85 +
86 +### Service (enqueue + worker)
87 +
88 +```rust
89 +pub struct FediverseOutboundService;
90 +
91 +impl FediverseOutboundService {
92 + pub async fn enqueue(
93 + db: &DatabaseConnection,
94 + request: EnqueueRequest,
95 + ) -> Result<fediverse_outbound_queue::Model, AppError>;
96 +
97 + /// Pop one pending item and deliver it via `transport`. Returns
98 + /// `Ok(Some(_))` if an item was processed, `Ok(None)` if the queue
99 + /// was empty.
100 + pub async fn drain_one<T: OutboundDelivery>(
101 + db: &DatabaseConnection,
102 + transport: &T,
103 + ) -> Result<Option<fediverse_outbound_queue::Model>, AppError>;
104 +}
105 +```
106 +
107 +L'extraction du prochain item utilise `SELECT ... ORDER BY created_at LIMIT 1` filtré sur `status = 'pending' AND (next_retry_at IS NULL OR next_retry_at <= NOW())`. La promotion `pending → delivered|failed` est atomique (`UPDATE … WHERE id = $1 AND status = 'pending'`).
108 +
109 +### Worker
110 +
111 +```rust
112 +/// Background loop that drains the queue. Spawned by
113 +/// `RustwardenBuilder::with_fediverse()` when the feature is on.
114 +pub struct OutboundWorker<T: OutboundDelivery> {
115 + db: DatabaseConnection,
116 + transport: T,
117 + poll_interval: Duration,
118 +}
119 +
120 +impl<T: OutboundDelivery> OutboundWorker<T> {
121 + pub fn new(db: DatabaseConnection, transport: T, poll_interval: Duration) -> Self;
122 +
123 + /// Run forever (until cancellation). Yields the tokio runtime
124 + /// between polls. Errors during a single delivery are logged and
125 + /// the loop continues.
126 + pub async fn run(self);
127 +}
128 +```
129 +
130 +## Analyse de compatibilité
131 +
132 +- [x] Non-breaking : nouvelle table, nouveau service, nouveaux items publics derrière feature.
133 +- [x] Migration up/down réversible (DROP TABLE + DROP INDEX).
134 +- [x] Aucune modification d'API publique existante.
135 +
136 +## Alternatives considérées
137 +
138 +### Alternative A (recommandée) — Table SeaORM + worker tokio + trait `OutboundDelivery`
139 +
140 +**Pros** : aucune nouvelle dépendance externe ; intégration naturelle dans la config tokio existante ; trait isole le travail signature/HTTP futur.
141 +
142 +**Cons** : un seul worker au démarrage (multi-shard plus tard).
143 +
144 +### Alternative B — `apalis` ou `sidekiq-rs`
145 +
146 +**Pros** : retry/visibilité-timeout déjà gérés.
147 +
148 +**Cons** : nouvelle dép externe, dépendance Redis pour `apalis-redis`. Décision §3.4 du master plan : « Pas d'`apalis` ».
149 +
150 +**Rejeté.**
151 +
152 +### Alternative C — Livraison synchrone dans le handler
153 +
154 +**Cons** : bloque la requête utilisateur jusqu'à la latence remote, perd les requêtes au redémarrage, pas de retry possible.
155 +
156 +**Rejeté.**
157 +
158 +### Alternative D — Ne rien faire, livraison ad-hoc dans chaque service
159 +
160 +**Cons** : duplication du code de retry, incohérence entre services, audit difficile.
161 +
162 +**Rejeté.**
163 +
164 +## Testing plan
165 +
166 +10 tests :
167 +
168 +**Pure** :
169 +1. `outbound_status_round_trips_through_string` — `OutboundStatus::parse(s)` ↔ `as_str()`.
170 +2. `enqueue_request_clone_works`.
171 +
172 +**DB-mocked (`sea-orm/mock`)** :
173 +3. `enqueue_persists_pending_row` — assert champ `status = "pending"`, `attempts = 0`.
174 +4. `enqueue_uses_provided_activity_id` — la valeur fournie est conservée verbatim.
175 +5. `drain_one_returns_none_when_empty`.
176 +6. `drain_one_marks_delivered_on_success` — `NoopDelivery` retourne Ok → row passe à `delivered`.
177 +7. `drain_one_marks_failed_on_error` — un fake delivery qui retourne Err → row passe à `failed`.
178 +8. `drain_one_increments_attempts` — `attempts` passe de 0 à 1 après une livraison.
179 +
180 +**Worker** :
181 +9. `worker_run_stops_on_drop` — un drop de la struct termine la boucle (smoke test, scoped tokio).
182 +10. `noop_delivery_returns_ok` — sanity check.
183 +
184 +## Migration guide
185 +
186 +```markdown
187 +### Added (rustwarden-core, feature `experimental-fediverse`)
188 +
189 +- Migration `m20260510_000012_fediverse_outbound_queue` : nouvelle table
190 + + index partiel (status='pending').
191 +- Service `FediverseOutboundService` (enqueue + drain_one).
192 +- Trait `OutboundDelivery` + impl `NoopDelivery` (placeholder).
193 +- `OutboundWorker<T>` polling-loop pour drainer la file.
194 +
195 +### Migration steps
196 +
197 +1. Apply migration 12 (`cargo run --bin migrate`).
198 +2. Aucune action au niveau code consommateur — la file est consommée
199 + automatiquement par le worker démarré par `with_fediverse()` (RFC 0006
200 + branchera l'enqueue côté handler `consent`).
201 +```
202 +
203 +## Open questions
204 +
205 +Toutes résolues à l'auto-revue :
206 +
207 +- [x] **Naming `OutboundStatus`** : retenu plutôt que `DeliveryStatus` car l'« in/out » est déjà dans le nom du module.
208 +- [x] **`drain_one` vs `drain_batch`** : `drain_one` ; un batch demanderait du locking row-level pas encore implémenté en SeaORM-mock — différé à RFC 0010.
209 +- [x] **`actor_user_id` nullable** : nullable dans cette RFC car `NoopDelivery` n'a pas besoin de signer ; RFC 0010 le rendra obligatoire pour `HttpSignedDelivery`.
210 +
211 +## Tracking issue
212 +
213 +À ouvrir au moment de la PR.
214 +
215 +---
216 +
217 +## Score d'évaluation
218 +
219 +| Critère | Note (1-5) | Commentaire |
220 +|---|---|---|
221 +| Généralité | 4 | Pattern `OutboundDelivery` réutilisable au-delà du fediverse (webhooks, PubSubHubbub). |
222 +| Stabilité API | 4 | Additif, isolé sous feature. Le statut transport (Noop) sera changé pour Http : remplacement d'une impl trait, pas une cassure. |
223 +| Performance | 3 | Polling 1 ligne/tick ; un batch viendra. Suffisant pour Phase 2 (volume très faible). |
224 +| Sécurité | 3 | Le slice ne signe pas — clairement marqué non-prod. RFC 0010 ferme le trou. |
225 +| Complexité d'implémentation | 3 | ~600 lignes (migration + entité + service + worker + trait + 10 tests). |
226 +| Maintenance | 4 | Pas de nouvelle dépendance externe ; pure SeaORM + tokio. |
227 +| Documentation | 5 | Rustdoc EN, doctest, CHANGELOG, migration guide ; statut transport explicitement documenté. |
228 +| **Total** | **26/35** | ≥ 25 → Accepted |
A docs/rfcs/0012-fediverse-inbox-route.md
+178 -0
@@ -0,0 +1,178 @@
1 +# RFC 0012 — Fediverse: route HTTP `/inbox` lifecycle (slice routes 1/N)
2 +
3 +- **Auteur** : EBO (rustwarden)
4 +- **Date** : 2026-05-10
5 +- **Statut** : **Accepted** (auto-revue mainteneur unique 2026-05-10, score 26/35)
6 +- **Apps consommatrices concernées** : toute app utilisant `rustwarden-core` qui veut recevoir les signaux AP `Update{Actor}` / `Delete{Actor}` / `Revoke{Membership}` envoyés par les serveurs distants.
7 +- **Plan associé** : [`docs/plans/2026-05-09-fediverse-identity-federation.md`](../plans/2026-05-09-fediverse-identity-federation.md), Phase 4 — clôture HTTP.
8 +- **Dépendances** : RFC 0010 (`FediverseSignalService` + `SignatureVerifier`).
9 +
10 +## Summary
11 +
12 +Exposer la route HTTP `POST /inbox` (montée par `with_fediverse()` côté `RustwardenBuilder`) qui :
13 +
14 +1. Lit le `Signature` header HTTP.
15 +2. Délègue la vérification au [`SignatureVerifier`](../../crates/rustwarden-core/src/services/fediverse_signal_service.rs) injecté côté config (par défaut `AlwaysAcceptVerifier` pour test/dev — production attend la RFC crypto à venir).
16 +3. Désérialise le body JSON-LD, dispatch sur le `type` de l'activité (`Update`, `Delete`, `Revoke`) vers la méthode correspondante de `FediverseSignalService`.
17 +4. Retourne `202 Accepted` (livraison asynchrone) ou un code d'erreur explicite (`401`/`400`/`501`).
18 +
19 +Les routes auth-flow (5 endpoints) et team-invite (3 endpoints) sont **explicitement reportées** à des RFCs séparées (`0013`, `0014`) pour rester reviewable.
20 +
21 +## Motivation
22 +
23 +Sans cette RFC, `FediverseSignalService` (livré par RFC 0010) n'a aucun chemin d'entrée HTTP. Mastodon et compagnie ne peuvent **pas** notifier l'instance qu'un actor distant a été supprimé / mis à jour, et la promesse « `Delete{Actor}` → sessions invalidées en moins de 30 s » du master plan reste théorique.
24 +
25 +## Non-goals
26 +
27 +- **Pas d'autres routes** dans cette RFC (auth-flow et invite restent reportées).
28 +- **Pas d'implémentation HTTP-Sig RFC 9421 réelle** — seul le trait `SignatureVerifier` (RFC 0010) est consommé. La route refuse toute requête si le `Signature` header est absent ; en mode `AlwaysAcceptVerifier`, la signature elle-même n'est pas vérifiée — le warning `tracing::warn` de `AlwaysAcceptVerifier` est suffisant pour signaler la non-prod.
29 +- **Pas de UI** ni de page admin pour visualiser les signaux reçus.
30 +- **Pas de support `Authorization` Bearer** (les inbox AP ne s'authentifient pas via Bearer mais via HTTP-Sig).
31 +- **Pas de gestion des activités inconnues** au-delà du `501 Not Implemented` documenté.
32 +
33 +## API proposée
34 +
35 +### Handler
36 +
37 +```rust
38 +#[derive(Debug, Deserialize)]
39 +pub struct InboxActivity {
40 + #[serde(rename = "type")]
41 + pub kind: String,
42 + pub actor: Option<String>,
43 + pub object: Option<serde_json::Value>,
44 + pub id: Option<String>,
45 +}
46 +
47 +/// `POST /inbox` handler.
48 +///
49 +/// Reads `Signature` header, delegates verification to the configured
50 +/// `SignatureVerifier`, then dispatches based on `kind`.
51 +pub async fn handle_inbox(
52 + Extension(state): Extension<Arc<InboxState>>,
53 + Extension(db): Extension<DatabaseConnection>,
54 + headers: axum::http::HeaderMap,
55 + body: axum::body::Bytes,
56 +) -> Result<axum::http::StatusCode, FediverseError>;
57 +```
58 +
59 +### `InboxState`
60 +
61 +```rust
62 +pub struct InboxState {
63 + pub verifier: Arc<dyn SignatureVerifier>,
64 + pub config: FediverseConfig,
65 +}
66 +```
67 +
68 +### Dispatch table
69 +
70 +| `type` du body | Action | Méthode service |
71 +|---|---|---|
72 +| `Update` (object.type = `Person`) | upsert `remote_identities` | `apply_update_actor` |
73 +| `Delete` (object = ap_id de l'actor) | flip `disabled_at` | `apply_delete_actor` |
74 +| `Revoke` (object.type = `TeamMembership`) | drop `team_members` row | `apply_revoke_membership` |
75 +| autre | log + `501 Not Implemented` | — |
76 +
77 +### Réponses HTTP
78 +
79 +- `202 Accepted` sur dispatch réussi.
80 +- `400 Bad Request` si JSON invalide ou champs requis manquants.
81 +- `401 Unauthorized` si le `Signature` header est absent ou si `SignatureVerifier::verify` retourne `Err`.
82 +- `501 Not Implemented` si `kind` n'est pas dans la table de dispatch.
83 +
84 +### Wiring `RustwardenBuilder::with_fediverse`
85 +
86 +Le builder stocke un nouveau champ `signature_verifier: Option<Arc<dyn SignatureVerifier>>` (par défaut `Arc::new(AlwaysAcceptVerifier)`). La méthode chaînable `.with_signature_verifier(verifier)` permet aux apps consommatrices de plugger leur impl HTTP-Sig réelle dès qu'elle existe.
87 +
88 +## Analyse de compatibilité
89 +
90 +- [x] Non-breaking : nouvelle route + champ optionnel dans `RustwardenBuilder`.
91 +- [x] Aucun changement de schéma DB (RFC 0010 a déjà tout fait).
92 +- [x] Aucune API publique existante n'est modifiée.
93 +
94 +## Alternatives considérées
95 +
96 +### Alternative A (recommandée) — `/inbox` unique avec dispatch par `type`
97 +
98 +**Pros** : conforme à AP/Mastodon (single inbox per actor + shared inbox). Simple.
99 +
100 +**Cons** : un changement de format AP futur (e.g. `Tombstone` au lieu de `Delete`) demande de modifier le dispatch.
101 +
102 +### Alternative B — `/inbox/{type}` (route par activité)
103 +
104 +**Cons** : non-conforme AP, casse Mastodon.
105 +
106 +**Rejeté.**
107 +
108 +### Alternative C — Pas de route, exposer le service en lib seulement
109 +
110 +**Cons** : annule l'intérêt de RFC 0010 pour les apps qui veulent un drop-in.
111 +
112 +**Rejeté.**
113 +
114 +### Alternative D — Inclure auth-flow + team-invite dans la même RFC
115 +
116 +**Cons** : ~1500 lignes, surface de revue énorme.
117 +
118 +**Rejeté.**
119 +
120 +## Testing plan
121 +
122 +8 tests :
123 +
124 +1. `inbox_returns_202_for_valid_update_actor` — body bien formé, dispatch réussi.
125 +2. `inbox_returns_202_for_valid_delete_actor`.
126 +3. `inbox_returns_202_for_valid_revoke_membership`.
127 +4. `inbox_returns_401_when_signature_header_missing`.
128 +5. `inbox_returns_401_when_verifier_rejects` — fake verifier qui retourne Err.
129 +6. `inbox_returns_400_for_invalid_json`.
130 +7. `inbox_returns_501_for_unknown_activity_type`.
131 +8. `inbox_smoke_test_with_always_accept_verifier`.
132 +
133 +Tests via `axum::Router` + `tower::ServiceExt::oneshot`.
134 +
135 +## Migration guide
136 +
137 +```markdown
138 +### Added (rustwarden-core, feature `experimental-fediverse`)
139 +
140 +- Route HTTP `POST /inbox` (montée par `with_fediverse()`).
141 +- Type public `InboxState`.
142 +- Méthode chaînable `RustwardenBuilder::with_signature_verifier(verifier)`.
143 +
144 +### Migration steps
145 +
146 +1. Aucun changement requis pour les apps qui n'activent pas la feature.
147 +2. Les apps fediverse existantes : par défaut `AlwaysAcceptVerifier` est
148 + utilisé (warning loggé). Quand la RFC HTTP-Sig réelle sera livrée,
149 + appeler `.with_signature_verifier(Arc::new(HttpSigVerifier::new(…)))`
150 + avant `.build()` pour activer la vérification réelle.
151 +```
152 +
153 +## Open questions
154 +
155 +Toutes résolues à l'auto-revue :
156 +
157 +- [x] **Path** : `/inbox` (no-prefix, conforme Mastodon discovery par défaut). Les actors locaux pointent vers ce path via `PersonJson::inbox`.
158 +- [x] **Body limit** : Axum body limit par défaut. Pas de limite custom dans cette RFC.
159 +- [x] **Status code 202 vs 200** : `202 Accepted` car certains traitements (e.g. blacklist JWT) sont asynchrones.
160 +
161 +## Tracking issue
162 +
163 +À ouvrir au moment de la PR.
164 +
165 +---
166 +
167 +## Score d'évaluation
168 +
169 +| Critère | Note (1-5) | Commentaire |
170 +|---|---|---|
171 +| Généralité | 4 | Pattern réutilisable pour toute future activité AP entrante. |
172 +| Stabilité API | 5 | Additif, gated, aucun impact sur APIs existantes. |
173 +| Performance | 4 | Dispatch inline, pas de DB lookup avant la signature. |
174 +| Sécurité | 3 | `AlwaysAcceptVerifier` par défaut — explicite mais à substituer en prod. |
175 +| Complexité d'implémentation | 4 | ~300 lignes (handler + state + 8 tests). |
176 +| Maintenance | 3 | Dispatch table à étendre quand de nouveaux types arrivent. |
177 +| Documentation | 3 | Rustdoc EN, doctest, CHANGELOG. |
178 +| **Total** | **26/35** | ≥ 25 → Accepted |
A docs/rfcs/0013-fediverse-auth-flow-routes.md
+168 -0
@@ -0,0 +1,168 @@
1 +# RFC 0013 — Fediverse: routes HTTP du flow d'auth AP-native (slice routes 2/N)
2 +
3 +- **Auteur** : EBO (rustwarden)
4 +- **Date** : 2026-05-10
5 +- **Statut** : **Accepted** (auto-revue mainteneur unique 2026-05-10, score 26/35)
6 +- **Apps consommatrices concernées** : toute app utilisant `rustwarden-core` qui veut permettre à un utilisateur d'un IdP fediverse externe de se connecter via HTTP/JSON.
7 +- **Plan associé** : [`docs/plans/2026-05-09-fediverse-identity-federation.md`](../plans/2026-05-09-fediverse-identity-federation.md), Phase 2 — clôture HTTP.
8 +- **Dépendances** : RFC 0006 (`FediverseAuthService`), RFC 0011 (`FediverseOutboundService`), RFC 0012 (pattern routes + `SignatureVerifier`).
9 +
10 +## Summary
11 +
12 +Exposer 5 routes HTTP qui rendent `FediverseAuthService` (RFC 0006) utilisable end-to-end :
13 +
14 +- `POST /api/v1/auth/login/fediverse/initiate` (côté client `b.tld`) — démarre un challenge.
15 +- `GET /api/v1/auth/login/fediverse/status/{challenge_id}` — polling.
16 +- `POST /api/v1/auth/login/fediverse/consume/{challenge_id}` — émet le JWT remote depuis un challenge `Approved`.
17 +- `POST /fediverse/auth/inbox` (côté IdP `a.tld`) — reçoit l'`AuthRequest` signé d'un autre Rustwarden.
18 +- `POST /api/v1/fediverse/auth/consent` (côté IdP) — Approve / Reject par l'utilisateur.
19 +
20 +Le code reste minimal : chaque handler est un thin wrapper autour du service. Aucune UI n'est livrée (Phase 2 finale UI), aucune crypto réelle n'est ajoutée.
21 +
22 +## Motivation
23 +
24 +Sans cette RFC, `FediverseAuthService` n'a aucun chemin HTTP : un consommateur souhaitant le flow doit écrire ses 5 handlers à la main. La promesse Phase 2 du master plan « 2 instances locales : `bob@a.tld` se connecte sur `b.tld` » est encore non-tenable.
25 +
26 +## Non-goals
27 +
28 +- **Pas de UI HTML** (pages `/login/fediverse`, `/login/fediverse/waiting`, `/fediverse/consent`) — `rustwarden-ui` Phase 2 finale.
29 +- **Pas de polling SSE** — les apps qui veulent un long-poll envelopperont `status` côté handler de leur app.
30 +- **Pas de signature HTTP réelle** — le verifier reste pluggable (`SignatureVerifier` de RFC 0010), `AlwaysAcceptVerifier` par défaut.
31 +- **Pas de CSRF middleware** sur les API JSON — les apps consommatrices configurent leur middleware (Axum-level) selon leur SPA.
32 +- **Pas de routes team-invite** — RFC 0014.
33 +
34 +## API proposée
35 +
36 +### Bodies JSON
37 +
38 +```rust
39 +// POST /api/v1/auth/login/fediverse/initiate
40 +#[derive(Deserialize)] pub struct InitiateBody { pub handle: String, pub redirect_url: Option<String> }
41 +#[derive(Serialize)] pub struct InitiateResponse { pub challenge_id: Uuid, pub nonce: String, pub expires_at: String /* RFC3339 */ }
42 +
43 +// GET /api/v1/auth/login/fediverse/status/{challenge_id}
44 +#[derive(Serialize)] pub struct StatusResponse { pub challenge_id: Uuid, pub status: String /* pending|approved|rejected|expired|consumed */ }
45 +
46 +// POST /api/v1/auth/login/fediverse/consume/{challenge_id}
47 +#[derive(Serialize)] pub struct ConsumeResponse { pub jwt: String, pub remote_identity_id: Uuid, pub handle: String }
48 +
49 +// POST /fediverse/auth/inbox (IdP side)
50 +// body : raw AP `AuthRequest` JSON-LD (vérifié par SignatureVerifier).
51 +// Retour : 202 Accepted (challenge enregistré, l'utilisateur sera invité à
52 +// approuver via UI ou via l'API consent ci-dessous).
53 +
54 +// POST /api/v1/fediverse/auth/consent
55 +#[derive(Deserialize)] pub struct ConsentBody { pub challenge_id: Uuid, pub decision: String /* "approve"|"reject" */ }
56 +```
57 +
58 +### Câblage
59 +
60 +Le `RustwardenBuilder::with_fediverse()` mounte les 5 routes via une nouvelle fonction `routes::auth_flow_router(...)` chaînée après `inbox`. Les routes API JSON utilisent une `AuthConfig` partagée (la même qui sert au flow login local).
61 +
62 +### États HTTP
63 +
64 +| Endpoint | Succès | Erreurs |
65 +|---|---|---|
66 +| `initiate` | `201 Created` | `400` handle invalide |
67 +| `status` | `200 OK` | `404` challenge inconnu |
68 +| `consume` | `200 OK` | `400` non `Approved`, `409` déjà `Consumed` |
69 +| `auth/inbox` | `202 Accepted` | `401` signature, `400` body |
70 +| `consent` | `200 OK` | `404` challenge inconnu, `409` déjà décidé |
71 +
72 +## Analyse de compatibilité
73 +
74 +- [x] Non-breaking : 5 nouveaux paths, gated par `experimental-fediverse`.
75 +- [x] Pas de schéma DB.
76 +- [x] Aucune API publique existante n'est modifiée.
77 +
78 +## Alternatives considérées
79 +
80 +### Alternative A (recommandée) — 5 endpoints REST minces
81 +
82 +**Pros** : conforme aux conventions JSON existantes ; SPA-friendly ; reste dans l'esprit Phase 2.
83 +
84 +**Cons** : 5 paths à mémoriser pour le consommateur.
85 +
86 +### Alternative B — Endpoint unique `/api/v1/auth/login/fediverse` avec champ `action`
87 +
88 +**Cons** : nuit à la lisibilité de l'audit log + des middlewares de rate-limit.
89 +
90 +**Rejeté.**
91 +
92 +### Alternative C — Tout en GraphQL
93 +
94 +**Cons** : hors stack actuelle.
95 +
96 +**Rejeté.**
97 +
98 +### Alternative D — Ne rien faire
99 +
100 +**Cons** : Phase 2 reste théorique.
101 +
102 +**Rejeté.**
103 +
104 +## Testing plan
105 +
106 +10 tests :
107 +
108 +1. `initiate_returns_201_with_challenge_id`.
109 +2. `initiate_returns_400_for_invalid_handle`.
110 +3. `status_returns_pending_for_fresh_challenge`.
111 +4. `status_returns_404_for_unknown_id`.
112 +5. `consume_returns_jwt_for_approved_challenge`.
113 +6. `consume_returns_400_when_pending`.
114 +7. `auth_inbox_returns_202_for_signed_authrequest`.
115 +8. `auth_inbox_returns_401_when_signature_missing`.
116 +9. `consent_approve_marks_challenge_approved`.
117 +10. `consent_reject_marks_challenge_rejected`.
118 +
119 +Tests via `axum::Router` + `tower::ServiceExt::oneshot`.
120 +
121 +## Migration guide
122 +
123 +```markdown
124 +### Added (rustwarden-core, feature `experimental-fediverse`)
125 +
126 +- 5 routes HTTP : `POST /api/v1/auth/login/fediverse/initiate`,
127 + `GET /api/v1/auth/login/fediverse/status/{id}`,
128 + `POST /api/v1/auth/login/fediverse/consume/{id}`,
129 + `POST /fediverse/auth/inbox`,
130 + `POST /api/v1/fediverse/auth/consent`.
131 +- Module `rustwarden_core::fediverse::auth_routes`.
132 +- Bodies / responses publics : `InitiateBody`, `InitiateResponse`,
133 + `StatusResponse`, `ConsumeResponse`, `ConsentBody`.
134 +
135 +### Migration steps
136 +
137 +1. Aucun changement obligatoire : les routes ne sont mountées que si
138 + `with_fediverse()` est appelé (déjà gated par feature).
139 +2. Apps consommatrices SPA : peuvent maintenant piloter le flow
140 + complet via JSON.
141 +```
142 +
143 +## Open questions
144 +
145 +Toutes résolues à l'auto-revue :
146 +
147 +- [x] **TTL challenge** : `fediverse.auth_challenge_ttl_secs` (300 s par défaut).
148 +- [x] **`auth/inbox` path** : `/fediverse/auth/inbox` (distinct de `/inbox` qui sert les signaux lifecycle, scope clair).
149 +- [x] **Rate limiting** : pas dans cette RFC ; les apps appliquent leur middleware comme pour `/api/v1/auth/login`.
150 +
151 +## Tracking issue
152 +
153 +À ouvrir au moment de la PR.
154 +
155 +---
156 +
157 +## Score d'évaluation
158 +
159 +| Critère | Note (1-5) | Commentaire |
160 +|---|---|---|
161 +| Généralité | 4 | Endpoints standards REST, faciles à wrapper en SPA. |
162 +| Stabilité API | 4 | Additif, gated. |
163 +| Performance | 4 | Tous les handlers sont O(1) lookups. |
164 +| Sécurité | 3 | Pas de rate-limit dans cette RFC, pas de CSRF (API JSON). |
165 +| Complexité d'implémentation | 4 | ~500 lignes (5 handlers + state + 10 tests). |
166 +| Maintenance | 3 | 5 nouveaux paths à conserver compatibles. |
167 +| Documentation | 4 | Rustdoc EN, doctest, CHANGELOG, migration guide. |
168 +| **Total** | **26/35** | ≥ 25 → Accepted |
A docs/rfcs/0014-fediverse-invite-routes.md
+165 -0
@@ -0,0 +1,165 @@
1 +# RFC 0014 — Fediverse: routes HTTP des invitations cross-instance (slice routes 3/N)
2 +
3 +- **Auteur** : EBO (rustwarden)
4 +- **Date** : 2026-05-10
5 +- **Statut** : **Accepted** (auto-revue mainteneur unique 2026-05-10, score 26/35)
6 +- **Apps consommatrices concernées** : toute app utilisant `rustwarden-core` qui veut permettre à un admin d'équipe d'inviter un utilisateur fediverse externe via HTTP/JSON, et à un IdP de gérer les invitations reçues.
7 +- **Plan associé** : [`docs/plans/2026-05-09-fediverse-identity-federation.md`](../plans/2026-05-09-fediverse-identity-federation.md), Phase 3 — clôture HTTP.
8 +- **Dépendances** : RFC 0009 (`FediverseInviteService`), RFC 0012/0013 (pattern routes).
9 +
10 +## Summary
11 +
12 +Exposer 6 routes HTTP qui rendent `FediverseInviteService` (RFC 0009) utilisable end-to-end :
13 +
14 +**Côté `b.tld` (admin de l'équipe)** :
15 +- `POST /api/v1/teams/{team_id}/members/invite-fediverse` — démarre une invitation.
16 +- `GET /api/v1/teams/{team_id}/invites` — liste les invitations pending.
17 +- `POST /api/v1/teams/{team_id}/invites/{invite_id}/revoke` — révocation.
18 +
19 +**Côté `a.tld` (IdP, utilisateur invité)** :
20 +- `GET /api/v1/fediverse/invites` — mes invitations en attente.
21 +- `POST /api/v1/fediverse/invites/{invite_id}/accept` — accepte.
22 +- `POST /api/v1/fediverse/invites/{invite_id}/reject` — refuse.
23 +
24 +Chaque handler est un thin wrapper autour du service. Aucune UI livrée.
25 +
26 +## Motivation
27 +
28 +Sans cette RFC, RFC 0009 reste sans chemin HTTP : un admin doit appeler le service Rust à la main pour inviter un user fediverse, et l'IdP n'a aucun moyen de présenter les invitations reçues. Le master plan §10 Phase 3 « b.tld invite alice@a.tld via UI → Alice voit l'invitation sur son IdP » devient enfin réalisable côté API JSON.
29 +
30 +## Non-goals
31 +
32 +- **Pas de UI HTML** (`/admin/teams/{id}` enrichie + `/profile/invites`) — Phase 3 finale UI.
33 +- **Pas d'authentification dans cette RFC** — les apps consommatrices appliquent leur middleware Bearer JWT habituel ; les handlers extraient `Claims` via l'extracteur existant `Auth(Claims)` côté `b.tld`, et acceptent un `Identity::Remote` côté IdP. Une vérification que l'identity correspond bien au handle de l'invite est faite par le service.
34 +- **Pas de listing pagination** — `list_pending_for_team` retourne tous les pending. Pagination différée si besoin (volume faible attendu).
35 +- **Pas de routes resource-share** (`shared_with_remote_identity_id`) — un follow-up RFC ajoutera l'endpoint quand `resource_service::share_with_remote_identity` sera livré.
36 +
37 +## API proposée
38 +
39 +### Bodies / responses JSON
40 +
41 +```rust
42 +// POST /api/v1/teams/{team_id}/members/invite-fediverse
43 +#[derive(Deserialize)]
44 +pub struct InviteBody { pub handle: String, pub role: String /* "member"|"admin" */ }
45 +#[derive(Serialize)]
46 +pub struct InviteResponse { pub invite_id: Uuid, pub activity_id: String, pub expires_at: String }
47 +
48 +// GET /api/v1/teams/{team_id}/invites
49 +#[derive(Serialize)]
50 +pub struct InviteRow {
51 + pub id: Uuid, pub handle: String, pub role: String, pub status: String,
52 + pub created_at: String, pub expires_at: String,
53 +}
54 +
55 +// IdP-side accept/reject — empty body, just a path id.
56 +```
57 +
58 +### Câblage
59 +
60 +`fediverse_invite_routes::router(state)` ajouté au `routes::router_with_auth` après `auth_routes::router`. Pas de nouvel `Extension` requis : tous les handlers utilisent le `DatabaseConnection` injecté.
61 +
62 +### États HTTP
63 +
64 +| Endpoint | Succès | Erreurs |
65 +|---|---|---|
66 +| `invite-fediverse` | `201 Created` | `400` handle/role invalide |
67 +| `invites` (list) | `200 OK` | — |
68 +| `invites/{id}/revoke` | `200 OK` | `404` invite inconnu, `400` non-revocable |
69 +| `/fediverse/invites` (IdP) | `200 OK` | — |
70 +| `accept` | `200 OK` | `404`, `400` non-pending |
71 +| `reject` | `200 OK` | `404`, `400` non-pending |
72 +
73 +## Analyse de compatibilité
74 +
75 +- [x] Non-breaking : 6 nouveaux paths gated par `experimental-fediverse`.
76 +- [x] Aucune modification de l'API `team_service` au-delà de RFC 0009.
77 +- [x] Aucun changement de schéma DB.
78 +
79 +## Alternatives considérées
80 +
81 +### Alternative A (recommandée) — 6 endpoints REST minces
82 +
83 +**Pros** : conforme aux conventions existantes ; chaque endpoint a un audit clair.
84 +
85 +**Cons** : 6 paths à mémoriser.
86 +
87 +### Alternative B — Endpoint unique `/api/v1/fediverse/invites/{id}` avec champ `decision`
88 +
89 +**Cons** : pollue le path avec une logique d'action ; rate-limit / audit moins clair.
90 +
91 +**Rejeté.**
92 +
93 +### Alternative C — IdP-side via le même path que côté admin
94 +
95 +**Cons** : confusion de responsabilités, erreurs d'authz.
96 +
97 +**Rejeté.**
98 +
99 +### Alternative D — Ne rien faire
100 +
101 +**Cons** : Phase 3 reste théorique côté HTTP.
102 +
103 +**Rejeté.**
104 +
105 +## Testing plan
106 +
107 +12 tests :
108 +
109 +1. `invite_returns_201_with_invite_id`.
110 +2. `invite_returns_400_for_invalid_role`.
111 +3. `list_pending_returns_200_with_array`.
112 +4. `revoke_returns_200_for_pending_invite`.
113 +5. `revoke_returns_404_for_unknown_invite`.
114 +6. `idp_list_returns_200_with_invites_for_remote_identity`.
115 +7. `idp_accept_returns_200_for_pending`.
116 +8. `idp_accept_returns_400_for_already_accepted`.
117 +9. `idp_reject_returns_200_for_pending`.
118 +10. `idp_reject_returns_400_for_already_decided`.
119 +11. `invite_uses_default_ttl_from_fediverse_config`.
120 +12. `idp_list_returns_empty_array_when_no_invites`.
121 +
122 +Tests via `axum::Router` + `tower::ServiceExt::oneshot`.
123 +
124 +## Migration guide
125 +
126 +```markdown
127 +### Added (rustwarden-core, feature `experimental-fediverse`)
128 +
129 +- 6 routes HTTP : 3 admin + 3 IdP.
130 +- Module `rustwarden_core::fediverse::invite_routes`.
131 +- Bodies / responses : `InviteBody`, `InviteResponse`, `InviteRow`.
132 +
133 +### Migration steps
134 +
135 +1. Aucun changement obligatoire — gating feature.
136 +2. Apps consommatrices : peuvent maintenant piloter le flow
137 + invitation via JSON depuis n'importe quel SPA / app mobile.
138 +```
139 +
140 +## Open questions
141 +
142 +Toutes résolues à l'auto-revue :
143 +
144 +- [x] **TTL invitations** : 7 jours par défaut (paramétrable via `FediverseConfig` plus tard, hors scope ici).
145 +- [x] **Path IdP** : `/api/v1/fediverse/invites` (pas `/api/v1/me/invites` pour rester cohérent avec les autres routes fediverse).
146 +- [x] **Pagination** : différée. Si nécessaire, ajouter `?cursor=…&limit=…` dans une RFC suivante.
147 +
148 +## Tracking issue
149 +
150 +À ouvrir au moment de la PR.
151 +
152 +---
153 +
154 +## Score d'évaluation
155 +
156 +| Critère | Note (1-5) | Commentaire |
157 +|---|---|---|
158 +| Généralité | 4 | Pattern réutilisable pour d'autres types d'invitations (resource-share). |
159 +| Stabilité API | 4 | Additif, gated. |
160 +| Performance | 4 | Lookups indexés (`fediverse_pending_invites_status_idx`). |
161 +| Sécurité | 3 | Authz déléguée aux apps consommatrices via leur middleware Bearer. |
162 +| Complexité d'implémentation | 4 | ~500 lignes (6 handlers + state + 12 tests). |
163 +| Maintenance | 3 | 6 paths à conserver compatibles. |
164 +| Documentation | 4 | Rustdoc EN, doctest, CHANGELOG, migration guide. |
165 +| **Total** | **26/35** | ≥ 25 → Accepted |
A docs/rfcs/0015-fediverse-http-signatures.md
+218 -0
@@ -0,0 +1,218 @@
1 +# RFC 0015 — Fediverse: signatures HTTP draft-cavage (RSA-SHA256)
2 +
3 +- **Auteur** : EBO (rustwarden)
4 +- **Date** : 2026-05-10
5 +- **Statut** : **Accepted** (auto-revue mainteneur unique 2026-05-10, score 27/35)
6 +- **Apps consommatrices concernées** : toute app utilisant `rustwarden-core` qui veut interopérer avec Mastodon, GoToSocial, Pleroma — c'est-à-dire 100 % des apps fediverse en prod.
7 +- **Plan associé** : [`docs/plans/2026-05-09-fediverse-identity-federation.md`](../plans/2026-05-09-fediverse-identity-federation.md), Phase 5 — déblocage interop.
8 +- **Dépendances** : RFC 0005 (clés acteur), RFC 0010 (`SignatureVerifier`), RFC 0011 (`OutboundDelivery`).
9 +
10 +## Summary
11 +
12 +Livrer deux implémentations cryptographiques branchées sur les traits déjà en place :
13 +
14 +- `HttpSigVerifier` — impl de [`SignatureVerifier`](../../crates/rustwarden-core/src/services/fediverse_signal_service.rs) qui valide le header HTTP `Signature` style **draft-cavage-12** (utilisé par Mastodon) avec RSA-SHA256.
15 +- `HttpSignedDelivery` — impl de [`OutboundDelivery`](../../crates/rustwarden-core/src/services/fediverse_outbound_service.rs) qui signe les activités sortantes avec la clé privée RFC 0005 du local actor avant le `POST` HTTP.
16 +
17 +Aucune nouvelle dépendance externe : on réutilise `rsa` (RFC 0005) + `sha2` + `base64` + `reqwest` (déjà pulled par feature `oauth`).
18 +
19 +## Motivation
20 +
21 +Sans cette RFC, `AlwaysAcceptVerifier` accepte n'importe quelle requête entrante sur `/inbox` et `/fediverse/auth/inbox`, et `NoopDelivery` n'envoie aucune requête sortante. Concrètement, **Mastodon refuse toute fédération** avec rustwarden tant que les signatures HTTP ne sont pas implémentées (Authorized Fetch + signed deliveries sont obligatoires sur Mastodon ≥ 3.0).
22 +
23 +Sans cette RFC, l'objectif Phase 5 « `bob@a.tld` se connecte avec succès sur `b.tld` » reste impossible avec Mastodon comme `a.tld`.
24 +
25 +## Non-goals
26 +
27 +- **Pas d'implémentation RFC 9421** complète (la version finale du standard). On vise **draft-cavage-12** car c'est ce que Mastodon parle aujourd'hui ; un follow-up suivra quand Mastodon migrera.
28 +- **Pas de support Ed25519** — Mastodon impose RSA-2048.
29 +- **Pas de support algorithmes autres que `rsa-sha256` / `hs2019`**.
30 +- **Pas de blob de "double-knocking"** (essayer cavage puis 9421) — un seul format pour cette livraison.
31 +- **Pas de cache de clé publique** — le `RemoteIdentityService` (RFC 0003) gère déjà le cache via TTL.
32 +- **Pas de gestion du `Digest` body header** — on signe `(request-target) host date` minimum côté sortant ; côté entrant on vérifie ce que Mastodon envoie réellement.
33 +
34 +## API proposée
35 +
36 +### Module `rustwarden_core::fediverse::http_signatures`
37 +
38 +```rust
39 +/// Verifier production : valide le header Signature draft-cavage.
40 +pub struct HttpSigVerifier {
41 + /// Resolver pour récupérer le PEM public de l'acteur signataire
42 + /// (via WebFinger + cache `remote_identities` de RFC 0003).
43 + fetcher: Arc<dyn RemoteFetcher>,
44 +}
45 +
46 +impl HttpSigVerifier {
47 + pub fn new(fetcher: Arc<dyn RemoteFetcher>) -> Self;
48 +}
49 +
50 +#[async_trait]
51 +impl SignatureVerifier for HttpSigVerifier {
52 + async fn verify(
53 + &self,
54 + actor_ap_id: &str,
55 + signature_header: &str,
56 + body: &[u8],
57 + public_key_pem: &str,
58 + ) -> Result<(), AppError>;
59 +}
60 +
61 +/// Delivery production : signe + POST l'activité.
62 +pub struct HttpSignedDelivery {
63 + db: DatabaseConnection,
64 + http: reqwest::Client,
65 + base_url: String,
66 +}
67 +
68 +impl HttpSignedDelivery {
69 + pub fn new(db: DatabaseConnection, base_url: String) -> Self;
70 +}
71 +
72 +#[async_trait]
73 +impl OutboundDelivery for HttpSignedDelivery {
74 + async fn deliver(
75 + &self,
76 + item: &fediverse_outbound_queue::Model,
77 + ) -> Result<(), AppError>;
78 +}
79 +```
80 +
81 +### Format Signature header (draft-cavage)
82 +
83 +```
84 +Signature: keyId="https://a.tld/users/alice#main-key",
85 + algorithm="rsa-sha256",
86 + headers="(request-target) host date digest",
87 + signature="<base64>"
88 +```
89 +
90 +### Algorithme de vérification
91 +
92 +1. Parser le header en map `key=value` (gestion des quotes).
93 +2. Vérifier `algorithm == "rsa-sha256"` (refuser `hs2019` pour cette livraison — extension future).
94 +3. Reconstituer la *signature input string* à partir de `headers` (ex. `(request-target): post /inbox\nhost: b.tld\ndate: …`).
95 +4. Décoder le PEM public depuis `public_key_pem` (passé par le caller, qui l'a obtenu via `RemoteIdentityService::resolve(actor_ap_id)`).
96 +5. Appeler `RsaPublicKey::verify(Pkcs1v15Sign::new::<Sha256>(), &input, &signature_bytes)`.
97 +6. Mapper succès → `Ok(())`, échec → `AppError::Internal("HTTP signature mismatch")`.
98 +
99 +### Algorithme de signature sortante
100 +
101 +1. Charger le local actor (`actor_user_id` du row queue) via SeaORM.
102 +2. Récupérer sa paire RSA via `ActorKeyService::ensure_keys(db, user_id)` (RFC 0005).
103 +3. Construire la *signature input string* avec `(request-target) host date`.
104 +4. Calculer SHA-256, signer via `RsaPrivateKey::sign(Pkcs1v15Sign::new::<Sha256>(), &hash)`.
105 +5. POSTer le body avec les headers Signature / Date / Host.
106 +
107 +## Analyse de compatibilité
108 +
109 +- [x] Non-breaking : 2 nouveaux types publics qui s'enfichent sur les traits existants.
110 +- [x] Pas de nouvelle dépendance majeure (`rsa` déjà tiré par RFC 0005).
111 +- [x] `reqwest` activé par la feature `oauth` ; on peut soit le rendre optionnel pour `experimental-fediverse`, soit imposer la dépendance commune. **Décision** : `reqwest` est ajouté à `experimental-fediverse` deps (déjà optionnel).
112 +- [x] Pas de schéma DB.
113 +
114 +## Alternatives considérées
115 +
116 +### Alternative A (recommandée) — Implémentation maison sur `rsa` + `sha2`
117 +
118 +**Pros** : zéro nouvelle dep ; contrôle total ; Mastodon-compat.
119 +
120 +**Cons** : ~250 lignes de parsing HTTP-Sig à maintenir.
121 +
122 +### Alternative B — Crate `httpsig-rs` ou équivalent
123 +
124 +**Cons** : aucun crate Rust mature ne supporte draft-cavage **et** RFC 9421 en parallèle aujourd'hui ; risque d'abandon ; on ajoute une dep externe pour 250 lignes.
125 +
126 +**Rejeté.**
127 +
128 +### Alternative C — Bloquer toute interop tant que Mastodon migre vers RFC 9421
129 +
130 +**Cons** : interop = 0 pendant 1-2 ans.
131 +
132 +**Rejeté.**
133 +
134 +### Alternative D — Ne rien faire (rester sur `AlwaysAccept` + `Noop`)
135 +
136 +**Cons** : pas de prod possible.
137 +
138 +**Rejeté.**
139 +
140 +## Testing plan
141 +
142 +12 tests :
143 +
144 +**Pure (parsing + canonical string)** :
145 +1. `parse_signature_header_extracts_keyid_algorithm_headers_signature`.
146 +2. `parse_signature_header_rejects_missing_required_fields`.
147 +3. `build_signature_input_uses_lowercase_header_names`.
148 +4. `build_signature_input_handles_request_target`.
149 +
150 +**Crypto round-trip** :
151 +5. `sign_then_verify_round_trip` — génère une paire, signe, vérifie : Ok.
152 +6. `verify_rejects_tampered_signature`.
153 +7. `verify_rejects_wrong_public_key`.
154 +
155 +**Verifier integration** :
156 +8. `verifier_rejects_when_algorithm_not_rsa_sha256`.
157 +9. `verifier_rejects_when_signature_invalid_base64`.
158 +10. `verifier_accepts_well_formed_signature_with_test_key`.
159 +
160 +**Delivery (mocked)** :
161 +11. `delivery_skips_signing_when_actor_user_id_none` — `NoopDelivery`-like fallback ; pas d'erreur silencieuse, retourne explicitement `Err(Config)`.
162 +12. `delivery_returns_err_when_actor_keys_missing`.
163 +
164 +Pas de test contre `reqwest` réel : la livraison HTTP est testée via un mock-server dédié dans un follow-up `tests/interop/` (Phase 5).
165 +
166 +## Migration guide
167 +
168 +```markdown
169 +### Added (rustwarden-core, feature `experimental-fediverse`)
170 +
171 +- `rustwarden_core::fediverse::http_signatures::{HttpSigVerifier, HttpSignedDelivery}`.
172 +- `reqwest` ajouté aux deps gated par `experimental-fediverse`
173 + (déjà optionnel pour la feature `oauth`).
174 +
175 +### Migration steps
176 +
177 +1. Apps fediverse en dev : changement zéro ; `AlwaysAcceptVerifier` +
178 + `NoopDelivery` continuent de fonctionner.
179 +2. Apps en prod : remplacer
180 + ```rust
181 + .with_fediverse()? // garde AlwaysAccept + Noop
182 + ```
183 + par :
184 + ```rust
185 + .with_fediverse()?
186 + .with_signature_verifier(Arc::new(HttpSigVerifier::new(fetcher)))
187 + .with_delivery(Arc::new(HttpSignedDelivery::new(db, base_url)))
188 + ```
189 + (les méthodes `with_signature_verifier` / `with_delivery` sont
190 + ajoutées par cette RFC sur `RustwardenBuilder`).
191 +```
192 +
193 +## Open questions
194 +
195 +Toutes résolues à l'auto-revue :
196 +
197 +- [x] **Tolerance horloge `Date` header** : 1 heure (Mastodon-compat).
198 +- [x] **Refus de `hs2019`** : oui, le draft-cavage moderne. Si Mastodon migre, follow-up RFC.
199 +- [x] **Caching de la clé publique** : déléguée à `RemoteIdentityService` qui a déjà un TTL.
200 +
201 +## Tracking issue
202 +
203 +À ouvrir au moment de la PR.
204 +
205 +---
206 +
207 +## Score d'évaluation
208 +
209 +| Critère | Note (1-5) | Commentaire |
210 +|---|---|---|
211 +| Généralité | 4 | Trait pluggable, donc impl HTTP-Sig réelle facilement remplacée par RFC 9421. |
212 +| Stabilité API | 4 | Additif, gated. |
213 +| Performance | 3 | RSA-SHA256 côté hot path entrant ; cache key public via RFC 0003 limite. |
214 +| Sécurité | 5 | Le seul vrai déblocage prod ; constant-time signature via `rsa`/`subtle`. |
215 +| Complexité d'implémentation | 3 | ~600 lignes (parsing + canonical + sign + verify + 12 tests). |
216 +| Maintenance | 4 | Pas de nouvelle dep, mais format à mettre à jour si/quand Mastodon migre. |
217 +| Documentation | 4 | Rustdoc EN, doctest, CHANGELOG, migration guide. |
218 +| **Total** | **27/35** | ≥ 25 → Accepted |
A docs/rfcs/README.md
+98 -0
@@ -0,0 +1,98 @@
1 +# RFCs Rustwarden
2 +
3 +Ce répertoire contient les **Request For Comments (RFC)** du framework Rustwarden, conformément au [processus d'évolution `RUSTWARDEN_EVOLUTION_PROCESS.md`](../RUSTWARDEN_EVOLUTION_PROCESS.md).
4 +
5 +## À quoi sert une RFC
6 +
7 +Une RFC est un document formalisé qui propose un changement structurant : nouvelle API publique, nouveau service, modification de schéma DB, changement de format sérialisé, nouvelle dépendance Cargo, ou changement de politique de sécurité. Elle force la formalisation des alternatives, des tests, et de la stratégie de migration **avant** de commencer à coder.
8 +
9 +Voir la liste exhaustive des cas qui exigent une RFC dans [`RUSTWARDEN_EVOLUTION_PROCESS.md` §2](../RUSTWARDEN_EVOLUTION_PROCESS.md#2-quand-une-rfc-est-obligatoire).
10 +
11 +## Convention de nommage
12 +
13 +```
14 +docs/rfcs/NNNN-slug-en-kebab-case.md
15 +```
16 +
17 +- `NNNN` : numéro incrémental sur 4 chiffres (`0001`, `0002`, …).
18 +- `slug` : identifiant court, kebab-case, descriptif (ex. `fediverse-config-and-builder`).
19 +
20 +Le numéro est attribué à la création de la RFC (pas à son acceptation). En cas de RFC rejetée, son numéro reste réservé — on n'en réutilise jamais.
21 +
22 +## Cycle de vie d'une RFC
23 +
24 +| Statut | Signification |
25 +|---|---|
26 +| `Draft` | En cours de rédaction, pas encore prête pour revue |
27 +| `Under Review` | Soumise à la revue communauté/mainteneurs |
28 +| `Accepted` | Validée, score ≥ 25/35 sur 7 critères, l'implémentation peut commencer |
29 +| `Rejected` | Refusée — la décision et la raison sont consignées dans le document |
30 +| `Superseded by #MMMM` | Remplacée par une autre RFC plus récente |
31 +
32 +## Workflow
33 +
34 +1. **Identifier** un changement candidat (cf. critères §2 du processus).
35 +2. **Créer** un nouveau fichier `NNNN-slug.md` à partir de [`0000-template.md`](0000-template.md).
36 +3. **Rédiger** : Summary, Motivation, Non-goals, API proposée, Analyse de compatibilité, **≥ 3 alternatives** (incluant "ne rien faire"), Testing plan, Migration guide, Open questions, Tracking issue.
37 +4. **Soumettre** en PR avec statut `Under Review`.
38 +5. **Revoir** : un mainteneur framework + un mainteneur consommateur. Score sur 7 critères, ≥ 25/35 pour passer en `Accepted`.
39 +6. **Implémenter** en suivant TDD (test-first), en respectant les quality gates de la constitution.
40 +7. **Merge** après quality gates verts (build/test/clippy/doc/audit).
41 +
42 +## Critères d'évaluation (35 points max)
43 +
44 +Chaque critère noté de 1 (mauvais) à 5 (excellent) :
45 +
46 +| Critère | Description |
47 +|---|---|
48 +| Généralité | Sert plusieurs consommateurs ou anticipe leurs besoins ? |
49 +| Stabilité API | Non-breaking par ajout pur ? Avec feature flag ? Breaking ? |
50 +| Performance | Overhead mesuré sur le hot path |
51 +| Sécurité | Régression possible sans mitigation, ou hardening net ? |
52 +| Complexité d'implémentation | Lignes, refacto, `unsafe` requis ? |
53 +| Charge de maintenance | Dépendances externes maintenues, cas edge ? |
54 +| Documentation | Rustdoc, exemple, CHANGELOG, guide migration ? |
55 +
56 +Score minimum **25/35** pour passer en `Accepted`. Voir grille détaillée dans [`RUSTWARDEN_EVOLUTION_PROCESS.md` §4](../RUSTWARDEN_EVOLUTION_PROCESS.md#4-grille-devaluation).
57 +
58 +## Index des RFCs
59 +
60 +| # | Titre | Statut | Auteur | Date |
61 +|---|---|---|---|---|
62 +| [0001](0001-fediverse-config-and-builder.md) | Fediverse — `FediverseConfig` + `with_fediverse()` builder | **Accepted** (28/35) | EBO | 2026-05-09 |
63 +| [0002](0002-webfinger-actor-nodeinfo.md) | Fediverse — WebFinger + Acteur + NodeInfo (couche découverte) | **Accepted** (29/35) | EBO | 2026-05-09 |
64 +| [0003](0003-remote-identity-store.md) | Fediverse — Stockage identités distantes + résolution `@user@domain` | **Accepted** (26/35) | EBO | 2026-05-09 |
65 +| [0004](0004-fediverse-trust-model.md) | Fediverse — Modèle de confiance (allowlist / blocklist + scope) | **Accepted** (27/35) | EBO | 2026-05-09 |
66 +| [0005](0005-actor-key-storage.md) | Fediverse — Stockage des clés d'acteur RSA chiffrées | **Accepted** (26/35) | EBO | 2026-05-09 |
67 +| [0006](0006-ap-native-auth-flow.md) | Fediverse — Flow d'authentification AP-native (couche service) | **Accepted** (26/35) | EBO | 2026-05-09 |
68 +| [0007](0007-jwt-claims-identity-kind.md) | Fediverse — Extension JWT `Claims` (`identity_kind`, `remote_id`) | **Accepted** (27/35) | EBO | 2026-05-09 |
69 +| [0008](0008-identity-abstraction.md) | Fediverse — Abstraction `Identity` + colonnes sibling FK | **Accepted** (26/35) | EBO | 2026-05-09 |
70 +| [0009](0009-team-resource-cross-instance.md) | Fediverse — Équipes / partages cross-instance + invitations | **Accepted** (26/35) | EBO | 2026-05-09 |
71 +| [0010](0010-fediverse-lifecycle-signals.md) | Fediverse — Signaux lifecycle + retry/backoff outbound | **Accepted** (26/35) | EBO | 2026-05-09 |
72 +| [0011](0011-fediverse-outbound-worker.md) | Fediverse — File outbound + worker (slice Phase 2 minimal) | **Accepted** (26/35) | EBO | 2026-05-09 |
73 +| [0012](0012-fediverse-inbox-route.md) | Fediverse — Route HTTP `/inbox` lifecycle (slice routes 1/N) | **Accepted** (26/35) | EBO | 2026-05-10 |
74 +| [0013](0013-fediverse-auth-flow-routes.md) | Fediverse — Routes HTTP du flow d'auth AP-native (slice routes 2/N) | **Accepted** (26/35) | EBO | 2026-05-10 |
75 +| [0014](0014-fediverse-invite-routes.md) | Fediverse — Routes HTTP des invitations cross-instance (slice routes 3/N) | **Accepted** (26/35) | EBO | 2026-05-10 |
76 +| [0015](0015-fediverse-http-signatures.md) | Fediverse — Signatures HTTP draft-cavage (RSA-SHA256) | **Accepted** (27/35) | EBO | 2026-05-10 |
77 +
78 +À mesure que les RFCs sont créées, elles doivent apparaître dans cette table avec leur statut courant.
79 +
80 +## RFCs liées au plan fediverse
81 +
82 +Le plan d'intégration de la fédération d'identité ([`docs/plans/2026-05-09-fediverse-identity-federation.md`](../plans/2026-05-09-fediverse-identity-federation.md) §17) prévoit 11 RFCs :
83 +
84 +| # | Slug | Phase plan |
85 +|---|---|---|
86 +| 0001 | `fediverse-config-and-builder` | 1 |
87 +| 0002 | `webfinger-actor-nodeinfo` | 1 |
88 +| 0003 | `remote-identity-store` | 1 |
89 +| 0004 | `fediverse-trust-model` | 1 |
90 +| 0005 | `actor-key-storage` | 1 |
91 +| 0006 | `ap-native-auth-flow` | 2 |
92 +| 0007 | `jwt-claims-identity-kind` | 2 |
93 +| 0008 | `identity-abstraction` | 3 |
94 +| 0009 | `team-resource-cross-instance` | 3 |
95 +| 0010 | `fediverse-lifecycle-signals` | 4 |
96 +| 0011 | `fediverse-outbound-worker` | 2-4 |
97 +
98 +Chacune doit être rédigée et acceptée **avant** le démarrage du chantier correspondant.