Screen-select

Eric BOUHANA <eric@ebii.fr> committé le 2026-08-04 08:31

05aeaca99c670bb1a9644676f908e7791697ef4a

1 parent(s)

37 fichiers modifiés +4675 -201
M Cargo.lock
+7 -7
@@ -4156,7 +4156,7 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
4156 4156
4157 4157 [[package]]
4158 4158 name = "rustwarden-app"
4159 -version = "0.1.0"
4159 +version = "1.0.0"
4160 4160 dependencies = [
4161 4161 "anyhow",
4162 4162 "async-trait",
@@ -4178,7 +4178,7 @@ dependencies = [
4178 4178
4179 4179 [[package]]
4180 4180 name = "rustwarden-cli"
4181 -version = "0.1.0"
4181 +version = "1.0.0"
4182 4182 dependencies = [
4183 4183 "chrono",
4184 4184 "clap",
@@ -4189,7 +4189,7 @@ dependencies = [
4189 4189
4190 4190 [[package]]
4191 4191 name = "rustwarden-core"
4192 -version = "0.1.0"
4192 +version = "1.0.0"
4193 4193 dependencies = [
4194 4194 "aes-gcm",
4195 4195 "anyhow",
@@ -4240,7 +4240,7 @@ dependencies = [
4240 4240
4241 4241 [[package]]
4242 4242 name = "rustwarden-fediverse"
4243 -version = "0.1.0"
4243 +version = "1.0.0"
4244 4244 dependencies = [
4245 4245 "activitypub_federation",
4246 4246 "ammonia",
@@ -4276,7 +4276,7 @@ dependencies = [
4276 4276
4277 4277 [[package]]
4278 4278 name = "rustwarden-fediverse-guard"
4279 -version = "0.1.0"
4279 +version = "1.0.0"
4280 4280 dependencies = [
4281 4281 "askama",
4282 4282 "async-trait",
@@ -4298,7 +4298,7 @@ dependencies = [
4298 4298
4299 4299 [[package]]
4300 4300 name = "rustwarden-fediverse-ui"
4301 -version = "0.1.0"
4301 +version = "1.0.0"
4302 4302 dependencies = [
4303 4303 "askama",
4304 4304 "async-trait",
@@ -4324,7 +4324,7 @@ dependencies = [
4324 4324
4325 4325 [[package]]
4326 4326 name = "rustwarden-ui"
4327 -version = "0.1.0"
4327 +version = "1.0.0"
4328 4328 dependencies = [
4329 4329 "askama",
4330 4330 "async-trait",
M README.md
+113 -42
@@ -16,6 +16,8 @@ Permet de creer rapidement des applications web avec authentification JWT, RBAC
16 16 - Rate limiting configurable
17 17 - Internationalisation (fr, en, de, es)
18 18 - Middleware d'authentification et d'autorisation
19 +- Mode headless (API-only) cle en main : routeur d'auth JSON canonique `rustwarden_core::auth::auth_api_routes()` monte en une ligne, comportement d'echec d'auth configurable (redirection vers `login_path` ou `401` JSON)
20 +- Outil `screen-select` : genere le plan des ecrans UI a coder selon les fonctionnalites activees (`docs/plans/ui-screens.md`) puis recoche le realise par analyse statique (`-- --check`) — pour les UIs custom/headless
19 21 - Conformite ANSSI (complexite des mots de passe, HttpOnly cookies, etc.)
20 22 - OAuth2 / OpenID Connect (Google, GitHub, Discord, Microsoft) avec architecture extensible
21 23 - Federation ActivityPub optionnelle (RFC 0001+) : WebFinger, NodeInfo, login AP-native, invitations d'equipe cross-instance, modele de confiance allowlist/blocklist — fournie par le crate `rustwarden-fediverse`
@@ -26,9 +28,11 @@ Permet de creer rapidement des applications web avec authentification JWT, RBAC
26 28 ```
27 29 rustwarden/
28 30 ├── crates/
29 -│ ├── rustwarden-core/ # Library : auth, RBAC, services, middleware, i18n, hooks
30 -│ ├── rustwarden-ui/ # UI optionnelle : templates HTML, admin panel, handlers
31 -│ └── rustwarden-fediverse/ # Federation ActivityPub optionnelle (RFC 0001+)
31 +│ ├── rustwarden-core/ # Library : auth, RBAC, services, middleware, i18n, hooks
32 +│ ├── rustwarden-ui/ # UI optionnelle : templates HTML, admin panel, handlers
33 +│ ├── rustwarden-fediverse/ # Federation ActivityPub optionnelle (RFC 0001+)
34 +│ ├── rustwarden-fediverse-ui/ # UI HTML de reference pour la federation
35 +│ └── rustwarden-fediverse-guard/ # Detection d'abus et bans pour la federation (RFC 0028+)
32 36 ├── app/ # Application de reference (12 lignes de main.rs)
33 37 ├── tools/
34 38 │ └── rustwarden-cli/ # Wizard interactif pour creer de nouveaux projets
@@ -45,6 +49,8 @@ rustwarden/
45 49 | `rustwarden-core` | Authentification JWT, modeles SeaORM, services (users, roles, permissions, email), middleware (auth, admin, CSRF, rate-limit), i18n, background tasks, hooks |
46 50 | `rustwarden-ui` | Interface HTML par defaut (login, profil, admin panel) avec Askama, DaisyUI, HTMX |
47 51 | `rustwarden-fediverse` | Federation ActivityPub (WebFinger, NodeInfo, actor, inbox, signatures HTTP, auth AP-native, invitations cross-instance, worker outbound, modele de confiance allowlist/blocklist) — optionnel via feature Cargo `fediverse` |
52 +| `rustwarden-fediverse-ui` | UI HTML de reference pour la federation : handlers admin/login/consent/invite, templates Askama, carte dashboard injectable |
53 +| `rustwarden-fediverse-guard` | Detection d'abus temps reel et gestion de bans pour la couche federation (RFC 0028+) — optionnel |
48 54 | `rustwarden-app` | Binaire de reference utilisant le builder |
49 55 | `rustwarden-cli` | Wizard CLI interactif pour scaffolder des applications autonomes |
50 56
@@ -78,7 +84,7 @@ Le wizard demande interactivement :
78 84 2. **Nom affiche** (APP_NAME dans l'interface)
79 85 3. **Description**
80 86 4. **Dossier cible**
81 -5. **Mode** (interface web ou API seule)
87 +5. **Mode** (interface web ou API seule, federation optionnelle : aucune / headless / avec UI de reference)
82 88 6. **Migrations automatiques** (oui/non)
83 89 7. **Port du serveur**
84 90 8. **Base de donnees** (nom, utilisateur, mot de passe)
@@ -131,56 +137,62 @@ async fn main() -> anyhow::Result<()> {
131 137 ```rust
132 138 RustwardenApp::builder()
133 139 .from_env()
134 - .headless()
135 - .merge_routes(my_api_routes())
140 + .headless() // API-only : echec d'auth = 401 JSON (au lieu d'une redirection)
141 + .merge_machine_routes(rustwarden_core::auth::auth_api_routes()) // API d'auth /api/v1/auth/* prete a l'emploi
142 + .merge_routes(my_api_routes()) // vos routes metier
136 143 .build()
137 144 .await?
138 145 .run()
139 146 .await
140 147 ```
141 148
149 +En mode headless, **aucun prefixe protege n'est enregistre par defaut** : declarez
150 +les votres via `.register_protected_prefix("/…")`. Le chemin de login (`login_path`)
151 +et le mode d'echec (`auth_failure_mode`) sont configurables — voir la section
152 +[Configuration](#configuration-env).
153 +
154 +Pour planifier les ecrans d'une UI custom, le binaire `screen-select` (livre avec
155 +`rustwarden-core`) genere `docs/plans/ui-screens.md` — la liste des ecrans a coder
156 +selon les fonctionnalites activees — puis recoche le realise par analyse statique
157 +des routes :
158 +
159 +```bash
160 +cargo run -p rustwarden-core --bin screen-select # wizard : selection des fonctionnalites → plan
161 +cargo run -p rustwarden-core --bin screen-select -- --check # controle de couverture (recoche le realise)
162 +```
163 +
164 +Catalogue des ecrans et limites de detection : [docs/SERVICES_REFERENCE.md](docs/SERVICES_REFERENCE.md) §22.
165 +
142 166 ---
143 167
144 168 ## Builder API
145 169
146 170 ```rust
147 171 RustwardenApp::builder()
148 - .from_env() // charge .env + init logging
149 - .database_url("postgres://...") // ou .database(db_connection)
150 - .address("0.0.0.0:8080") // adresse du serveur
151 - .with_ui(rustwarden_ui::default_routes) // UI par defaut
152 - .headless() // ou mode API-only (pas d'UI)
153 - .merge_routes(custom_router) // routes metier supplementaires
154 - .hooks(MyHooks) // lifecycle hooks personnalises
155 - .auto_migrate(true) // migrations auto au demarrage
156 - .without_email() // desactiver l'email
157 - .without_background_tasks() // desactiver les taches de fond
158 - .locale_dir("my_locales") // repertoire de traductions
159 - .supported_locales(vec!["fr", "en"]) // locales supportees
160 - .default_locale("fr") // locale par defaut
172 + .from_env() // charge .env + init logging
173 + .database_url("postgres://...") // ou .database(db_connection)
174 + .address("0.0.0.0:8080") // adresse du serveur
175 + .with_ui(rustwarden_ui::browser_routes) // UI SSR (canal browser, CSRF)
176 + .merge_machine_routes(rustwarden_ui::api_routes()) // API JSON de l'UI (canal machine, sans CSRF)
177 + .headless() // OU mode API-only (pas d'UI) → echec d'auth = 401 JSON
178 + .login_path("/connexion") // chemin de redirection de login (defaut "/login")
179 + .auth_failure_mode(AuthFailureMode::Json401) // Redirect (defaut UI) ou Json401 (defaut headless)
180 + .merge_browser_routes(custom_ui_router) // pages SSR mutantes → CSRF applique
181 + .merge_machine_routes(custom_api_router) // API JSON / webhooks → sans CSRF
182 + .register_protected_prefix("/espace") // auth requise sur ce prefixe
183 + .hooks(MyHooks) // lifecycle hooks personnalises
184 + .auto_migrate(true) // migrations auto au demarrage
185 + .without_email() // desactiver l'email
186 + .without_background_tasks() // desactiver les taches de fond
187 + .locale_dir("my_locales") // repertoire de traductions
188 + .supported_locales(vec!["fr", "en"]) // locales supportees
189 + .default_locale("fr") // locale par defaut
161 190 .build().await?
162 191 .run().await
163 192 ```
164 -### Procédure manuelle actuelle
165 -
166 - 1. Remplacer les crates du framework dans le projet consommateur :
167 - # Depuis le dossier du projet consommateur
168 - rm -rf crates/rustwarden-core crates/rustwarden-ui
169 - cp -r /chemin/vers/Rustwarden/crates/rustwarden-core crates/
170 - cp -r /chemin/vers/Rustwarden/crates/rustwarden-ui crates/
171 - 2. Copier les fichiers partagés (si modifiés) :
172 - cp -r /chemin/vers/Rustwarden/locales ./
173 - cp -r /chemin/vers/Rustwarden/static ./
174 - 3. Vérifier les breaking changes :
175 - - Les exports du prelude (signatures de RustwardenApp, AuthUser, Claims, etc.)
176 - - Les migrations : s'assurer que core_migrations() dans src/migrations.rs est à jour
177 - - Le contrat des templates (LayoutContext variables)
178 - - Les dépendances dans Cargo.toml (les versions récentes ont changé : thiserror 2.0, askama 0.15, jsonwebtoken 10, rand 0.9, etc.)
179 - 4. Compiler et corriger :
180 - cargo check
181 - cargo clippy -- -D warnings
182 -
183 -
193 +
194 +> `with_ui`/`merge_browser_routes` = canal **browser** (CSRF applique) ; `merge_machine_routes` = canal **machine** (API Bearer/JSON, sans CSRF). `rustwarden_ui::default_routes` est **deprecie** (remettrait `/api/v1/*` sous CSRF) → utiliser `browser_routes()` + `api_routes()`.
195 +
184 196 ### Sequencement interne du builder
185 197
186 198 1. Connexion a la base PostgreSQL
@@ -194,6 +206,53 @@ RustwardenApp::builder()
194 206
195 207 ---
196 208
209 +## Mise a jour du framework (projets generes)
210 +
211 +Les projets generes par le wizard embarquent les crates du framework en local (vendored).
212 +Pour les resynchroniser sur une version plus recente :
213 +
214 +```bash
215 +# Depuis le depot Rustwarden (le wizard demande le dossier du projet cible)
216 +cargo run -p rustwarden-cli -- update-project
217 +```
218 +
219 +La commande synchronise les crates presentes dans le projet (`rustwarden-core`,
220 +`rustwarden-ui`, `rustwarden-fediverse*` selon le cas — binaire `screen-select` inclus),
221 +preserve les templates personnalises, supprime les fichiers retires du framework,
222 +propose d'ajouter la federation si elle est absente, puis rappelle les actions
223 +post-update (`cargo check`, migrations, `screen-select`).
224 +
225 +<details>
226 +<summary>Procedure manuelle (fallback)</summary>
227 +
228 +```bash
229 +# Depuis le dossier du projet consommateur
230 +rm -rf crates/rustwarden-core crates/rustwarden-ui
231 +cp -r /chemin/vers/Rustwarden/crates/rustwarden-core crates/
232 +cp -r /chemin/vers/Rustwarden/crates/rustwarden-ui crates/
233 +# Fichiers partages (si modifies)
234 +cp -r /chemin/vers/Rustwarden/locales ./
235 +cp -r /chemin/vers/Rustwarden/static ./
236 +```
237 +
238 +Verifier ensuite les breaking changes :
239 +
240 +- exports du prelude (signatures de `RustwardenApp`, `AuthUser`, `Claims`, etc.)
241 +- migrations : `core_migrations()` dans `src/migrations.rs` a jour
242 +- contrat des templates (variables `LayoutContext`)
243 +- versions des dependances dans `Cargo.toml` (les versions recentes ont change : thiserror 2.0, askama 0.15, jsonwebtoken 10, rand 0.9, etc.)
244 +
245 +Puis compiler :
246 +
247 +```bash
248 +cargo check
249 +cargo clippy -- -D warnings
250 +```
251 +
252 +</details>
253 +
254 +---
255 +
197 256 ## Routes disponibles
198 257
199 258 ### Routes publiques
@@ -221,6 +280,9 @@ RustwardenApp::builder()
221 280 | GET | `/api/v1/auth/verify-email/{token}` | Verifier l'adresse email |
222 281 | POST | `/api/v1/auth/resend-verification` | Renvoyer le mail de verification |
223 282 | POST | `/api/v1/auth/resend-verification/by-username` | Idem, par username |
283 +| POST | `/api/v1/auth/2fa/verify` | Verifier le code 2FA (challenge de login) |
284 +
285 +> Cet ensemble est fourni cle en main par `rustwarden_core::auth::auth_api_routes()` (a monter via `merge_machine_routes`). En mode UI, `rustwarden_ui::api_routes()` monte exactement le meme jeu de routes.
224 286
225 287 ### API REST (OAuth) *(feature `oauth`)*
226 288
@@ -454,6 +516,13 @@ Le panneau d'administration (`/admin/roles`) permet de :
454 516 | `COOKIE_SECURE` | Cookies securises (HTTPS only) | `false` |
455 517 | `COOKIE_SAME_SITE` | Politique SameSite | `Lax` |
456 518
519 +### Comportement d'authentification
520 +
521 +| Variable | Description | Defaut |
522 +|----------|-------------|--------|
523 +| `LOGIN_PATH` | Chemin de redirection de login emis par le middleware d'auth (decouple de l'UI) | `/login` |
524 +| `AUTH_FAILURE_MODE` | Reponse sur route protegee sans auth : `redirect` (302 vers `LOGIN_PATH`) ou `json401` (401 JSON). `.headless()` bascule le defaut sur `json401` | `redirect` |
525 +
457 526 ### Admin initial
458 527
459 528 | Variable | Description | Defaut |
@@ -637,15 +706,16 @@ La locale est detectee automatiquement dans cet ordre :
637 706 |----------|-------------|
638 707 | `cargo run -p rustwarden-app` | Lancer l'application de reference |
639 708 | `cargo run -p rustwarden-cli -- start-project` | Wizard de creation de projet |
709 +| `cargo run -p rustwarden-cli -- update-project` | Resynchroniser le framework vendored d'un projet genere |
640 710 | `cargo run --bin migrate` | Executer les migrations |
641 711 | `cargo run --bin seed` | Creer/mettre a jour l'admin initial |
642 712 | `cargo run --bin hash_password` | Hasher un mot de passe bcrypt |
643 -| `cargo run --bin change_password` | Changer le mot de passe d'un utilisateur |
713 +| `cargo run --bin change_password` | Changer le mot de passe d'un utilisateur (ex. `cargo run --bin change_password -- admin "Admin123!"`) |
714 +| `cargo run -p rustwarden-core --bin screen-select` | Generer le plan des ecrans UI a coder (depuis un projet consommateur) |
715 +| `cargo run -p rustwarden-core --bin screen-select -- --check` | Recocher les ecrans realises par analyse statique |
644 716 | `cargo test` | Lancer les tests |
645 717 | `cargo build --release` | Build optimise pour la production |
646 718
647 - cargo run --bin change_password -- admin "Admin123!"
648 -
649 719 ---
650 720
651 721 ## Documentation complementaire
@@ -653,6 +723,7 @@ La locale est detectee automatiquement dans cet ordre :
653 723 | Document | Description |
654 724 |----------|-------------|
655 725 | [DEVELOPER.md](DEVELOPER.md) | Guide detaille pour les developpeurs (architecture, hooks, ajout de routes/modeles) |
726 +| [docs/SERVICES_REFERENCE.md](docs/SERVICES_REFERENCE.md) | Reference exhaustive des services et API pour projets consommateurs (§22 : binaire `screen-select`) |
656 727 | [docs/ANSSI_COMPLIANCE_REPORT.md](docs/ANSSI_COMPLIANCE_REPORT.md) | Rapport de conformite aux recommandations ANSSI |
657 728 | [docs/CSS_STYLE_GUIDE.md](docs/CSS_STYLE_GUIDE.md) | Guide de style CSS/DaisyUI |
658 729 | [docs/configuration-flows.md](docs/configuration-flows.md) | Diagrammes des flux de configuration |
M app/Cargo.toml
+1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "rustwarden-app"
3 -version = "0.1.0"
3 +version = "1.0.0"
4 4 edition.workspace = true
5 5 rust-version.workspace = true
6 6 description = "Reference application using the Rustwarden framework"
M crates/rustwarden-core/Cargo.toml
+5 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "rustwarden-core"
3 -version = "0.1.0"
3 +version = "1.0.0"
4 4 edition.workspace = true
5 5 rust-version.workspace = true
6 6 description = "Core library for Rustwarden – authentication, RBAC, user management framework"
@@ -94,6 +94,10 @@ path = "src/bin/seed.rs"
94 94 name = "change_password"
95 95 path = "src/bin/change_password.rs"
96 96
97 +[[bin]]
98 +name = "screen-select"
99 +path = "src/bin/screen-select/main.rs"
100 +
97 101 [dev-dependencies]
98 102 hyper = { version = "1", features = ["full"] }
99 103 hyper-util = { version = "0.1", features = ["full"] }
M crates/rustwarden-core/src/app.rs
+108 -16
@@ -145,8 +145,9 @@ pub struct RustwardenBuilder {
145 145 supported_locales: Vec<String>,
146 146 default_locale: String,
147 147 /// URL path prefixes that the authentication middleware must protect.
148 - /// Filled in by `rustwarden-ui` (and by `rustwarden-fediverse` when
149 - /// the consumer mounts its routes). See `register_protected_prefix`.
148 + /// Empty by default; [`RustwardenBuilder::with_ui`] registers the default
149 + /// UI areas (`/home`, `/profile`, `/settings`, `/admin`) and companion
150 + /// crates / consumers add their own via `register_protected_prefix`.
150 151 pub(crate) protected_prefixes: Vec<String>,
151 152 /// Optional revocation checker consulted by `AuthService` for remote
152 153 /// JWTs (RFC 0018 / F10). Injected by `rustwarden-fediverse` when
@@ -159,6 +160,15 @@ pub struct RustwardenBuilder {
159 160 /// `Extension<Vec<DashboardCardDTO>>` so `admin_dashboard` can pick
160 161 /// them up at render time.
161 162 pub(crate) dashboard_cards: Vec<DashboardCardDTO>,
163 + /// Override for [`crate::config::auth::AuthConfig::login_path`] applied at
164 + /// build time. `None` keeps the value resolved from the environment.
165 + login_path: Option<String>,
166 + /// Override for [`crate::config::auth::AuthConfig::auth_failure_mode`].
167 + /// `None` keeps the env/default value, unless `headless` flips it.
168 + auth_failure_mode: Option<crate::config::auth::AuthFailureMode>,
169 + /// Set by [`RustwardenBuilder::headless`]; flips the default auth-failure
170 + /// mode to `Json401` when no explicit mode was requested.
171 + headless: bool,
162 172 }
163 173
164 174 impl Default for RustwardenBuilder {
@@ -177,15 +187,15 @@ impl Default for RustwardenBuilder {
177 187 locale_dirs: Vec::new(),
178 188 supported_locales: vec!["fr".into(), "en".into(), "de".into(), "es".into()],
179 189 default_locale: "fr".into(),
180 - protected_prefixes: vec![
181 - "/home".into(),
182 - "/profile".into(),
183 - "/test-base".into(),
184 - "/settings".into(),
185 - "/admin".into(),
186 - ],
190 + // Empty by default so headless (API-only) apps carry no
191 + // UI-specific prefixes. `with_ui` registers the default UI set;
192 + // consumers add their own via `register_protected_prefix`.
193 + protected_prefixes: Vec::new(),
187 194 revocation_checker: None,
188 195 dashboard_cards: Vec::new(),
196 + login_path: None,
197 + auth_failure_mode: None,
198 + headless: false,
189 199 }
190 200 }
191 201 }
@@ -263,11 +273,21 @@ impl RustwardenBuilder {
263 273
264 274 /// Register the default UI routes (login, profile, admin panel).
265 275 /// Pass the function that returns the UI router (from `rustwarden_ui::default_routes`).
276 + ///
277 + /// This also registers the authenticated UI areas (`/home`, `/profile`,
278 + /// `/settings`, `/admin`) as protected prefixes, so the default UI stays
279 + /// protected without the core hard-wiring these paths for every app.
280 + /// Headless apps, which never call `with_ui`, do not inherit them.
266 281 pub fn with_ui(
267 282 mut self,
268 283 routes_fn: impl FnOnce() -> Router<DatabaseConnection> + Send + 'static,
269 284 ) -> Self {
270 285 self.ui_routes_fn = Some(Box::new(routes_fn));
286 + for prefix in ["/home", "/profile", "/settings", "/admin"] {
287 + if !self.protected_prefixes.iter().any(|p| p == prefix) {
288 + self.protected_prefixes.push(prefix.to_string());
289 + }
290 + }
271 291 self
272 292 }
273 293
@@ -278,17 +298,44 @@ impl RustwardenBuilder {
278 298 }
279 299
280 300 /// Run in headless mode (API-only, no UI).
301 + ///
302 + /// Besides dropping the default UI routes, this flips the default
303 + /// authentication-failure behaviour to [`AuthFailureMode::Json401`] so
304 + /// protected routes answer unauthenticated requests with `401` JSON
305 + /// instead of redirecting to a `/login` page that does not exist. Call
306 + /// [`RustwardenBuilder::auth_failure_mode`] afterwards to override.
307 + ///
308 + /// [`AuthFailureMode::Json401`]: crate::config::auth::AuthFailureMode::Json401
281 309 pub fn headless(mut self) -> Self {
282 310 self.ui_routes_fn = None;
311 + self.headless = true;
312 + self
313 + }
314 +
315 + /// Override the browser login redirect path
316 + /// ([`crate::config::auth::AuthConfig::login_path`], default `/login`).
317 + pub fn login_path(mut self, path: impl Into<String>) -> Self {
318 + self.login_path = Some(path.into());
319 + self
320 + }
321 +
322 + /// Override how the auth middleware answers unauthenticated requests to
323 + /// protected routes
324 + /// ([`crate::config::auth::AuthConfig::auth_failure_mode`]). Takes
325 + /// precedence over the default flipped by [`RustwardenBuilder::headless`].
326 + pub fn auth_failure_mode(mut self, mode: crate::config::auth::AuthFailureMode) -> Self {
327 + self.auth_failure_mode = Some(mode);
283 328 self
284 329 }
285 330
286 331 /// Register a URL path prefix that the authentication middleware must
287 - /// protect (redirect anonymous visitors to `/login`). The default set
288 - /// (`/home`, `/profile`, `/test-base`, `/settings`, `/admin`) is
289 - /// installed in [`Default::default`]; consumers and companion crates
290 - /// add more on top — for instance `rustwarden-fediverse::mount` adds
291 - /// `/fediverse/consent` and `/fediverse/invite`.
332 + /// protect (redirect anonymous visitors to the login page, or reply
333 + /// `401` in headless mode). The set is empty by default;
334 + /// [`RustwardenBuilder::with_ui`] registers the default UI areas
335 + /// (`/home`, `/profile`, `/settings`, `/admin`), and consumers and
336 + /// companion crates add more on top — for instance
337 + /// `rustwarden-fediverse::mount` adds `/fediverse/consent` and
338 + /// `/fediverse/invite`.
292 339 pub fn register_protected_prefix(mut self, prefix: impl Into<String>) -> Self {
293 340 self.protected_prefixes.push(prefix.into());
294 341 self
@@ -359,7 +406,7 @@ impl RustwardenBuilder {
359 406
360 407 /// Build the application. Connects to the database, initializes services,
361 408 /// and assembles the router with all middleware.
362 - pub async fn build(self) -> anyhow::Result<RustwardenApp> {
409 + pub async fn build(mut self) -> anyhow::Result<RustwardenApp> {
363 410 tracing::info!("Building Rustwarden application...");
364 411
365 412 // 1. Database connection
@@ -393,8 +440,19 @@ impl RustwardenBuilder {
393 440 tracing::info!("Default admin and app settings initialized");
394 441
395 442 // 3. Auth config & session (SEC-M1: load once, share via Extension)
396 - let auth_config =
443 + let mut auth_config =
397 444 AuthConfig::from_env().map_err(|e| anyhow::anyhow!("Auth config error: {}", e))?;
445 + // Apply builder overrides: an explicit setter wins; otherwise
446 + // `.headless()` flips the default failure mode to Json401 so API-only
447 + // apps return 401 instead of redirecting to a non-existent /login.
448 + if let Some(path) = self.login_path.take() {
449 + auth_config.login_path = path;
450 + }
451 + if let Some(mode) = self.auth_failure_mode {
452 + auth_config.auth_failure_mode = mode;
453 + } else if self.headless {
454 + auth_config.auth_failure_mode = crate::config::auth::AuthFailureMode::Json401;
455 + }
398 456 let session_layer = SessionConfig::create_memory_layer(&auth_config);
399 457 let auth_config = Arc::new(auth_config);
400 458 tracing::info!("Session management configured");
@@ -867,3 +925,37 @@ async fn locale_middleware(
867 925 let request = Request::from_parts(parts, body);
868 926 next.run(request).await
869 927 }
928 +
929 +#[cfg(test)]
930 +mod builder_tests {
931 + use super::*;
932 +
933 + #[test]
934 + fn default_builder_has_no_protected_prefixes() {
935 + assert!(RustwardenBuilder::default().protected_prefixes.is_empty());
936 + }
937 +
938 + #[test]
939 + fn with_ui_registers_default_ui_prefixes() {
940 + let builder = RustwardenBuilder::default().with_ui(Router::<DatabaseConnection>::new);
941 + for prefix in ["/home", "/profile", "/settings", "/admin"] {
942 + assert!(
943 + builder.protected_prefixes.iter().any(|p| p == prefix),
944 + "with_ui must register {prefix}"
945 + );
946 + }
947 + }
948 +
949 + #[test]
950 + fn headless_sets_flag_without_ui_prefixes() {
951 + let builder = RustwardenBuilder::default().headless();
952 + assert!(builder.headless);
953 + assert!(builder.protected_prefixes.is_empty());
954 + }
955 +
956 + #[test]
957 + fn register_protected_prefix_appends_custom_prefix() {
958 + let builder = RustwardenBuilder::default().register_protected_prefix("/custom");
959 + assert!(builder.protected_prefixes.iter().any(|p| p == "/custom"));
960 + }
961 +}
M crates/rustwarden-core/src/auth/mod.rs
+4 -0
@@ -11,6 +11,10 @@ pub mod extractors;
11 11 pub mod handlers;
12 12 /// JWT creation and validation service.
13 13 pub mod jwt;
14 +/// Ready-to-mount router for the built-in JSON authentication API.
15 +pub mod routes;
16 +
17 +pub use routes::auth_api_routes;
14 18 /// Axum extractor that validates a Bearer JWT and provides the authenticated user.
15 19 pub mod jwt_extractor;
16 20 /// OAuth 2.0 / OIDC handlers (redirect initiation and callback processing).
A crates/rustwarden-core/src/auth/routes.rs
+92 -0
@@ -0,0 +1,92 @@
1 +//! Ready-to-mount router for the built-in JSON authentication API.
2 +
3 +use axum::{
4 + Router,
5 + routing::{get, post},
6 +};
7 +use sea_orm::DatabaseConnection;
8 +
9 +use crate::auth::handlers;
10 +
11 +/// Build the JSON authentication API router (`/api/v1/auth/*`).
12 +///
13 +/// Mounts the framework's built-in auth handlers: login, register, refresh,
14 +/// logout, current-user, password reset, email verification, and 2FA
15 +/// verification. These endpoints authenticate by `Authorization: Bearer` (or
16 +/// are pre-session, for login/register) and are **not** CSRF-vulnerable, so
17 +/// mount this on the machine channel of the application builder
18 +/// ([`crate::app::RustwardenBuilder::merge_machine_routes`]) — never behind
19 +/// the CSRF layer.
20 +///
21 +/// Headless (UI-less) applications get a complete, working authentication API
22 +/// simply by wiring this router; `rustwarden-ui` mounts the same set inside
23 +/// its own `api_routes()`.
24 +///
25 +/// # Example
26 +///
27 +/// ```rust,ignore
28 +/// RustwardenApp::builder()
29 +/// .from_env()
30 +/// .headless()
31 +/// .merge_machine_routes(rustwarden_core::auth::auth_api_routes())
32 +/// .build()
33 +/// .await?;
34 +/// ```
35 +pub fn auth_api_routes() -> Router<DatabaseConnection> {
36 + Router::new()
37 + .route("/api/v1/auth/login", post(handlers::login))
38 + .route("/api/v1/auth/register", post(handlers::register))
39 + .route("/api/v1/auth/refresh", post(handlers::refresh_token))
40 + .route("/api/v1/auth/logout", post(handlers::logout))
41 + .route("/api/v1/auth/me", get(handlers::get_current_user))
42 + .route(
43 + "/api/v1/auth/forgot-password",
44 + post(handlers::forgot_password),
45 + )
46 + .route(
47 + "/api/v1/auth/reset-password",
48 + post(handlers::reset_password),
49 + )
50 + .route(
51 + "/api/v1/auth/verify-email/{token}",
52 + get(handlers::verify_email),
53 + )
54 + .route(
55 + "/api/v1/auth/resend-verification",
56 + post(handlers::resend_verification),
57 + )
58 + .route(
59 + "/api/v1/auth/resend-verification/by-username",
60 + post(handlers::resend_verification_by_username),
61 + )
62 + .route("/api/v1/auth/2fa/verify", post(handlers::verify_2fa))
63 +}
64 +
65 +#[cfg(test)]
66 +mod tests {
67 + use super::auth_api_routes;
68 + use axum::{Json, Router, routing::get};
69 + use sea_orm::DatabaseConnection;
70 +
71 + /// Mirrors the protected handler the CLI scaffolds into headless
72 + /// projects. Kept in sync so the generated code's interface (the
73 + /// `AuthUser` extractor composing into a `Router<DatabaseConnection>`
74 + /// with a `Json<serde_json::Value>` response) is verified at compile
75 + /// time against the real framework surface.
76 + async fn whoami(user: crate::auth::extractors::AuthUser) -> Json<serde_json::Value> {
77 + Json(serde_json::json!({
78 + "user_id": user.user_id,
79 + "username": user.username,
80 + "roles": user.roles,
81 + }))
82 + }
83 +
84 + #[test]
85 + fn auth_api_routes_compose_with_scaffolded_protected_handler() {
86 + // Type-checks the exact wiring the headless scaffold emits:
87 + // .merge_machine_routes(auth_api_routes())
88 + // .merge_routes(app_routes()) // app_routes mounts `whoami`
89 + let _router: Router<DatabaseConnection> =
90 + auth_api_routes().merge(Router::new().route("/api/whoami", get(whoami)));
91 + }
92 +}
A crates/rustwarden-core/src/bin/screen-select/catalogue.rs
+356 -0
@@ -0,0 +1,356 @@
1 +//! Catalogue statique fonctionnalité → écrans (source de vérité de complétude).
2 +
3 +/// Canal de montage d'un écran.
4 +#[derive(Clone, Copy, PartialEq, Eq, Debug)]
5 +#[allow(dead_code)] // `Machine` réservé à de futurs écrans (aucun aujourd'hui)
6 +pub enum Channel {
7 + /// Route SSR pilotée par cookie de session (CSRF appliqué).
8 + Browser,
9 + /// API JSON / Bearer (hors CSRF).
10 + Machine,
11 +}
12 +
13 +impl Channel {
14 + /// Libellé court pour le tableau.
15 + pub fn label(self) -> &'static str {
16 + match self {
17 + Channel::Browser => "browser",
18 + Channel::Machine => "machine",
19 + }
20 + }
21 +}
22 +
23 +/// Niveau d'autorisation attendu pour monter l'écran (contrat de sécurité).
24 +#[derive(Clone, Copy, PartialEq, Eq, Debug)]
25 +pub enum AuthLevel {
26 + /// Accessible sans session (flux publics : login, reset, vérification…).
27 + Public,
28 + /// Session valide requise (n'importe quel utilisateur authentifié).
29 + Authenticated,
30 + /// Authentifié + l'écran manipule une ressource nominative : vérifier
31 + /// l'appartenance avant lecture/mutation (défense IDOR).
32 + ResourceOwner,
33 + /// Réservé aux administrateurs : `ensure_admin()` en défense en profondeur.
34 + Admin,
35 +}
36 +
37 +impl AuthLevel {
38 + /// Libellé pour le contrat de sécurité par écran.
39 + #[allow(dead_code)] // consommé par le rendu de la section sécurité (tâche ultérieure)
40 + pub fn label(self) -> &'static str {
41 + match self {
42 + AuthLevel::Public => "Public",
43 + AuthLevel::Authenticated => "Authentifié",
44 + AuthLevel::ResourceOwner => "Propriétaire de ressource",
45 + AuthLevel::Admin => "Administrateur",
46 + }
47 + }
48 +
49 + /// Reporté tel quel dans la colonne « Protégé » du tableau :
50 + /// tout niveau non public exige une session.
51 + pub fn is_protected(self) -> bool {
52 + !matches!(self, AuthLevel::Public)
53 + }
54 +}
55 +
56 +/// Un écran requis quand sa fonctionnalité est activée.
57 +#[derive(Clone, Copy, Debug)]
58 +pub struct Screen {
59 + pub name: &'static str,
60 + pub suggested_route: &'static str,
61 + pub channel: Channel,
62 + pub auth_level: AuthLevel,
63 + pub core_methods: &'static str,
64 + pub io: &'static str,
65 + /// Pièges de sécurité spécifiques à cet écran (imprimés dans le plan).
66 + #[allow(dead_code)] // consommé par le rendu de la section sécurité (tâche ultérieure)
67 + pub security_notes: &'static [&'static str],
68 +}
69 +
70 +/// Une fonctionnalité activable et ses écrans.
71 +#[derive(Clone, Copy, Debug)]
72 +pub struct Functionality {
73 + pub key: &'static str,
74 + pub label: &'static str,
75 + pub always: bool,
76 + pub screens: &'static [Screen],
77 +}
78 +
79 +/// Toutes les fonctionnalités du catalogue.
80 +pub fn all() -> &'static [Functionality] {
81 + CATALOGUE
82 +}
83 +
84 +/// Recherche par clé stable.
85 +#[allow(dead_code)] // utilisé par les tests des autres modules du binaire
86 +pub fn by_key(key: &str) -> Option<&'static Functionality> {
87 + CATALOGUE.iter().find(|f| f.key == key)
88 +}
89 +
90 +const CATALOGUE: &[Functionality] = &[
91 + Functionality { key: "authentication", label: "Authentication", always: true, screens: &[
92 + Screen { name: "Login", suggested_route: "/login", channel: Channel::Browser, auth_level: AuthLevel::Public,
93 + core_methods: "AuthService, JwtService, POST /api/v1/auth/login", io: "form login → cookie JWT / challenge 2FA",
94 + security_notes: &[
95 + "redirect post-login : n'autoriser qu'un chemin relatif same-origin (rejeter les URL absolues et `//host`) — bloque l'open redirect `?redirect=`",
96 + "flux public : ne pas divulguer si l'identifiant/email existe (message d'échec générique)",
97 + ] },
98 + ]},
99 + Functionality { key: "registration", label: "Registration", always: false, screens: &[
100 + Screen { name: "Register", suggested_route: "/register", channel: Channel::Browser, auth_level: AuthLevel::Public,
101 + core_methods: "UserService, AppSettingsService(allow_registration), POST /api/v1/auth/register", io: "form inscription",
102 + security_notes: &[
103 + "respecter `allow_registration` (403 si désactivé) ; valider/normaliser l'email côté serveur",
104 + "flux public : ne pas révéler qu'un email est déjà enregistré",
105 + ] },
106 + ]},
107 + Functionality { key: "password_reset", label: "PasswordReset", always: false, screens: &[
108 + Screen { name: "ForgotPassword", suggested_route: "/forgot-password", channel: Channel::Browser, auth_level: AuthLevel::Public,
109 + core_methods: "PasswordResetService, POST /api/v1/auth/forgot-password", io: "email → lien reset",
110 + security_notes: &[
111 + "réponse identique que l'email existe ou non (anti-énumération)",
112 + ] },
113 + Screen { name: "ResetPassword", suggested_route: "/reset-password", channel: Channel::Browser, auth_level: AuthLevel::Public,
114 + core_methods: "PasswordResetService, POST /api/v1/auth/reset-password", io: "token + nouveau mdp",
115 + security_notes: &[
116 + "le token ne doit JAMAIS être interpolé dans un `<script>` : le passer par un attribut `data-*` et le lire via `dataset` (l'escaping HTML d'Askama n'échappe pas le contexte JS ni le backslash)",
117 + "consommer le token en un seul usage ; ne pas confirmer si le token est valide avant soumission",
118 + ] },
119 + ]},
120 + Functionality { key: "email_validation", label: "EmailValidation", always: false, screens: &[
121 + Screen { name: "ResendVerification", suggested_route: "/resend-verification", channel: Channel::Browser, auth_level: AuthLevel::Public,
122 + core_methods: "EmailValidationService, POST /api/v1/auth/resend-verification", io: "renvoi email (résultat rendu par le core)",
123 + security_notes: &[
124 + "réponse identique quel que soit l'état du compte (anti-énumération) ; rate-limit les renvois",
125 + ] },
126 + ]},
127 + Functionality { key: "two_factor", label: "TwoFactor", always: false, screens: &[
128 + Screen { name: "TwoFactorChallenge", suggested_route: "/login/2fa", channel: Channel::Browser, auth_level: AuthLevel::Public,
129 + core_methods: "TotpService, POST /api/v1/auth/2fa/verify", io: "code TOTP ou backup",
130 + security_notes: &[
131 + "étape intermédiaire liée à une pré-session : rate-limit strict, ne pas révéler la validité partielle du code",
132 + ] },
133 + Screen { name: "TwoFactorSetup", suggested_route: "/settings/2fa", channel: Channel::Browser, auth_level: AuthLevel::Authenticated,
134 + core_methods: "TotpService::generate_setup/confirm/disable", io: "QR + confirmation",
135 + security_notes: &[
136 + "toute redirection de sécurité doit parser et whitelister les query params attendus — ne jamais forwarder un `RawQuery` brut",
137 + "exiger une confirmation par code avant activation ; le secret TOTP reste chiffré (CryptoService)",
138 + ] },
139 + Screen { name: "TwoFactorBackupCodes", suggested_route: "/settings/2fa/backup", channel: Channel::Browser, auth_level: AuthLevel::Authenticated,
140 + core_methods: "TotpService (backup codes)", io: "génération/affichage codes",
141 + security_notes: &[
142 + "codes affichés une seule fois, jamais logués ; ne stocker que leur hash",
143 + ] },
144 + ]},
145 + Functionality { key: "account_profile", label: "AccountProfile", always: false, screens: &[
146 + Screen { name: "Profile", suggested_route: "/profile", channel: Channel::Browser, auth_level: AuthLevel::Authenticated,
147 + core_methods: "UserService", io: "voir/éditer profil",
148 + security_notes: &[
149 + "n'exposer et n'éditer que le profil de `claims.sub`",
150 + ] },
151 + ]},
152 + Functionality { key: "password_change", label: "PasswordChange", always: false, screens: &[
153 + Screen { name: "PasswordChange", suggested_route: "/settings/password", channel: Channel::Browser, auth_level: AuthLevel::Authenticated,
154 + core_methods: "PasswordChangeService", io: "changement mdp (confirmation email)",
155 + security_notes: &[
156 + "exiger le mot de passe courant ; invalider les autres sessions après changement",
157 + ] },
158 + Screen { name: "ConfirmPasswordChange", suggested_route: "/confirm-password-change", channel: Channel::Browser, auth_level: AuthLevel::Public,
159 + core_methods: "PasswordChangeService::find_by_token/confirm_request", io: "confirme via token",
160 + security_notes: &[
161 + "valider le token côté core ; token à usage unique et expirant",
162 + ] },
163 + ]},
164 + Functionality { key: "session_management", label: "SessionManagement", always: false, screens: &[
165 + Screen { name: "SessionsList", suggested_route: "/settings/sessions", channel: Channel::Browser, auth_level: AuthLevel::ResourceOwner,
166 + core_methods: "RefreshTokenService::list_active/revoke, JwtBlacklistService", io: "liste sessions + révoquer",
167 + security_notes: &[
168 + "IDOR : lister et révoquer uniquement les sessions dont l'`id` appartient à `claims.sub` (vérifier l'appartenance avant révocation)",
169 + ] },
170 + ]},
171 + Functionality { key: "preferences", label: "Preferences", always: false, screens: &[
172 + Screen { name: "Preferences", suggested_route: "/settings/preferences", channel: Channel::Browser, auth_level: AuthLevel::Authenticated,
173 + core_methods: "UserPreferencesService", io: "préférences utilisateur",
174 + security_notes: &[
175 + "ne modifier que les préférences de `claims.sub`",
176 + ] },
177 + ]},
178 + Functionality { key: "oauth_linking", label: "OAuthLinking", always: false, screens: &[
179 + Screen { name: "OAuthAccountLinking", suggested_route: "/settings/oauth", channel: Channel::Browser, auth_level: AuthLevel::Authenticated,
180 + core_methods: "OAuthService, GET /api/v1/auth/oauth/{provider}/redirect", io: "lier/délier comptes (+ boutons login)",
181 + security_notes: &[
182 + "vérifier le paramètre `state` anti-CSRF au retour du provider",
183 + "données renvoyées par l'API en `textContent`, jamais `innerHTML` ; URLs construites via `encodeURIComponent`",
184 + ] },
185 + ]},
186 + Functionality { key: "teams", label: "Teams", always: false, screens: &[
187 + Screen { name: "TeamsManagement", suggested_route: "/teams", channel: Channel::Browser, auth_level: AuthLevel::ResourceOwner,
188 + core_methods: "TeamService (create/add_member/…), TeamRole", io: "équipes + membres (owner/admin/member)",
189 + security_notes: &[
190 + "IDOR/RBAC : vérifier le rôle de l'appelant dans CETTE équipe (`role_of`) avant chaque mutation ; owner non ré-attribuable",
191 + "à l'ajout d'un membre : message générique si l'utilisateur est introuvable (anti-énumération)",
192 + ] },
193 + ]},
194 + Functionality { key: "personal_access_tokens", label: "PersonalAccessTokens", always: false, screens: &[
195 + Screen { name: "PatManagement", suggested_route: "/settings/tokens", channel: Channel::Browser, auth_level: AuthLevel::ResourceOwner,
196 + core_methods: "PersonalTokenService::create/list_for_user/revoke", io: "créer/révoquer jetons (plaintext 1 fois)",
197 + security_notes: &[
198 + "jeton en clair affiché une seule fois, jamais reconsultable ni logué",
199 + "IDOR : révocation et listage filtrés par `claims.sub`",
200 + ] },
201 + ]},
202 + Functionality { key: "admin_users", label: "Admin · Users", always: false, screens: &[
203 + Screen { name: "AdminUsers", suggested_route: "/admin/users", channel: Channel::Browser, auth_level: AuthLevel::Admin,
204 + core_methods: "UserService, RoleService, PasswordResetService, TotpService", io: "liste + CRUD + reset",
205 + security_notes: &[
206 + "`ensure_admin()` dans le handler en plus du middleware (défense en profondeur)",
207 + "empêcher l'auto-suppression ; message générique si utilisateur introuvable",
208 + ] },
209 + ]},
210 + Functionality { key: "admin_roles", label: "Admin · Roles", always: false, screens: &[
211 + Screen { name: "AdminRoles", suggested_route: "/admin/roles", channel: Channel::Browser, auth_level: AuthLevel::Admin,
212 + core_methods: "RoleService, PermissionService", io: "liste + CRUD rôles/permissions",
213 + security_notes: &[
214 + "`ensure_admin()` en défense en profondeur ; protéger le dernier admin",
215 + "ne pas permettre l'auto-escalade de permissions",
216 + ] },
217 + ]},
218 + Functionality { key: "admin_settings", label: "Admin · Settings", always: false, screens: &[
219 + Screen { name: "AdminSettings", suggested_route: "/admin/settings", channel: Channel::Browser, auth_level: AuthLevel::Admin,
220 + core_methods: "AppSettingsService, CryptoService", io: "réglages applicatifs",
221 + security_notes: &[
222 + "`ensure_admin()` en défense en profondeur ; secrets chiffrés via CryptoService, jamais rendus en clair",
223 + ] },
224 + ]},
225 + Functionality { key: "admin_error_log", label: "Admin · ErrorLog", always: false, screens: &[
226 + Screen { name: "AdminErrorLog", suggested_route: "/admin/errors", channel: Channel::Browser, auth_level: AuthLevel::Admin,
227 + core_methods: "error_handler, I18nService", io: "journal d'erreurs",
228 + security_notes: &[
229 + "`ensure_admin()` en défense en profondeur ; ne pas exposer de stack traces / chemins internes en production",
230 + ] },
231 + ]},
232 + Functionality { key: "admin_email_queue", label: "Admin · EmailQueue", always: false, screens: &[
233 + Screen { name: "AdminEmailQueue", suggested_route: "/admin/emails", channel: Channel::Browser, auth_level: AuthLevel::Admin,
234 + core_methods: "EmailQueueService", io: "file d'emails",
235 + security_notes: &[
236 + "`ensure_admin()` en défense en profondeur ; ne pas afficher de contenu d'email sensible en clair",
237 + ] },
238 + ]},
239 + Functionality { key: "admin_dashboard", label: "Admin · Dashboard", always: false, screens: &[
240 + Screen { name: "AdminDashboard", suggested_route: "/admin", channel: Channel::Browser, auth_level: AuthLevel::Admin,
241 + core_methods: "cartes DashboardCardDTO", io: "accueil admin",
242 + security_notes: &[
243 + "`ensure_admin()` en défense en profondeur ; cartes injectées correctement échappées",
244 + ] },
245 + ]},
246 +];
247 +
248 +#[cfg(test)]
249 +mod tests {
250 + use super::*;
251 +
252 + #[test]
253 + fn catalogue_invariants() {
254 + assert!(!all().is_empty(), "catalogue non vide");
255 + // clés uniques
256 + for (i, f) in all().iter().enumerate() {
257 + assert!(!f.key.is_empty() && !f.label.is_empty());
258 + assert!(!f.screens.is_empty(), "{} a au moins un écran", f.key);
259 + assert!(
260 + all().iter().skip(i + 1).all(|g| g.key != f.key),
261 + "clé dupliquée: {}",
262 + f.key
263 + );
264 + }
265 + // exactement une fonctionnalité "always" (Authentication)
266 + assert_eq!(all().iter().filter(|f| f.always).count(), 1);
267 + }
268 +
269 + #[test]
270 + fn catalogue_couvre_les_fonctionnalites_attendues() {
271 + let expected = [
272 + "authentication", "registration", "password_reset", "email_validation",
273 + "two_factor", "account_profile", "password_change", "session_management",
274 + "preferences", "oauth_linking", "teams", "personal_access_tokens",
275 + "admin_users", "admin_roles", "admin_settings", "admin_error_log",
276 + "admin_email_queue", "admin_dashboard",
277 + ];
278 + let keys: Vec<&str> = all().iter().map(|f| f.key).collect();
279 + for k in expected {
280 + assert!(keys.contains(&k), "fonctionnalité manquante au catalogue : {k}");
281 + }
282 + assert_eq!(keys.len(), expected.len(), "nombre de fonctionnalités inattendu");
283 + }
284 +
285 + #[test]
286 + fn auth_level_preserve_la_protection_existante() {
287 + // Public ⇒ non protégé ; tout le reste ⇒ protégé (colonne du tableau inchangée).
288 + assert!(!AuthLevel::Public.is_protected());
289 + assert!(AuthLevel::Authenticated.is_protected());
290 + assert!(AuthLevel::ResourceOwner.is_protected());
291 + assert!(AuthLevel::Admin.is_protected());
292 + // Login reste public ; tous les écrans admin restent en niveau Admin.
293 + assert_eq!(
294 + by_key("authentication").unwrap().screens[0].auth_level,
295 + AuthLevel::Public
296 + );
297 + assert!(
298 + all()
299 + .iter()
300 + .filter(|f| f.key.starts_with("admin_"))
301 + .flat_map(|f| f.screens)
302 + .all(|s| s.auth_level == AuthLevel::Admin),
303 + "tous les écrans admin doivent être AuthLevel::Admin"
304 + );
305 + }
306 +
307 + #[test]
308 + fn contrats_de_securite_couvrent_les_pieges_de_l_audit() {
309 + let note = |key: &str, screen: &str, needle: &str| {
310 + by_key(key)
311 + .unwrap()
312 + .screens
313 + .iter()
314 + .find(|s| s.name == screen)
315 + .unwrap_or_else(|| panic!("écran {screen} introuvable"))
316 + .security_notes
317 + .iter()
318 + .any(|n| n.to_lowercase().contains(needle))
319 + };
320 + // #1 XSS — token jamais dans un <script>, passer par data-*.
321 + assert!(note("password_reset", "ResetPassword", "data-"));
322 + // #2 open redirect ?redirect=.
323 + assert!(note("authentication", "Login", "redirect"));
324 + // #4 RawQuery pass-through.
325 + assert!(note("two_factor", "TwoFactorSetup", "rawquery"));
326 +
327 + // Invariants par niveau + présence d'au moins une note partout.
328 + for f in all() {
329 + for s in f.screens {
330 + assert!(
331 + !s.security_notes.is_empty(),
332 + "{} doit porter au moins une note de sécurité",
333 + s.name
334 + );
335 + match s.auth_level {
336 + AuthLevel::Admin => assert!(
337 + s.security_notes.iter().any(|n| n.contains("ensure_admin")),
338 + "{} (Admin) doit rappeler ensure_admin()",
339 + s.name
340 + ),
341 + AuthLevel::ResourceOwner => assert!(
342 + s.security_notes.iter().any(|n| {
343 + let l = n.to_lowercase();
344 + l.contains("idor")
345 + || l.contains("claims.sub")
346 + || l.contains("appartenance")
347 + }),
348 + "{} (ResourceOwner) doit rappeler le contrôle d'appartenance (IDOR)",
349 + s.name
350 + ),
351 + _ => {}
352 + }
353 + }
354 + }
355 + }
356 +}
A crates/rustwarden-core/src/bin/screen-select/check.rs
+95 -0
@@ -0,0 +1,95 @@
1 +//! Contrôle statique du réalisé : une route du todo est « faite » si un
2 +//! `.route("<chemin>")` (chaîne exacte) apparaît dans les sources du projet.
3 +
4 +use std::path::Path;
5 +
6 +use crate::todo::TodoRow;
7 +
8 +/// (pur) Marque `done` selon la présence de `"<route>"` dans `haystack`.
9 +pub fn mark(rows: &mut [TodoRow], haystack: &str) {
10 + for r in rows.iter_mut() {
11 + // chaîne littérale exacte, guillemet fermant inclus → évite les
12 + // faux positifs de préfixe (/settings vs /settings/sessions).
13 + let needle = format!("\"{}\"", r.route);
14 + r.done = haystack.contains(&needle);
15 + }
16 +}
17 +
18 +/// (I/O) Concatène le contenu des `.rs` sous `root`, en ignorant `target/`
19 +/// et les crates vendored `rustwarden-*`.
20 +pub fn collect_sources(root: &Path) -> String {
21 + let mut out = String::new();
22 + walk(root, &mut out);
23 + out
24 +}
25 +
26 +fn walk(dir: &Path, out: &mut String) {
27 + let Ok(entries) = std::fs::read_dir(dir) else {
28 + return;
29 + };
30 + for entry in entries.flatten() {
31 + let path = entry.path();
32 + let name = entry.file_name();
33 + let name = name.to_string_lossy();
34 + if path.is_dir() {
35 + if name == "target" || name.starts_with("rustwarden-") {
36 + continue;
37 + }
38 + walk(&path, out);
39 + } else if path.extension().is_some_and(|e| e == "rs") {
40 + if let Ok(s) = std::fs::read_to_string(&path) {
41 + out.push_str(&s);
42 + out.push('\n');
43 + }
44 + }
45 + }
46 +}
47 +
48 +#[cfg(test)]
49 +mod tests {
50 + use super::*;
51 +
52 + fn row(route: &str) -> TodoRow {
53 + TodoRow {
54 + done: false, functionality: "F".into(), screen: "S".into(),
55 + route: route.into(), channel: "browser".into(), protected: true,
56 + methods: "".into(), io: "".into(),
57 + }
58 + }
59 +
60 + #[test]
61 + fn mark_detecte_route_presente() {
62 + let mut rows = vec![row("/profile"), row("/settings/sessions")];
63 + let haystack = r#"router.route("/profile", get(profile))"#;
64 + mark(&mut rows, haystack);
65 + assert!(rows[0].done, "/profile trouvée");
66 + assert!(!rows[1].done, "/settings/sessions absente");
67 + }
68 +
69 + #[test]
70 + fn mark_evite_les_faux_positifs_de_prefixe() {
71 + // "/settings" ne doit PAS matcher "/settings/sessions"
72 + let mut rows = vec![row("/settings")];
73 + let haystack = r#".route("/settings/sessions", get(h))"#;
74 + mark(&mut rows, haystack);
75 + assert!(!rows[0].done);
76 + }
77 +
78 + #[test]
79 + fn collect_sources_ignore_target_et_vendored() {
80 + let base = std::env::temp_dir().join(format!("scrsel_{}", std::process::id()));
81 + let _ = std::fs::remove_dir_all(&base);
82 + std::fs::create_dir_all(base.join("src")).unwrap();
83 + std::fs::create_dir_all(base.join("crates/rustwarden-core/src")).unwrap();
84 + std::fs::create_dir_all(base.join("target")).unwrap();
85 + std::fs::write(base.join("src/app.rs"), r#".route("/mine", get(h))"#).unwrap();
86 + std::fs::write(base.join("crates/rustwarden-core/src/x.rs"), r#".route("/vendored", get(h))"#).unwrap();
87 + std::fs::write(base.join("target/y.rs"), r#".route("/built", get(h))"#).unwrap();
88 +
89 + let hay = collect_sources(&base);
90 + assert!(hay.contains("/mine"));
91 + assert!(!hay.contains("/vendored"));
92 + assert!(!hay.contains("/built"));
93 + let _ = std::fs::remove_dir_all(&base);
94 + }
95 +}
A crates/rustwarden-core/src/bin/screen-select/generate.rs
+127 -0
@@ -0,0 +1,127 @@
1 +//! Construction des lignes de todo et rendu du plan Markdown complet.
2 +
3 +use crate::catalogue::Functionality;
4 +use crate::todo::{self, TodoRow};
5 +use crate::{section, security};
6 +
7 +/// Entrées de génération du plan.
8 +pub struct PlanInputs<'a> {
9 + pub project: &'a str,
10 + pub selected: Vec<&'static Functionality>,
11 + pub design_md: Option<String>,
12 + pub layout: Option<String>,
13 +}
14 +
15 +/// Aplati les fonctionnalités sélectionnées en lignes de todo (non faites).
16 +pub fn rows_for(selected: &[&'static Functionality]) -> Vec<TodoRow> {
17 + let mut rows = Vec::new();
18 + for f in selected {
19 + for s in f.screens {
20 + rows.push(TodoRow {
21 + done: false,
22 + functionality: f.label.to_string(),
23 + screen: s.name.to_string(),
24 + route: s.suggested_route.to_string(),
25 + channel: s.channel.label().to_string(),
26 + protected: s.auth_level.is_protected(),
27 + methods: s.core_methods.to_string(),
28 + io: s.io.to_string(),
29 + });
30 + }
31 + }
32 + rows
33 +}
34 +
35 +/// Rend le document complet : titre, charte, structure, tableau.
36 +pub fn render_plan(inputs: &PlanInputs, rows: &[TodoRow]) -> String {
37 + let mut out = String::new();
38 + out.push_str(&format!("# Plan UI — {}\n\n", inputs.project));
39 + out.push_str("> Généré par `cargo run -p rustwarden-core --bin screen-select`. Recochez le réalisé via `cargo run -p rustwarden-core --bin screen-select -- --check`.\n\n");
40 + out.push_str(&format!(
41 + "Codez ces écrans dans une crate dédiée **`{}-ui`** consommant `rustwarden-core`, montée dans le routeur principal.\n\n",
42 + inputs.project
43 + ));
44 + // Garde-fous transversaux + conventions de rendu sûr (statique, vu en premier).
45 + out.push_str(&section::block(security::GUIDES_ID, &security::guides_body()));
46 + out.push_str("\n\n");
47 + if let Some(design) = &inputs.design_md {
48 + out.push_str("## Charte graphique (à respecter)\n\n");
49 + out.push_str(design.trim());
50 + out.push_str("\n\n");
51 + }
52 + if let Some(layout) = &inputs.layout {
53 + out.push_str("## Structure de page\n\n");
54 + out.push_str(layout.trim());
55 + out.push_str("\n\n");
56 + }
57 + out.push_str("## Écrans à coder\n\n");
58 + out.push_str(&todo::render(rows));
59 + out.push('\n');
60 + // Contrat de sécurité par écran (dérivé du catalogue, en référence sous le tableau).
61 + out.push_str(&section::block(security::PER_SCREEN_ID, &security::reference_body(&inputs.selected)));
62 + out.push('\n');
63 + out
64 +}
65 +
66 +#[cfg(test)]
67 +mod tests {
68 + use super::*;
69 + use crate::catalogue;
70 +
71 + #[test]
72 + fn rows_for_aplati_les_ecrans() {
73 + let two_factor = catalogue::by_key("two_factor").unwrap();
74 + let rows = rows_for(&[two_factor]);
75 + assert_eq!(rows.len(), 3); // challenge + setup + backup
76 + assert!(rows.iter().all(|r| !r.done));
77 + assert!(rows.iter().any(|r| r.screen == "TwoFactorSetup"));
78 + }
79 +
80 + #[test]
81 + fn render_plan_inclut_charte_et_tableau() {
82 + let auth = catalogue::by_key("authentication").unwrap();
83 + let inputs = PlanInputs {
84 + project: "demo",
85 + selected: vec![auth],
86 + design_md: Some("Police: Inter. Couleur: #123456".into()),
87 + layout: Some("navbar: gauche; 2 colonnes".into()),
88 + };
89 + let rows = rows_for(&inputs.selected);
90 + let out = render_plan(&inputs, &rows);
91 + assert!(out.contains("# Plan UI — demo"));
92 + assert!(out.contains("**`demo-ui`**"), "l'intro doit nommer la crate <projet>-ui");
93 + assert!(out.contains("Police: Inter"));
94 + assert!(out.contains("navbar: gauche"));
95 + assert!(out.contains("| Statut | Fonctionnalité |"));
96 + assert!(out.contains("Login"));
97 + }
98 +
99 + #[test]
100 + fn rows_for_derive_protected_du_niveau_auth() {
101 + // Login est Public ⇒ non protégé ; TwoFactorSetup est Authentifié ⇒ protégé.
102 + let auth = catalogue::by_key("authentication").unwrap();
103 + let tf = catalogue::by_key("two_factor").unwrap();
104 + let login = &rows_for(&[auth])[0];
105 + assert!(!login.protected);
106 + let setup = rows_for(&[tf]).into_iter().find(|r| r.screen == "TwoFactorSetup").unwrap();
107 + assert!(setup.protected);
108 + }
109 +
110 + #[test]
111 + fn render_plan_inclut_les_sections_securite() {
112 + let auth = catalogue::by_key("authentication").unwrap();
113 + let inputs = PlanInputs { project: "demo", selected: vec![auth], design_md: None, layout: None };
114 + let rows = rows_for(&inputs.selected);
115 + let out = render_plan(&inputs, &rows);
116 + assert!(out.contains("<!-- screen-select:security-guides -->"));
117 + assert!(out.contains("Sécurité transversale"));
118 + assert!(out.contains("Conventions de rendu sûr"));
119 + assert!(out.contains("<!-- screen-select:security-per-screen -->"));
120 + assert!(out.contains("## Contrat de sécurité par écran"));
121 + assert!(out.contains("### Login (`/login`) — Public"));
122 + // le bloc statique doit précéder le tableau (vu en premier par le dev)
123 + let guides = out.find("Sécurité transversale").unwrap();
124 + let table = out.find("| Statut").unwrap();
125 + assert!(guides < table, "les garde-fous transversaux doivent précéder le tableau");
126 + }
127 +}
A crates/rustwarden-core/src/bin/screen-select/main.rs
+125 -0
@@ -0,0 +1,125 @@
1 +//! `screen-select` : génère/complète le plan des écrans à coder pour une UI
2 +//! custom consommant rustwarden-core, et recoche le réalisé (`--check`).
3 +
4 +mod catalogue;
5 +mod check;
6 +mod generate;
7 +mod section;
8 +mod security;
9 +mod todo;
10 +
11 +use std::io::{self, Write};
12 +use std::path::Path;
13 +
14 +const PLAN_PATH: &str = "docs/plans/ui-screens.md";
15 +
16 +fn main() -> io::Result<()> {
17 + if std::env::args().any(|a| a == "--check") {
18 + run_check()
19 + } else {
20 + run_wizard()
21 + }
22 +}
23 +
24 +/// Lit une ligne au clavier (chaîne vide si EOF).
25 +fn prompt(msg: &str) -> io::Result<String> {
26 + print!("{msg}");
27 + io::stdout().flush()?;
28 + let mut line = String::new();
29 + io::stdin().read_line(&mut line)?;
30 + Ok(line.trim().to_string())
31 +}
32 +
33 +fn run_wizard() -> io::Result<()> {
34 + println!("== screen-select : écrans à coder ==\n");
35 +
36 + // 1. Sélection des fonctionnalités (les "always" sont incluses d'office).
37 + let choosable: Vec<&catalogue::Functionality> =
38 + catalogue::all().iter().filter(|f| !f.always).collect();
39 + println!("Fonctionnalités activées ? (numéros séparés par des virgules)\n");
40 + for (i, f) in choosable.iter().enumerate() {
41 + println!(" {:>2}. {}", i + 1, f.label);
42 + }
43 + let raw = prompt("\nSélection : ")?;
44 + let picked: Vec<usize> = raw
45 + .split(',')
46 + .filter_map(|s| s.trim().parse::<usize>().ok())
47 + .filter(|n| *n >= 1 && *n <= choosable.len())
48 + .map(|n| n - 1)
49 + .collect();
50 +
51 + let mut selected: Vec<&catalogue::Functionality> =
52 + catalogue::all().iter().filter(|f| f.always).collect();
53 + for i in picked {
54 + selected.push(choosable[i]);
55 + }
56 +
57 + // 2. Entrées optionnelles.
58 + let design_path = prompt("Chemin d'un design.md (charte graphique, vide si aucun) : ")?;
59 + let design_md = if design_path.is_empty() {
60 + None
61 + } else {
62 + Some(std::fs::read_to_string(&design_path)?)
63 + };
64 + let layout = prompt("Structure de page (ex. 'navbar: gauche; 2 colonnes', vide si aucune) : ")?;
65 + let layout = if layout.is_empty() { None } else { Some(layout) };
66 +
67 + let project = std::env::current_dir()?
68 + .file_name()
69 + .map(|n| n.to_string_lossy().into_owned())
70 + .unwrap_or_else(|| "projet".into());
71 +
72 + let inputs = generate::PlanInputs { project: &project, selected, design_md, layout };
73 + let desired = generate::rows_for(&inputs.selected);
74 +
75 + // 3. Fusion si un plan existe déjà (préserve statuts + routes éditées).
76 + let path = Path::new(PLAN_PATH);
77 + let content = if path.exists() {
78 + let existing = std::fs::read_to_string(path)?;
79 + let prev = todo::parse(&existing);
80 + let merged = todo::merge(&prev, &desired);
81 + // conserve l'en-tête existant, remplace le tableau…
82 + let mut c = todo::replace_table(&existing, &merged);
83 + // …puis régénère les sections sécurité (idempotent, suit la sélection courante).
84 + c = section::upsert(&c, security::GUIDES_ID, &security::guides_body());
85 + c = section::upsert(&c, security::PER_SCREEN_ID, &security::reference_body(&inputs.selected));
86 + c
87 + } else {
88 + generate::render_plan(&inputs, &desired)
89 + };
90 +
91 + if let Some(parent) = path.parent() {
92 + std::fs::create_dir_all(parent)?;
93 + }
94 + std::fs::write(path, content)?;
95 + println!("\n✓ Plan écrit dans {PLAN_PATH} ({} écrans).", desired.len());
96 + println!(" Contrôlez le réalisé : cargo run -p rustwarden-core --bin screen-select -- --check");
97 + Ok(())
98 +}
99 +
100 +fn run_check() -> io::Result<()> {
101 + let path = Path::new(PLAN_PATH);
102 + if !path.exists() {
103 + eprintln!("Aucun plan à {PLAN_PATH} — lancez d'abord `screen-select` sans --check.");
104 + std::process::exit(1);
105 + }
106 + let content = std::fs::read_to_string(path)?;
107 + let mut rows = todo::parse(&content);
108 + let haystack = check::collect_sources(&std::env::current_dir()?);
109 + check::mark(&mut rows, &haystack);
110 + let updated = todo::replace_table(&content, &rows);
111 + std::fs::write(path, updated)?;
112 +
113 + let done = rows.iter().filter(|r| r.done).count();
114 + let missing: Vec<&todo::TodoRow> = rows.iter().filter(|r| !r.done).collect();
115 + println!("Couverture : {}/{} écrans réalisés.", done, rows.len());
116 + if missing.is_empty() {
117 + println!("✓ Tous les écrans des fonctionnalités activées sont câblés.");
118 + } else {
119 + println!("Écrans manquants :");
120 + for r in missing {
121 + println!(" ☐ {} — {} ({})", r.functionality, r.screen, r.route);
122 + }
123 + }
124 + Ok(())
125 +}
A crates/rustwarden-core/src/bin/screen-select/section.rs
+63 -0
@@ -0,0 +1,63 @@
1 +//! Remplacement idempotent d'une section délimitée par des marqueurs HTML.
2 +//! Permet de régénérer les blocs de sécurité lors d'un re-run du wizard sans
3 +//! écraser le reste du document (charte, tableau, prose ajoutée par le dev).
4 +
5 +/// Bloc encadré : `<!-- id -->\n{body}\n<!-- /id -->`.
6 +pub fn block(id: &str, body: &str) -> String {
7 + format!("<!-- {id} -->\n{}\n<!-- /{id} -->", body.trim_end())
8 +}
9 +
10 +/// Insère (à la fin) ou remplace en place la section `id` par `body`.
11 +pub fn upsert(content: &str, id: &str, body: &str) -> String {
12 + let block = block(id, body);
13 + let open = format!("<!-- {id} -->");
14 + let close = format!("<!-- /{id} -->");
15 + if let (Some(start), Some(close_at)) = (content.find(&open), content.find(&close)) {
16 + if start < close_at {
17 + let end = close_at + close.len();
18 + let mut out = String::with_capacity(content.len() + block.len());
19 + out.push_str(&content[..start]);
20 + out.push_str(&block);
21 + out.push_str(&content[end..]);
22 + return out;
23 + }
24 + }
25 + let mut out = content.to_string();
26 + if !out.ends_with('\n') {
27 + out.push('\n');
28 + }
29 + out.push('\n');
30 + out.push_str(&block);
31 + out.push('\n');
32 + out
33 +}
34 +
35 +#[cfg(test)]
36 +mod tests {
37 + use super::*;
38 +
39 + #[test]
40 + fn block_encadre_le_corps() {
41 + let b = block("demo:x", "corps\n");
42 + assert_eq!(b, "<!-- demo:x -->\ncorps\n<!-- /demo:x -->");
43 + }
44 +
45 + #[test]
46 + fn upsert_ajoute_a_la_fin_si_absent() {
47 + let out = upsert("# Titre\n", "demo:x", "corps");
48 + assert!(out.starts_with("# Titre\n"));
49 + assert!(out.contains("<!-- demo:x -->\ncorps\n<!-- /demo:x -->"));
50 + }
51 +
52 + #[test]
53 + fn upsert_remplace_en_place_et_est_idempotent() {
54 + let base = format!("A\n\n{}\n\nB\n", block("demo:x", "v1"));
55 + let once = upsert(&base, "demo:x", "v2");
56 + assert!(once.contains("A\n"), "préfixe préservé");
57 + assert!(once.contains("B\n"), "suffixe préservé");
58 + assert!(once.contains("v2"));
59 + assert!(!once.contains("v1"), "ancien corps remplacé");
60 + // idempotence : ré-appliquer avec le même corps ne change rien.
61 + assert_eq!(upsert(&once, "demo:x", "v2"), once);
62 + }
63 +}
A crates/rustwarden-core/src/bin/screen-select/security.rs
+89 -0
@@ -0,0 +1,89 @@
1 +//! Contenu de sécurité injecté dans le plan : garde-fous transversaux (statiques)
2 +//! et contrat de sécurité par écran (dérivé du catalogue).
3 +
4 +use crate::catalogue::{Channel, Functionality};
5 +
6 +/// Identifiant de section pour le bloc statique (transversal + conventions).
7 +pub const GUIDES_ID: &str = "screen-select:security-guides";
8 +/// Identifiant de section pour le contrat de sécurité par écran.
9 +pub const PER_SCREEN_ID: &str = "screen-select:security-per-screen";
10 +
11 +/// Corps statique : sécurité transversale (obligatoire, hors écran) + conventions
12 +/// de rendu sûr. Indépendant de la sélection.
13 +pub fn guides_body() -> String {
14 + let mut s = String::new();
15 + s.push_str("## Sécurité transversale (obligatoire, hors écran)\n\n");
16 + s.push_str("- [ ] Middleware de headers sur TOUTES les réponses : `Content-Security-Policy` (viser sans `unsafe-inline`/`unsafe-eval`), `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`.\n");
17 + s.push_str("- [ ] Validation de configuration au démarrage : refuser `ADMIN_PASSWORD=password` ; avertir si `APP_DEBUG=true`, `COOKIE_SECURE=false` ou `RUST_LOG=debug` en profil production ; secrets uniquement via variables d'environnement.\n");
18 + s.push_str("- [ ] Cookies de session : `HttpOnly`, `Secure` en production, `SameSite`.\n");
19 + s.push_str("- [ ] `cargo audit` régulier ; confirmer que le JWT est signé en **HMAC** (HS256/384/512), pas RSA (neutralise RUSTSEC-2023-0071) ; `accept_invalid_certs` SMTP/IMAP à `false` en production.\n\n");
20 + s.push_str("## Conventions de rendu sûr\n\n");
21 + s.push_str("- **Escaping contextuel** : ne jamais interpoler une valeur contrôlée par l'utilisateur dans un contexte JavaScript (`<script>`). L'échappement HTML d'Askama n'échappe PAS le contexte JS (le backslash notamment). Passer la valeur par un attribut `data-*` et la lire via `dataset`.\n");
22 + s.push_str("- Données dynamiques d'API côté client : `textContent`, jamais `innerHTML`.\n");
23 + s.push_str("- URLs : `encodeURIComponent` / helper `urlenc()` du core ; aucune donnée personnelle en query string.\n");
24 + s.push_str("- Aucun filtre `|safe` Askama sur une valeur contrôlée par l'utilisateur.\n");
25 + s
26 +}
27 +
28 +/// Corps dérivé du catalogue : un paragraphe par écran des fonctionnalités
29 +/// sélectionnées (niveau d'auth, CSRF si browser, pièges spécifiques).
30 +pub fn reference_body(selected: &[&'static Functionality]) -> String {
31 + let mut s = String::new();
32 + s.push_str("## Contrat de sécurité par écran\n\n");
33 + for f in selected {
34 + for sc in f.screens {
35 + s.push_str(&format!(
36 + "### {} (`{}`) — {}\n\n",
37 + sc.name,
38 + sc.suggested_route,
39 + sc.auth_level.label()
40 + ));
41 + if matches!(sc.channel, Channel::Browser) {
42 + s.push_str("- Canal *browser* ⇒ valider le token CSRF sur chaque `POST` (`validate_csrf_token`).\n");
43 + }
44 + for note in sc.security_notes {
45 + s.push_str(&format!("- {note}\n"));
46 + }
47 + s.push('\n');
48 + }
49 + }
50 + s
51 +}
52 +
53 +#[cfg(test)]
54 +mod tests {
55 + use super::*;
56 + use crate::catalogue;
57 +
58 + #[test]
59 + fn guides_couvre_les_axes_transversaux() {
60 + let g = guides_body();
61 + assert!(g.contains("Sécurité transversale"));
62 + assert!(g.contains("Content-Security-Policy"));
63 + assert!(g.contains("X-Frame-Options"));
64 + assert!(g.contains("ADMIN_PASSWORD"));
65 + assert!(g.contains("HMAC"));
66 + assert!(g.contains("Conventions de rendu sûr"));
67 + assert!(g.contains("textContent"));
68 + }
69 +
70 + #[test]
71 + fn reference_rend_le_contrat_par_ecran() {
72 + let two_factor = catalogue::by_key("two_factor").unwrap();
73 + let r = reference_body(&[two_factor]);
74 + assert!(r.contains("## Contrat de sécurité par écran"));
75 + assert!(r.contains("### TwoFactorSetup (`/settings/2fa`) — Authentifié"));
76 + // canal browser ⇒ rappel CSRF automatique
77 + assert!(r.contains("CSRF"));
78 + // piège spécifique repris du catalogue
79 + assert!(r.to_lowercase().contains("rawquery"));
80 + }
81 +
82 + #[test]
83 + fn reference_marque_le_niveau_admin() {
84 + let admin = catalogue::by_key("admin_users").unwrap();
85 + let r = reference_body(&[admin]);
86 + assert!(r.contains("— Administrateur"));
87 + assert!(r.contains("ensure_admin"));
88 + }
89 +}
A crates/rustwarden-core/src/bin/screen-select/todo.rs
+207 -0
@@ -0,0 +1,207 @@
1 +//! Rendu / lecture / fusion du tableau de todo Markdown.
2 +
3 +/// Une ligne du tableau de todo.
4 +#[derive(Clone, Debug, PartialEq, Eq)]
5 +pub struct TodoRow {
6 + pub done: bool,
7 + pub functionality: String,
8 + pub screen: String,
9 + pub route: String,
10 + pub channel: String,
11 + pub protected: bool,
12 + pub methods: String,
13 + pub io: String,
14 +}
15 +
16 +const HEADER: &str =
17 + "| Statut | Fonctionnalité | Écran | Route | Canal | Protégé | Méthodes core | I/O |";
18 +const SEP: &str = "|---|---|---|---|---|---|---|---|";
19 +
20 +/// Rend les lignes en tableau Markdown (en-tête + séparateur + lignes).
21 +pub fn render(rows: &[TodoRow]) -> String {
22 + let mut out = String::new();
23 + out.push_str(HEADER);
24 + out.push('\n');
25 + out.push_str(SEP);
26 + out.push('\n');
27 + for r in rows {
28 + out.push_str(&format!(
29 + "| {} | {} | {} | `{}` | {} | {} | {} | {} |\n",
30 + if r.done { "☑" } else { "☐" },
31 + r.functionality,
32 + r.screen,
33 + r.route,
34 + r.channel,
35 + if r.protected { "oui" } else { "non" },
36 + r.methods,
37 + r.io,
38 + ));
39 + }
40 + out
41 +}
42 +
43 +/// Extrait les lignes de données du premier bloc-tableau rencontré.
44 +pub fn parse(content: &str) -> Vec<TodoRow> {
45 + let mut rows = Vec::new();
46 + for line in content.lines() {
47 + let line = line.trim();
48 + if !line.starts_with('|') {
49 + continue;
50 + }
51 + // ignorer en-tête et séparateur
52 + if line.starts_with("| Statut") || line.starts_with("|---") {
53 + continue;
54 + }
55 + let cells: Vec<&str> = line
56 + .trim_matches('|')
57 + .split('|')
58 + .map(|c| c.trim())
59 + .collect();
60 + if cells.len() != 8 {
61 + continue;
62 + }
63 + rows.push(TodoRow {
64 + done: cells[0].contains('☑'),
65 + functionality: cells[1].to_string(),
66 + screen: cells[2].to_string(),
67 + route: cells[3].trim_matches('`').to_string(),
68 + channel: cells[4].to_string(),
69 + protected: cells[5] == "oui",
70 + methods: cells[6].to_string(),
71 + io: cells[7].to_string(),
72 + });
73 + }
74 + rows
75 +}
76 +
77 +/// Clé d'identité d'une ligne (indépendante de la route, éditable).
78 +fn key(r: &TodoRow) -> (&str, &str) {
79 + (r.functionality.as_str(), r.screen.as_str())
80 +}
81 +
82 +/// Produit les lignes pour l'ensemble `desired`, en conservant `done` et
83 +/// `route` des lignes `existing` de même (fonctionnalité, écran).
84 +pub fn merge(existing: &[TodoRow], desired: &[TodoRow]) -> Vec<TodoRow> {
85 + desired
86 + .iter()
87 + .map(|d| match existing.iter().find(|e| key(e) == key(d)) {
88 + Some(prev) => TodoRow {
89 + done: prev.done,
90 + route: prev.route.clone(),
91 + ..d.clone()
92 + },
93 + None => d.clone(),
94 + })
95 + .collect()
96 +}
97 +
98 +/// Remplace le premier bloc-tableau (`|`…) de `content` par le rendu de `rows`.
99 +pub fn replace_table(content: &str, rows: &[TodoRow]) -> String {
100 + let lines: Vec<&str> = content.lines().collect();
101 + let start = lines.iter().position(|l| l.trim_start().starts_with('|'));
102 + let Some(start) = start else {
103 + // pas de tableau existant : on ajoute à la fin
104 + let mut out = content.to_string();
105 + if !out.ends_with('\n') {
106 + out.push('\n');
107 + }
108 + out.push_str(&render(rows));
109 + return out;
110 + };
111 + let mut end = start;
112 + while end < lines.len() && lines[end].trim_start().starts_with('|') {
113 + end += 1;
114 + }
115 + let mut out = String::new();
116 + for l in &lines[..start] {
117 + out.push_str(l);
118 + out.push('\n');
119 + }
120 + out.push_str(&render(rows));
121 + for l in &lines[end..] {
122 + out.push_str(l);
123 + out.push('\n');
124 + }
125 + out
126 +}
127 +
128 +#[cfg(test)]
129 +mod tests {
130 + use super::*;
131 +
132 + fn row() -> TodoRow {
133 + TodoRow {
134 + done: false,
135 + functionality: "TwoFactor".into(),
136 + screen: "TwoFactorSetup".into(),
137 + route: "/settings/2fa".into(),
138 + channel: "browser".into(),
139 + protected: true,
140 + methods: "TotpService".into(),
141 + io: "QR".into(),
142 + }
143 + }
144 +
145 + #[test]
146 + fn render_contient_entete_et_ligne() {
147 + let out = render(&[row()]);
148 + assert!(out.contains(HEADER));
149 + assert!(out.contains(SEP));
150 + assert!(out.contains("| ☐ | TwoFactor | TwoFactorSetup | `/settings/2fa` | browser | oui | TotpService | QR |"));
151 + }
152 +
153 + #[test]
154 + fn render_coche_les_faits() {
155 + let mut r = row();
156 + r.done = true;
157 + assert!(render(&[r]).contains("| ☑ |"));
158 + }
159 +
160 + #[test]
161 + fn parse_round_trip() {
162 + let mut a = row();
163 + a.done = true;
164 + let b = TodoRow { screen: "SessionsList".into(), route: "/settings/sessions".into(), ..row() };
165 + let rendered = render(&[a.clone(), b.clone()]);
166 + let parsed = parse(&rendered);
167 + assert_eq!(parsed, vec![a, b]);
168 + }
169 +
170 + #[test]
171 + fn parse_ignore_hors_tableau() {
172 + let md = format!("# Titre\n\ntexte\n\n{}", render(&[row()]));
173 + assert_eq!(parse(&md).len(), 1);
174 + }
175 +
176 + #[test]
177 + fn merge_preserve_statut_et_route() {
178 + let mut existing = row();
179 + existing.done = true;
180 + existing.route = "/custom/2fa".into(); // route éditée par le dev
181 + // "desired" = catalogue (route suggérée par défaut), même clé (func+screen)
182 + let desired = row();
183 + let merged = merge(&[existing.clone()], &[desired.clone()]);
184 + assert_eq!(merged.len(), 1);
185 + assert!(merged[0].done, "statut conservé");
186 + assert_eq!(merged[0].route, "/custom/2fa", "route éditée conservée");
187 + }
188 +
189 + #[test]
190 + fn merge_ajoute_les_nouveaux() {
191 + let desired_new = TodoRow { functionality: "Teams".into(), screen: "TeamsManagement".into(), ..row() };
192 + let merged = merge(&[], &[row(), desired_new.clone()]);
193 + assert_eq!(merged.len(), 2);
194 + assert!(merged.iter().any(|r| r.screen == "TeamsManagement" && !r.done));
195 + }
196 +
197 + #[test]
198 + fn replace_table_remplace_le_bloc() {
199 + let content = format!("# Titre\n\n## Écrans\n\n{}\n(fin)\n", render(&[row()]));
200 + let mut r = row();
201 + r.done = true;
202 + let out = replace_table(&content, &[r]);
203 + assert!(out.contains("# Titre"), "en-tête préservé");
204 + assert!(out.contains("(fin)"), "suffixe préservé");
205 + assert!(out.contains("| ☑ |"), "tableau mis à jour");
206 + }
207 +}
M crates/rustwarden-core/src/config/auth.rs
+95 -0
@@ -62,6 +62,17 @@ pub struct AuthConfig {
62 62 pub cookie_secure: bool,
63 63 /// Value of the `SameSite` cookie attribute (env: `COOKIE_SAME_SITE`, default: `"Strict"`).
64 64 pub cookie_same_site: String,
65 + /// Path the auth middleware redirects anonymous browser visitors to
66 + /// (env: `LOGIN_PATH`, default: `"/login"`). Decoupled from the UI so
67 + /// consumers with a custom login route — or none, in headless mode —
68 + /// are not forced onto `rustwarden-ui`'s `/login`.
69 + pub login_path: String,
70 + /// How the auth middleware answers a protected route reached without
71 + /// valid credentials (env: `AUTH_FAILURE_MODE`, default: `Redirect`).
72 + /// Headless apps set this to [`AuthFailureMode::Json401`] so browsers
73 + /// and API clients receive a `401` instead of a redirect to a login
74 + /// page that does not exist.
75 + pub auth_failure_mode: AuthFailureMode,
65 76 }
66 77
67 78 impl AuthConfig {
@@ -191,6 +202,19 @@ impl AuthConfig {
191 202 let cookie_same_site =
192 203 env::var("COOKIE_SAME_SITE").unwrap_or_else(|_| "Strict".to_string());
193 204
205 + // Decouple the login redirect target from the UI crate.
206 + let login_path = env::var("LOGIN_PATH").unwrap_or_else(|_| "/login".to_string());
207 +
208 + // Auth-failure behaviour; invalid values fall back to the default
209 + // (Redirect) after a warning rather than aborting startup.
210 + let auth_failure_mode = match env::var("AUTH_FAILURE_MODE") {
211 + Ok(raw) => raw.parse::<AuthFailureMode>().unwrap_or_else(|err| {
212 + tracing::warn!("{err}; falling back to AuthFailureMode::Redirect");
213 + AuthFailureMode::Redirect
214 + }),
215 + Err(_) => AuthFailureMode::Redirect,
216 + };
217 +
194 218 tracing::info!(
195 219 "Auth configuration loaded: JWT expiration={}min, Session backend={:?}, Session timeout={}min",
196 220 jwt_expiration_minutes,
@@ -218,6 +242,8 @@ impl AuthConfig {
218 242 rate_limit_general_per_minute,
219 243 cookie_secure,
220 244 cookie_same_site,
245 + login_path,
246 + auth_failure_mode,
221 247 })
222 248 }
223 249
@@ -239,3 +265,72 @@ impl AuthConfig {
239 265 chrono::Duration::minutes(self.session_timeout_minutes)
240 266 }
241 267 }
268 +
269 +/// Behaviour of the authentication middleware when a protected route is
270 +/// reached without valid credentials.
271 +///
272 +/// Selected per application: SSR/UI apps keep the default [`Redirect`], while
273 +/// headless (API-only) apps switch to [`Json401`] via
274 +/// [`crate::app::RustwardenBuilder::headless`] or the
275 +/// [`crate::app::RustwardenBuilder::auth_failure_mode`] setter.
276 +///
277 +/// [`Redirect`]: AuthFailureMode::Redirect
278 +/// [`Json401`]: AuthFailureMode::Json401
279 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
280 +pub enum AuthFailureMode {
281 + /// Reply with `303 See Other` to [`AuthConfig::login_path`], carrying the
282 + /// original path in a `redirect` query parameter. Suited to browser UIs.
283 + #[default]
284 + Redirect,
285 + /// Reply with `401 Unauthorized` and a JSON body. Suited to SPAs, mobile
286 + /// clients, and machine APIs where a redirect to an HTML login page is
287 + /// meaningless.
288 + Json401,
289 +}
290 +
291 +impl std::str::FromStr for AuthFailureMode {
292 + type Err = String;
293 +
294 + /// Parse the `AUTH_FAILURE_MODE` environment value (case-insensitive).
295 + fn from_str(s: &str) -> Result<Self, Self::Err> {
296 + match s.trim().to_ascii_lowercase().as_str() {
297 + "redirect" => Ok(Self::Redirect),
298 + "json401" | "json_401" | "json" | "401" => Ok(Self::Json401),
299 + other => Err(format!(
300 + "invalid AUTH_FAILURE_MODE '{other}' (expected 'redirect' or 'json401')"
301 + )),
302 + }
303 + }
304 +}
305 +
306 +#[cfg(test)]
307 +mod tests {
308 + use super::AuthFailureMode;
309 + use std::str::FromStr;
310 +
311 + #[test]
312 + fn auth_failure_mode_defaults_to_redirect() {
313 + assert_eq!(AuthFailureMode::default(), AuthFailureMode::Redirect);
314 + }
315 +
316 + #[test]
317 + fn auth_failure_mode_parses_known_values_case_insensitively() {
318 + assert_eq!(
319 + AuthFailureMode::from_str("redirect").unwrap(),
320 + AuthFailureMode::Redirect
321 + );
322 + assert_eq!(
323 + AuthFailureMode::from_str("Json401").unwrap(),
324 + AuthFailureMode::Json401
325 + );
326 + assert_eq!(
327 + AuthFailureMode::from_str(" JSON ").unwrap(),
328 + AuthFailureMode::Json401
329 + );
330 + }
331 +
332 + #[test]
333 + fn auth_failure_mode_rejects_unknown_value() {
334 + assert!(AuthFailureMode::from_str("nope").is_err());
335 + }
336 +}
M crates/rustwarden-core/src/middleware/admin_extractor.rs
+5 -5
@@ -43,11 +43,11 @@ where
43 43 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
44 44 // Get Claims from extensions (added by admin_required middleware)
45 45 let claims = parts.extensions.get::<Claims>().ok_or_else(|| {
46 - // If Claims not in extensions, middleware didn't run
47 - // This should not happen if middleware is properly applied
48 - let uri = parts.uri.clone();
49 - let redirect_url = format!("/login?redirect={}", uri);
50 - axum::response::Redirect::to(&redirect_url).into_response()
46 + // Claims absent → the auth middleware did not run for this route.
47 + crate::middleware::auth_service::auth_failure_from_extensions(
48 + &parts.extensions,
49 + &parts.uri,
50 + )
51 51 })?;
52 52
53 53 // Verify admin role
M crates/rustwarden-core/src/middleware/admin_required.rs
+9 -5
@@ -7,7 +7,7 @@ use axum::{
7 7 extract::{Request, State},
8 8 http::{StatusCode, Uri},
9 9 middleware::Next,
10 - response::{IntoResponse, Redirect, Response},
10 + response::{IntoResponse, Response},
11 11 };
12 12 use sea_orm::DatabaseConnection;
13 13
@@ -72,8 +72,10 @@ pub async fn admin_required(
72 72 Some(claims) => claims,
73 73 None => {
74 74 tracing::warn!("Unauthenticated access attempt to admin route: {}", uri);
75 - let redirect_url = crate::middleware::auth_service::sanitize_redirect(&uri.to_string());
76 - return Redirect::to(&redirect_url).into_response();
75 + return crate::middleware::auth_service::auth_failure_from_extensions(
76 + request.extensions(),
77 + &uri,
78 + );
77 79 }
78 80 };
79 81
@@ -111,8 +113,10 @@ pub async fn admin_required_impl(
111 113 Some(claims) => claims,
112 114 None => {
113 115 tracing::warn!("Unauthenticated access attempt to admin route: {}", uri);
114 - let redirect_url = crate::middleware::auth_service::sanitize_redirect(&uri.to_string());
115 - return Ok(Redirect::to(&redirect_url).into_response());
116 + return Ok(crate::middleware::auth_service::auth_failure_from_extensions(
117 + request.extensions(),
118 + &uri,
119 + ));
116 120 }
117 121 };
118 122
M crates/rustwarden-core/src/middleware/admin_service.rs
+26 -31
@@ -60,39 +60,15 @@ where
60 60 // We use parts to extract JWT (no need for body)
61 61 let (parts, body) = req.into_parts();
62 62
63 - // Extract JWT token from parts
64 - let jwt_token =
65 - match crate::auth::jwt_extractor::extract_jwt_from_request_parts(&parts) {
66 - Ok(token) => {
67 - tracing::debug!("AdminService: JWT token found");
68 - token
69 - }
70 - Err(_) => {
71 - tracing::warn!(
72 - "AdminService: No JWT token found, redirecting to login"
73 - );
74 - let redirect_url = auth_service::sanitize_redirect(uri.path());
75 - return Ok(axum::response::Redirect::to(&redirect_url).into_response());
76 - }
77 - };
78 -
79 - // Validate JWT and extract claims
80 63 use crate::auth::jwt::JwtService;
81 64 use crate::config::auth::AuthConfig;
82 65 use crate::services::jwt_blacklist_service::JwtBlacklistService;
83 66
84 - if JwtBlacklistService::is_blacklisted(&db, &jwt_token)
85 - .await
86 - .is_ok_and(|b| b)
87 - {
88 - let redirect_url = auth_service::sanitize_redirect(uri.path());
89 - return Ok(axum::response::Redirect::to(&redirect_url).into_response());
90 - }
91 -
92 - // Load auth config from request extensions; fall back to
93 - // `AuthConfig::from_env()` when the layer order puts the
94 - // Extension layer further inside the stack (same regression
95 - // as `AuthService` since commit `d4d6828`).
67 + // Resolve auth config up-front so every auth-failure branch
68 + // honours `login_path` / `auth_failure_mode`. Prefer the
69 + // injected `Extension`; fall back to `AuthConfig::from_env()`
70 + // when the layer order puts the Extension further inside the
71 + // stack (same regression as `AuthService` since `d4d6828`).
96 72 let auth_config = match parts
97 73 .extensions
98 74 .get::<std::sync::Arc<AuthConfig>>()
@@ -114,11 +90,30 @@ where
114 90 },
115 91 };
116 92
93 + // Extract JWT token from parts
94 + let jwt_token =
95 + match crate::auth::jwt_extractor::extract_jwt_from_request_parts(&parts) {
96 + Ok(token) => {
97 + tracing::debug!("AdminService: JWT token found");
98 + token
99 + }
100 + Err(_) => {
101 + tracing::warn!("AdminService: No JWT token found");
102 + return Ok(auth_service::auth_failure_response(&auth_config, &uri));
103 + }
104 + };
105 +
106 + if JwtBlacklistService::is_blacklisted(&db, &jwt_token)
107 + .await
108 + .is_ok_and(|b| b)
109 + {
110 + return Ok(auth_service::auth_failure_response(&auth_config, &uri));
111 + }
112 +
117 113 let claims = match JwtService::validate_token(&auth_config, &jwt_token) {
118 114 Ok(claims) => claims,
119 115 Err(_) => {
120 - let redirect_url = auth_service::sanitize_redirect(uri.path());
121 - return Ok(axum::response::Redirect::to(&redirect_url).into_response());
116 + return Ok(auth_service::auth_failure_response(&auth_config, &uri));
122 117 }
123 118 };
124 119
M crates/rustwarden-core/src/middleware/auth_required.rs
+13 -7
@@ -7,7 +7,7 @@ use axum::{
7 7 extract::{Request, State},
8 8 http::Uri,
9 9 middleware::Next,
10 - response::{IntoResponse, Redirect, Response},
10 + response::{IntoResponse, Response},
11 11 };
12 12 use sea_orm::DatabaseConnection;
13 13
@@ -139,16 +139,20 @@ pub async fn auth_required_impl(
139 139 Ok(token) => token,
140 140 Err(_) => {
141 141 tracing::warn!("Unauthenticated access attempt to: {} (no token)", uri);
142 - let redirect_url = crate::middleware::auth_service::sanitize_redirect(&uri.to_string());
143 - return Ok(Redirect::to(&redirect_url).into_response());
142 + return Ok(crate::middleware::auth_service::auth_failure_response(
143 + &auth_config,
144 + &uri,
145 + ));
144 146 }
145 147 };
146 148
147 149 // Check if token is blacklisted
148 150 if JwtBlacklistService::is_blacklisted(db, &jwt_token).await? {
149 151 tracing::warn!("Blacklisted JWT token used for: {}", uri);
150 - let redirect_url = crate::middleware::auth_service::sanitize_redirect(&uri.to_string());
151 - return Ok(Redirect::to(&redirect_url).into_response());
152 + return Ok(crate::middleware::auth_service::auth_failure_response(
153 + &auth_config,
154 + &uri,
155 + ));
152 156 }
153 157
154 158 // Validate JWT token
@@ -156,8 +160,10 @@ pub async fn auth_required_impl(
156 160 Ok(claims) => claims,
157 161 Err(e) => {
158 162 tracing::warn!("Invalid JWT token for {}: {}", uri, e);
159 - let redirect_url = crate::middleware::auth_service::sanitize_redirect(&uri.to_string());
160 - return Ok(Redirect::to(&redirect_url).into_response());
163 + return Ok(crate::middleware::auth_service::auth_failure_response(
164 + &auth_config,
165 + &uri,
166 + ));
161 167 }
162 168 };
163 169
M crates/rustwarden-core/src/middleware/auth_service.rs
+158 -40
@@ -4,7 +4,8 @@
4 4 /// This works around Axum 0.8 Sync limitations with from_fn_with_state.
5 5 use axum::{
6 6 extract::Request,
7 - response::{IntoResponse, Response},
7 + http::{StatusCode, Uri},
8 + response::{IntoResponse, Redirect, Response},
8 9 };
9 10 use sea_orm::DatabaseConnection;
10 11 use std::{
@@ -15,15 +16,65 @@ use std::{
15 16 };
16 17 use tower::Service;
17 18
19 +use crate::config::auth::{AuthConfig, AuthFailureMode};
20 +
18 21 /// SEC-C4: Sanitize redirect URL to prevent open redirect attacks.
19 22 /// Only allows relative paths starting with '/' (not '//').
20 23 /// The path is URL-encoded to prevent parameter injection.
21 -pub(crate) fn sanitize_redirect(uri: &str) -> String {
24 +///
25 +/// `login_path` is the destination anonymous visitors are sent to,
26 +/// configurable via [`AuthConfig::login_path`] so the middleware is not
27 +/// hard-wired to `rustwarden-ui`'s `/login`. The original target is preserved
28 +/// in a `redirect` query parameter when it is a safe relative path.
29 +pub(crate) fn sanitize_redirect(uri: &str, login_path: &str) -> String {
22 30 let path = uri.to_string();
23 31 if path.starts_with('/') && !path.starts_with("//") && !path.contains('\0') {
24 - format!("/login?redirect={}", urlencoding::encode(&path))
32 + format!("{login_path}?redirect={}", urlencoding::encode(&path))
25 33 } else {
26 - "/login".to_string()
34 + login_path.to_string()
35 + }
36 +}
37 +
38 +/// Response returned when a protected route is reached without valid
39 +/// credentials, honouring [`AuthConfig::auth_failure_mode`].
40 +///
41 +/// - [`AuthFailureMode::Redirect`] → `303 See Other` to
42 +/// [`AuthConfig::login_path`] (browser UIs).
43 +/// - [`AuthFailureMode::Json401`] → `401 Unauthorized` with a JSON body
44 +/// (headless / API apps, where redirecting to an HTML login page is
45 +/// meaningless).
46 +pub(crate) fn auth_failure_response(auth_config: &AuthConfig, uri: &Uri) -> Response {
47 + match auth_config.auth_failure_mode {
48 + AuthFailureMode::Json401 => (
49 + StatusCode::UNAUTHORIZED,
50 + axum::Json(serde_json::json!({ "error": "unauthorized" })),
51 + )
52 + .into_response(),
53 + AuthFailureMode::Redirect => {
54 + let url = sanitize_redirect(uri.path(), &auth_config.login_path);
55 + Redirect::to(&url).into_response()
56 + }
57 + }
58 +}
59 +
60 +/// Same as [`auth_failure_response`], for call sites that only hold request
61 +/// extensions (extractors, `from_fn` middlewares that don't pre-resolve the
62 +/// config). Resolves [`AuthConfig`] from the injected `Extension`, else from
63 +/// the environment, else falls back to a default `Redirect` to `/login`.
64 +pub(crate) fn auth_failure_from_extensions(
65 + extensions: &axum::http::Extensions,
66 + uri: &Uri,
67 +) -> Response {
68 + let config = extensions
69 + .get::<Arc<AuthConfig>>()
70 + .cloned()
71 + .or_else(|| AuthConfig::from_env().ok().map(Arc::new));
72 + match config {
73 + Some(cfg) => auth_failure_response(&cfg, uri),
74 + None => {
75 + let url = sanitize_redirect(uri.path(), "/login");
76 + Redirect::to(&url).into_response()
77 + }
27 78 }
28 79 }
29 80
@@ -136,39 +187,15 @@ where
136 187 Box::pin(async move {
137 188 let (parts, body) = req.into_parts();
138 189
139 - let jwt_token =
140 - match crate::auth::jwt_extractor::extract_jwt_from_request_parts(&parts) {
141 - Ok(token) => {
142 - tracing::debug!("AuthService: JWT token found");
143 - token
144 - }
145 - Err(_) => {
146 - tracing::warn!("AuthService: No JWT token found, redirecting to login");
147 - let redirect_url = sanitize_redirect(uri.path());
148 - return Ok(axum::response::Redirect::to(&redirect_url).into_response());
149 - }
150 - };
151 -
152 190 use crate::auth::jwt::JwtService;
153 - use crate::config::auth::AuthConfig;
154 191 use crate::services::jwt_blacklist_service::JwtBlacklistService;
155 192
156 - if JwtBlacklistService::is_blacklisted(&db, &jwt_token)
157 - .await
158 - .is_ok_and(|b| b)
159 - {
160 - tracing::warn!("AuthService: JWT token is blacklisted, redirecting to login");
161 - let redirect_url = sanitize_redirect(uri.path());
162 - return Ok(axum::response::Redirect::to(&redirect_url).into_response());
163 - }
164 -
165 - // Read AuthConfig from request extensions when the
166 - // outer router injects it (`.layer(Extension(auth_config))`),
167 - // otherwise fall back to `AuthConfig::from_env()` so this
168 - // middleware keeps working when the layer order puts the
193 + // Resolve AuthConfig up-front so every authentication-failure
194 + // branch below can honour `login_path` / `auth_failure_mode`.
195 + // Prefer the injected `Extension`; fall back to
196 + // `AuthConfig::from_env()` when the layer order puts the
169 197 // Extension layer further inside the stack — the regression
170 - // commit `d4d6828` flipped this path to a hard 500 by
171 - // mistake.
198 + // commit `d4d6828` flipped this path to a hard 500 by mistake.
172 199 let auth_config = match parts
173 200 .extensions
174 201 .get::<std::sync::Arc<AuthConfig>>()
@@ -190,12 +217,31 @@ where
190 217 },
191 218 };
192 219
220 + let jwt_token =
221 + match crate::auth::jwt_extractor::extract_jwt_from_request_parts(&parts) {
222 + Ok(token) => {
223 + tracing::debug!("AuthService: JWT token found");
224 + token
225 + }
226 + Err(_) => {
227 + tracing::warn!("AuthService: No JWT token found");
228 + return Ok(auth_failure_response(&auth_config, &uri));
229 + }
230 + };
231 +
232 + if JwtBlacklistService::is_blacklisted(&db, &jwt_token)
233 + .await
234 + .is_ok_and(|b| b)
235 + {
236 + tracing::warn!("AuthService: JWT token is blacklisted");
237 + return Ok(auth_failure_response(&auth_config, &uri));
238 + }
239 +
193 240 let claims = match JwtService::validate_token(&auth_config, &jwt_token) {
194 241 Ok(claims) => claims,
195 242 Err(_) => {
196 - tracing::warn!("AuthService: Invalid JWT token, redirecting to login");
197 - let redirect_url = sanitize_redirect(uri.path());
198 - return Ok(axum::response::Redirect::to(&redirect_url).into_response());
243 + tracing::warn!("AuthService: Invalid JWT token");
244 + return Ok(auth_failure_response(&auth_config, &uri));
199 245 }
200 246 };
201 247
@@ -217,10 +263,7 @@ where
217 263 "AuthService: remote_identity {} is revoked, rejecting JWT",
218 264 rid
219 265 );
220 - let redirect_url = sanitize_redirect(uri.path());
221 - return Ok(
222 - axum::response::Redirect::to(&redirect_url).into_response()
223 - );
266 + return Ok(auth_failure_response(&auth_config, &uri));
224 267 }
225 268 }
226 269 }
@@ -381,3 +424,78 @@ where
381 424 }
382 425 }
383 426 }
427 +
428 +#[cfg(test)]
429 +mod tests {
430 + use super::{auth_failure_response, sanitize_redirect};
431 + use crate::config::auth::{AuthConfig, AuthFailureMode, SessionBackend};
432 + use axum::http::{StatusCode, Uri, header::LOCATION};
433 +
434 + fn test_config(mode: AuthFailureMode, login_path: &str) -> AuthConfig {
435 + AuthConfig {
436 + jwt_secret: "0123456789abcdef0123456789abcdef".to_string(),
437 + jwt_expiration_minutes: 15,
438 + jwt_issuer: "test".to_string(),
439 + refresh_token_expiration_days: 7,
440 + remember_me_expiration_days: 30,
441 + session_timeout_minutes: 30,
442 + session_backend: SessionBackend::Memory,
443 + redis_url: None,
444 + rate_limit_login_per_minute: 5,
445 + rate_limit_refresh_per_minute: 10,
446 + rate_limit_general_per_minute: 100,
447 + cookie_secure: false,
448 + cookie_same_site: "Strict".to_string(),
449 + login_path: login_path.to_string(),
450 + auth_failure_mode: mode,
451 + }
452 + }
453 +
454 + #[test]
455 + fn sanitize_redirect_preserves_default_login_path() {
456 + assert_eq!(
457 + sanitize_redirect("/settings", "/login"),
458 + "/login?redirect=%2Fsettings"
459 + );
460 + }
461 +
462 + #[test]
463 + fn sanitize_redirect_honors_custom_login_path() {
464 + assert_eq!(
465 + sanitize_redirect("/settings", "/auth/signin"),
466 + "/auth/signin?redirect=%2Fsettings"
467 + );
468 + }
469 +
470 + #[test]
471 + fn sanitize_redirect_reduces_unsafe_target_to_bare_login_path() {
472 + assert_eq!(
473 + sanitize_redirect("//evil.example", "/auth/signin"),
474 + "/auth/signin"
475 + );
476 + }
477 +
478 + #[test]
479 + fn auth_failure_redirect_mode_points_to_configured_login_path() {
480 + let uri: Uri = "/settings".parse().unwrap();
481 + let resp = auth_failure_response(&test_config(AuthFailureMode::Redirect, "/auth/signin"), &uri);
482 + assert_eq!(resp.status(), StatusCode::SEE_OTHER);
483 + let location = resp
484 + .headers()
485 + .get(LOCATION)
486 + .and_then(|v| v.to_str().ok())
487 + .unwrap_or_default();
488 + assert!(
489 + location.starts_with("/auth/signin"),
490 + "unexpected Location header: {location}"
491 + );
492 + }
493 +
494 + #[test]
495 + fn auth_failure_json401_mode_returns_401_without_location() {
496 + let uri: Uri = "/settings".parse().unwrap();
497 + let resp = auth_failure_response(&test_config(AuthFailureMode::Json401, "/login"), &uri);
498 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
499 + assert!(resp.headers().get(LOCATION).is_none());
500 + }
501 +}
M crates/rustwarden-fediverse-guard/Cargo.toml
+1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "rustwarden-fediverse-guard"
3 -version = "0.1.0"
3 +version = "1.0.0"
4 4 edition.workspace = true
5 5 rust-version.workspace = true
6 6 description = "Real-time abuse detection and ban management for the Rustwarden ActivityPub federation layer (RFC 0028+)"
M crates/rustwarden-fediverse-ui/Cargo.toml
+1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "rustwarden-fediverse-ui"
3 -version = "0.1.0"
3 +version = "1.0.0"
4 4 edition.workspace = true
5 5 rust-version.workspace = true
6 6 description = "Reference HTML UI for Rustwarden ActivityPub federation — handlers, templates, dashboard card"
M crates/rustwarden-fediverse/Cargo.toml
+1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "rustwarden-fediverse"
3 -version = "0.1.0"
3 +version = "1.0.0"
4 4 edition.workspace = true
5 5 rust-version.workspace = true
6 6 description = "ActivityPub federation for Rustwarden — WebFinger, actor, inbox, HTTP signatures, AP-native auth, cross-instance team invitations"
M crates/rustwarden-fediverse/src/auth_routes.rs
+2 -0
@@ -413,6 +413,8 @@ mod tests {
413 413 rate_limit_general_per_minute: 100,
414 414 cookie_secure: false,
415 415 cookie_same_site: "Strict".to_string(),
416 + login_path: "/login".to_string(),
417 + auth_failure_mode: rustwarden_core::config::auth::AuthFailureMode::Redirect,
416 418 }
417 419 }
418 420
M crates/rustwarden-fediverse/src/services/fediverse_auth_service.rs
+2 -0
@@ -606,6 +606,8 @@ mod tests {
606 606 rate_limit_general_per_minute: 100,
607 607 cookie_secure: false,
608 608 cookie_same_site: "Strict".to_string(),
609 + login_path: "/login".to_string(),
610 + auth_failure_mode: rustwarden_core::config::auth::AuthFailureMode::Redirect,
609 611 }
610 612 }
611 613
M crates/rustwarden-ui/Cargo.toml
+1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "rustwarden-ui"
3 -version = "0.1.0"
3 +version = "1.0.0"
4 4 edition.workspace = true
5 5 rust-version.workspace = true
6 6 description = "Default UI for Rustwarden – HTML templates, handlers, admin panel"
M crates/rustwarden-ui/src/routes.rs
+6 -30
@@ -9,7 +9,6 @@ use crate::handlers::settings::{preferences, profile, security};
9 9 use crate::handlers::{
10 10 admin_handler, health, home_handler, login_handler, root_handler, totp_handler, user_handler,
11 11 };
12 -use rustwarden_core::auth::handlers as auth_handlers;
13 12
14 13 /// Build the **browser** family of UI routes (SSR, session-cookie driven).
15 14 ///
@@ -124,39 +123,16 @@ pub fn browser_routes() -> Router<DatabaseConnection> {
124 123 /// on the machine channel of the application builder
125 124 /// (`merge_machine_routes`), never behind the CSRF layer.
126 125 pub fn api_routes() -> Router<DatabaseConnection> {
127 - let auth_routes = Router::new()
128 - .route("/api/v1/auth/login", post(auth_handlers::login))
129 - .route("/api/v1/auth/register", post(auth_handlers::register))
130 - .route("/api/v1/auth/refresh", post(auth_handlers::refresh_token))
131 - .route("/api/v1/auth/logout", post(auth_handlers::logout))
132 - .route("/api/v1/auth/me", get(auth_handlers::get_current_user))
133 - .route(
134 - "/api/v1/auth/forgot-password",
135 - post(auth_handlers::forgot_password),
136 - )
137 - .route(
138 - "/api/v1/auth/reset-password",
139 - post(auth_handlers::reset_password),
140 - )
141 - .route(
142 - "/api/v1/auth/verify-email/{token}",
143 - get(auth_handlers::verify_email),
144 - )
145 - .route(
146 - "/api/v1/auth/resend-verification",
147 - post(auth_handlers::resend_verification),
148 - )
149 - .route(
150 - "/api/v1/auth/resend-verification/by-username",
151 - post(auth_handlers::resend_verification_by_username),
152 - )
153 - .route("/api/v1/auth/2fa/verify", post(auth_handlers::verify_2fa));
154 -
126 + // Auth endpoints are provided canonically by the framework core
127 + // (`rustwarden_core::auth::auth_api_routes`); mount that set and add the
128 + // UI crate's own user-management API on top.
155 129 let user_routes = Router::new()
156 130 .route("/api/v1/users", post(user_handler::create_user))
157 131 .route("/api/v1/users", get(user_handler::list_users));
158 132
159 - Router::new().merge(auth_routes).merge(user_routes)
133 + Router::new()
134 + .merge(rustwarden_core::auth::auth_api_routes())
135 + .merge(user_routes)
160 136 }
161 137
162 138 /// Create the main application router for Rustwarden.
M docs/SERVICES_REFERENCE.md
+94 -0
@@ -37,6 +37,7 @@
37 37 19. [Utilitaires](#19-utilitaires)
38 38 20. [Patterns communs](#20-patterns-communs)
39 39 21. [Fédération ActivityPub (features `fediverse*`)](#21-federation-activitypub-features-fediverse)
40 +22. [Binaire `screen-select` — plan des écrans UI](#22-binaire-screen-select--plan-des-ecrans-ui)
40 41
41 42 ---
42 43
@@ -1086,6 +1087,31 @@ Wrapper pratiques par-dessus `RateLimitService` :
1086 1087 - `auth_service::auth_middleware` — auth primaire (extraction JWT + validation + injection `Claims` en Extension).
1087 1088 - `password_complexity::enforce_password_complexity` — helper de validation.
1088 1089
1090 +#### Préfixes protégés & mode d'échec (⚠ changement 2026-08)
1091 +
1092 +L'auth middleware ne protège que les **préfixes enregistrés**. **Depuis 2026-08, l'ensemble par défaut est vide** (auparavant `/home`, `/profile`, `/test-base`, `/settings`, `/admin`) :
1093 +
1094 +- `with_ui(..)` enregistre automatiquement les zones UI `/home`, `/profile`, `/settings`, `/admin` — une app UI reste protégée sans rien changer.
1095 +- `/admin` et `/api/v1/admin` restent **en plus** protégés indépendamment par `AdminService`.
1096 +- Une app **headless** (sans `with_ui`) ne protège **que** ce qu'elle déclare via `register_protected_prefix("/…")`.
1097 +
1098 +> **⚠ Migration au prochain `update-project` (apps headless uniquement)** : si votre app sert des pages sous `/home`, `/profile` ou `/settings` en comptant sur les anciens défauts, ajoutez les `register_protected_prefix(..)` correspondants — sinon ces pages deviennent **publiques**. `/admin/*` reste couvert par `AdminService`. Contrôle : `grep -rn 'register_protected_prefix' src/` puis comparer avec vos `.route("/home | /profile | /settings …")`.
1099 +
1100 +Mode d'échec sur préfixe protégé atteint sans credentials valides — `AuthConfig.auth_failure_mode` :
1101 +- `Redirect` (défaut UI) : `303` vers `login_path` (défaut `/login`, env `LOGIN_PATH` ou builder `.login_path("/…")`).
1102 +- `Json401` : `401 {"error":"unauthorized"}`. **`.headless()` bascule le défaut sur `Json401`** ; override explicite via `.auth_failure_mode(AuthFailureMode::…)` (env `AUTH_FAILURE_MODE=redirect|json401`).
1103 +
1104 +#### Router auth JSON prêt à monter
1105 +
1106 +`rustwarden_core::auth::auth_api_routes() -> Router<DatabaseConnection>` monte les endpoints `/api/v1/auth/*` (login, register, refresh, logout, me, forgot/reset-password, verify-email, resend-verification, 2fa/verify). À poser sur le canal **machine** :
1107 +
1108 +```rust
1109 +.headless()
1110 +.merge_machine_routes(rustwarden_core::auth::auth_api_routes())
1111 +```
1112 +
1113 +Les projets générés par le CLI en mode headless câblent déjà ce router (+ un endpoint protégé d'exemple `whoami` via l'extracteur `AuthUser`). `rustwarden_ui::api_routes()` monte le même set.
1114 +
1089 1115 ---
1090 1116
1091 1117 ## 19. Utilitaires
@@ -1569,3 +1595,71 @@ Pour le câblage complet du `main.rs`, les variables d'environnement, les migrat
1569 1595 - **Toujours `tokio::spawn(OutboundWorker::run())`** au démarrage, avec un `HttpSignedDelivery` injecté — `NoopDelivery` ne fait que logger.
1570 1596 - **Toujours s'abonner au target tracing `rustwarden::fediverse::outbound`** — sinon vous découvrirez les `dead` par les clients qui râlent.
1571 1597 - **Étendre via les 5 traits** (`LocalUserResolver`, `SignatureVerifier`, `FediverseEventObserver`, `RemoteIdentityRevocationChecker`, `OutboundDelivery`) plutôt que forker. Les anti-patterns sont listés dans [`fediverse-consumer-guide.md` §10](fediverse-consumer-guide.md#10-anti-patterns-specifiques-federation).
1598 +
1599 +---
1600 +
1601 +## 22. Binaire `screen-select` — plan des écrans UI
1602 +
1603 +Le binaire `screen-select` est livré avec `rustwarden-core`. Il cible les projets consommateurs qui n'embarquent **pas** `rustwarden-ui` (mode headless / UI custom) et permet de planifier puis d'auditer la couverture des écrans à réaliser.
1604 +
1605 +```bash
1606 +# Depuis le répertoire racine du projet consommateur
1607 +cargo run -p rustwarden-core --bin screen-select # mode wizard (interactif)
1608 +cargo run -p rustwarden-core --bin screen-select -- --check # mode vérification (non-interactif)
1609 +```
1610 +
1611 +### Mode wizard
1612 +
1613 +Sans argument, `screen-select` démarre un assistant interactif sur stdin :
1614 +
1615 +- Sélection parmi 17 fonctionnalités optionnelles du catalogue (Authentification est toujours incluse — 18 fonctionnalités au total, 22 écrans).
1616 +- Chemin optionnel vers un fichier `design.md` et chaîne de layout personnalisée.
1617 +- Génère `docs/plans/ui-screens.md` dans le projet consommateur — tableau Markdown avec colonnes : Statut | Fonctionnalité | Écran | Route | Canal | Protégé | Méthodes core | I/O.
1618 +
1619 +**Comportement de fusion (re-run)** : les statuts (☑/☐) et les routes éditées à la main sont préservés par identité (fonctionnalité + écran). Les nouveaux écrans sont ajoutés ; ceux des fonctionnalités désélectionnées sont retirés.
1620 +
1621 +### Mode `--check`
1622 +
1623 +Scanne les sources `.rs` du projet consommateur en excluant `target/` et les crates `rustwarden-*` vendorisées, à la recherche de littéraux de route exacts (`"<route>"`). Met à jour la colonne Statut du tableau (☐/☑) et affiche :
1624 +
1625 +```
1626 +Couverture : N/M écrans réalisés.
1627 +Écrans manquants :
1628 + ☐ Fonctionnalité — Écran (/route/suggérée)
1629 +```
1630 +
1631 +### Source de vérité
1632 +
1633 +Le catalogue (fonctionnalité → écrans, routes suggérées, méthodes core, canal browser/machine, protection) réside dans `crates/rustwarden-core/src/bin/screen-select/catalogue.rs` en tant que `const CATALOGUE`. Des tests (`catalogue_invariants`, `catalogue_couvre_les_fonctionnalites_attendues`) en gardent la complétude.
1634 +
1635 +### Limites connues
1636 +
1637 +| Limite | Détail |
1638 +|--------|--------|
1639 +| Détection textuelle | Un `.route("/x")` commenté est compté comme implémenté. |
1640 +| Fichiers non-Rust | Les routes déclarées hors `.rs` (TOML, YAML, macros procédurales séparées) ne sont pas vues. |
1641 +| Écrasement des ☑ manuels | `--check` réécrit le statut à partir du scan — les coches posées à la main sont réinitialisées à chaque exécution. |
1642 +
1643 +Le CLI `rustwarden-cli` invite à exécuter `screen-select` à l'étape 4 de `start-project` et dans les notes finales de `update-project`.
1644 +
1645 +### Sections de sécurité du plan
1646 +
1647 +Le plan généré porte, en plus du tableau des écrans :
1648 +
1649 +- **Sécurité transversale (obligatoire, hors écran)** — garde-fous globaux à
1650 + câbler une fois : middleware de headers (CSP, `X-Frame-Options`,
1651 + `X-Content-Type-Options`, `Referrer-Policy`), validation de configuration au
1652 + démarrage (refus de `ADMIN_PASSWORD=password`, alerte `APP_DEBUG`/`COOKIE_SECURE`),
1653 + cookies `HttpOnly`/`Secure`/`SameSite`, `cargo audit` + JWT en HMAC.
1654 +- **Conventions de rendu sûr** — escaping contextuel (jamais de valeur utilisateur
1655 + dans un `<script>` : `data-*` + `dataset`), `textContent` plutôt qu'`innerHTML`,
1656 + `encodeURIComponent`/`urlenc()`, pas de filtre `|safe`.
1657 +- **Contrat de sécurité par écran** — pour chaque écran sélectionné : niveau
1658 + d'autorisation (Public / Authentifié / Propriétaire de ressource / Administrateur),
1659 + rappel CSRF automatique pour les écrans *browser*, et pièges spécifiques
1660 + (IDOR/ownership, `ensure_admin()`, anti-énumération, token de reset hors contexte JS…).
1661 +
1662 +Ces sections sont délimitées par des marqueurs HTML
1663 +(`<!-- screen-select:security-guides -->`, `<!-- screen-select:security-per-screen -->`)
1664 +et régénérées à chaque exécution du wizard. `--check` ne modifie que le tableau :
1665 +les sections de sécurité sont préservées telles quelles.
A docs/plans/2026-08-01-headless-dx-decouplage-scaffold-skill.md
+276 -0
@@ -0,0 +1,276 @@
1 +# Améliorer l'expérience agent en mode headless (A + B + C)
2 +
3 +> Statut : **Phase 1B — direction validée le 2026-08-01.** Formulation Gherkin
4 +> tranche par tranche, juste avant implémentation. Décisions verrouillées :
5 +> - Scaffold headless (B) = **JSON-first** (cookies JWT, aucun HTML/Askama).
6 +> - `.headless()` bascule le défaut de l'échec d'auth sur **401 JSON** (A2).
7 +
8 +## Contexte & valeur
9 +
10 +Un projet consommateur de `rustwarden-core` en **mode headless** (API-only, sans
11 +`rustwarden-ui`) reste coûteux à faire évoluer pour un agent, même avec la skill
12 +`rustwarden-boilerplate`. Le diagnostic sur le code réel (et non sur la mémoire)
13 +montre que **le framework est déjà mûr** sur plusieurs points qu'on croyait
14 +manquants :
15 +
16 +- `RustwardenBuilder` expose déjà `.headless()`, `.merge_browser_routes()`
17 + (CSRF auto), `.merge_machine_routes()` (sans CSRF), `.register_protected_prefix()`.
18 + Le split browser/machine est propre (`app.rs:126-309`).
19 +- Le CSRF est **déjà un layer automatique** (`middleware/csrf.rs`) : génération
20 + de token en session, comparaison constant-time, exemption `Authorization:
21 + Bearer`. En headless (API JSON / SPA via `X-CSRF-Token`), **aucun câblage
22 + manuel n'est requis**.
23 +- La skill est **déjà décomposée** : `SKILL.md` (142 l.) + 5 références
24 + (`routes-ecrans-sans-ui.md`, `services-api.md`, `env-vars.md`, `pieges.md`,
25 + `fediverse.md`).
26 +
27 +La douleur résiduelle, elle, est précise et se résume à trois écarts :
28 +
29 +1. **Couplage UI dans l'échec d'authentification.** `sanitize_redirect`
30 + (`middleware/auth_service.rs:21-28`) code en dur `/login`. En headless, un JWT
31 + absent/invalide sur une route protégée renvoie un **302 vers `/login`**
32 + (inexistant) au lieu d'un **401 JSON**. Les `protected_prefixes` par défaut
33 + (`app.rs:180-186`) sont des chemins UI en dur, dont `/test-base` (artefact de
34 + dev qui fuite dans les valeurs par défaut).
35 +2. **Scaffold headless vide.** Le CLI génère en headless un `main.rs` réduit à
36 + `.headless()` et **aucun handler d'authentification**
37 + (`start_project.rs:761`, `templates.rs:main_rs`). Le dev/agent réimplémente
38 + login / 2FA / logout / changement de mot de passe **from scratch**, guidé par
39 + ~149 lignes de prose. C'est le principal poste de coût.
40 +3. **Skill descriptive plutôt que génératrice.** `routes-ecrans-sans-ui.md`
41 + décrit en prose ce que l'agent doit écrire. Documenter la complexité ne la
42 + supprime pas ; une fois (2) livré, la référence doit pointer vers un squelette
43 + **généré et compilable** plutôt que la re-décrire.
44 +
45 +**Valeur cible :** un projet headless neuf démarre **vert et fonctionnel**
46 +(auth de bout en bout), le core ne présuppose plus d'UI, et la skill oriente vers
47 +une source canonique unique — l'agent complète au lieu de reconstruire.
48 +
49 +## Principes directeurs
50 +
51 +- **Zéro régression pour le mode UI.** Tout défaut préservé : `login_path` par
52 + défaut `/login`, comportement d'échec = redirect pour les routes browser.
53 +- **Sécurité intacte.** Les changements touchent des chemins auth/CSRF sensibles :
54 + chaque comportement (301/302 vs 401, blacklist JWT, challenge 2FA, anti-fixation
55 + de session) est verrouillé par un test avant modification.
56 +- **Pas de raw SQL, `///` sur toute API publique, zéro warning clippy, pas de
57 + `unwrap`/`expect` hors tests** (constitution).
58 +- **Le scaffold généré est du vrai code de production**, pas des stubs 501.
59 +
60 +## Notes techniques (hors comportement)
61 +
62 +- **A — core.** `AuthConfig` (`config/auth.rs:37`) gagne le paramétrage de
63 + l'échec auth. `sanitize_redirect` devient paramétré par le `login_path`. La
64 + branche protégée du middleware Tower (`auth_service.rs`, plus
65 + `admin_service.rs`, `auth_required.rs`, `admin_required.rs`,
66 + `admin_extractor.rs`) consulte un mode d'échec (Redirect | Json401). Le mode
67 + est porté par l'`Extension<Arc<AuthConfig>>` déjà posée par
68 + `RustwardenBuilder::build`.
69 +- **B — CLI.** Nouveau template `templates::auth_routes_rs(cfg)` généré
70 + uniquement en headless, écrit dans `src/auth_routes.rs` ; câblage dans
71 + `main_rs` via `merge_browser_routes` / `merge_machine_routes` +
72 + `register_protected_prefix`. Handlers en **JSON-first** (pas de HTML/Askama) :
73 + login, refresh, logout, verify-2fa, change-password, `GET /api/me` protégé.
74 + Réutilise les services de `rustwarden-core` (`services-api.md` fait foi).
75 +- **C — skill.** `routes-ecrans-sans-ui.md` réécrit pour pointer vers le scaffold
76 + généré (fichiers, services appelés) ; `SKILL.md` documente
77 + `login_path` / mode d'échec 401 et le fait que headless démarre vert.
78 +- **Tests.** Unitaires `#[cfg(test)]` à côté du code (core) ; acceptation via
79 + `tower::ServiceExt::oneshot` sur le `Router` ; QA e2e du projet généré par un
80 + agent (`cargo check` + smoke test des routes) — reproduit le protocole déjà
81 + utilisé (obs S326).
82 +
83 +## Journal d'implémentation
84 +
85 +### Workstream A — **FAIT** (2026-08-01)
86 +
87 +A1 et A2 partageant les 9 mêmes sites d'échec d'auth, ils ont été câblés
88 +ensemble via deux fonctions pures dans
89 +[`middleware/auth_service.rs`](../../crates/rustwarden-core/src/middleware/auth_service.rs) :
90 +`sanitize_redirect(uri, login_path)` et
91 +`auth_failure_response(&AuthConfig, &Uri)` (+ `auth_failure_from_extensions`
92 +pour les extracteurs). La résolution de l'`AuthConfig` a été remontée en tête
93 +des branches protégées d'`AuthService`/`AdminService` pour que **tous** les
94 +chemins d'échec honorent la config.
95 +
96 +- **A1** : `AuthConfig::login_path` (env `LOGIN_PATH`, défaut `/login`) +
97 + setter builder `.login_path()`. Les 5 fichiers de redirection consomment la
98 + valeur (`auth_service`, `admin_service`, `auth_required`, `admin_required`,
99 + `admin_extractor`).
100 +- **A2** : enum `AuthFailureMode { Redirect, Json401 }` (env `AUTH_FAILURE_MODE`)
101 + + `AuthConfig::auth_failure_mode` + setter `.auth_failure_mode()`.
102 + `.headless()` bascule le défaut sur `Json401` (override explicite prioritaire),
103 + appliqué dans `build()`.
104 +- **A3** : `protected_prefixes` par défaut désormais **vide** ; `with_ui()`
105 + enregistre `/home`,`/profile`,`/settings`,`/admin` (les apps UI restent
106 + protégées, le headless est propre). `/test-base` (artefact de dev) supprimé.
107 + `/admin` reste protégé indépendamment par `AdminService`.
108 +
109 +**Vérification** : 12 tests unitaires + 4 tests d'intégration verts, `cargo
110 +clippy --workspace` sans warning (hors `imap-proto` préexistant). Deux nouvelles
111 +variables d'env à documenter en C : `LOGIN_PATH`, `AUTH_FAILURE_MODE`.
112 +
113 +### Workstream B — **FAIT** (2026-08-01)
114 +
115 +Découverte décisive : `rustwarden-core` **contient déjà** tous les handlers d'auth
116 +JSON implémentés ([`auth/handlers.rs`](../../crates/rustwarden-core/src/auth/handlers.rs)
117 +— login, register, refresh, logout, get_current_user, 2fa/verify, password reset,
118 +email verify). B ne réécrit donc **aucune** logique de sécurité : il **monte** ces
119 +handlers existants.
120 +
121 +- **Core** : nouveau [`auth/routes.rs`](../../crates/rustwarden-core/src/auth/routes.rs)
122 + exposant `rustwarden_core::auth::auth_api_routes() -> Router` (assemble les
123 + handlers sur `/api/v1/auth/*`), source **canonique** unique.
124 +- **UI (DRY)** : `rustwarden-ui::api_routes()` délègue désormais à
125 + `auth_api_routes()` (suppression de la liste dupliquée).
126 +- **CLI (B1/B2)** : en mode headless, `main.rs` généré monte
127 + `.merge_machine_routes(rustwarden_core::auth::auth_api_routes())` (canal machine,
128 + Bearer/JSON, hors CSRF) + un endpoint protégé d'exemple `whoami` utilisant
129 + l'extracteur `AuthUser`. Dépendance `serde_json` ajoutée au `Cargo.toml` généré.
130 +
131 +**Vérification** :
132 +- Test de composition dans le core (interface du code généré verrouillée) : vert.
133 +- `cargo clippy --workspace` : 0 warning (hors `imap-proto` préexistant).
134 +- **B3 e2e réel** (agent qa-tester, wizard piloté via tmux) : projet headless
135 + généré → `cargo check` **PASS** (exit 0, 2m53), `main.rs` contient bien le
136 + wiring `auth_api_routes()` + `whoami`/`AuthUser`.
137 +
138 +Un projet headless neuf démarre donc **vert et fonctionnel** : login / 2FA / refresh
139 +/ logout / me opérationnels d'emblée, plus un exemple protégé à étendre.
140 +
141 +### Workstream C — **FAIT** (2026-08-01)
142 +
143 +Skill `rustwarden-boilerplate` (`~/.claude/skills/`) alignée sur le nouveau réel —
144 +écritures directes (doc, hors dépôt, pas de git) :
145 +
146 +- `references/env-vars.md` : ajout de `LOGIN_PATH` et `AUTH_FAILURE_MODE`.
147 +- `references/routes-ecrans-sans-ui.md` : §2 réécrit (le core fournit
148 + `auth_api_routes()` canonique, le scaffold headless le câble automatiquement →
149 + démarre vert ; suppression du bloc copié-collé à recréer), §3 réécrit
150 + (échec d'auth = `redirect` vers `login_path` **ou** `json401`), préfixes par
151 + défaut corrigés (aucun / `with_ui`), checklist + greps de revalidation à jour.
152 +- `references/pieges.md` + `references/services-api.md` : préfixes par défaut et
153 + comportement d'échec corrigés (`/test-base` retiré, `login_path`/401).
154 +- `SKILL.md` : squelette headless (`auth_api_routes()`), fin de la mention
155 + « `.headless()` ne monte rien », recette routes protégées (login_path/401).
156 +
157 +**Vérification** : grep de cohérence — plus aucune référence obsolète à `/test-base`
158 +comme défaut actif ni au « router auth à recréer à la main » ; les 4 fichiers
159 +référencent la source canonique et les 2 nouvelles env vars.
160 +
161 +---
162 +
163 +### Audit d'impact A3 sur les consommateurs (2026-08-01)
164 +
165 +A3 (défauts `protected_prefixes` vidés) est un **changement de comportement** pour
166 +les apps headless qui servaient des pages sous les anciens défauts. Audit des 5
167 +consommateurs de `~/Documents/devel` :
168 +
169 +| Projet | Verdict |
170 +|---|---|
171 +| TaxeFonciereNet | ✅ headless trivial, aucune page protégée |
172 +| rs_pasterman | ✅ `.with_ui()` → réenregistrement auto |
173 +| Ebii_MarketPlace | ✅ seulement `/admin/*` (couvert par `AdminService`) + préfixes propres |
174 +| **gitrust** | ⚠️ `/settings/*` (SSR headless) — plan créé |
175 +| **BotPlace** | ⚠️ `/home`, `/settings/*` (SSR headless) — plan créé |
176 +
177 +Décision (utilisateur) : garder A3 vide, documenter dans le
178 +`docs/SERVICES_REFERENCE.md` **du framework** (§18, se propage aux consommateurs
179 +via `fs::copy` à l'`update-project`), et créer un plan de migration post-update
180 +dans chaque projet exposé. Les plans couvrent **A3** (`register_protected_prefix`)
181 +**et A2** (ces apps sont SSR-en-headless → restaurer `auth_failure_mode(Redirect)`
182 +sinon 401 JSON au lieu du redirect login) :
183 +- `gitrust/docs/plans/rustwarden-a3-protected-prefixes-migration.md`
184 +- `BotPlace/docs/plans/005-rustwarden-a3-protected-prefixes-migration.md`
185 +
186 +## Bilan A + B + C
187 +
188 +Le mode headless n'est plus « complexe pour un agent » : le core est découplé de
189 +l'UI (échec 401 JSON configurable), un projet headless neuf démarre **vert avec
190 +une auth fonctionnelle** (source canonique `auth_api_routes()`), et la skill
191 +oriente vers ce réel au lieu de décrire une complexité disparue. Working tree
192 +laissé à l'utilisateur pour commit (aucune opération git effectuée).
193 +
194 +## Tranches
195 +
196 +Séquencement : **A → B → C** (B câble le mode 401 introduit en A ; C documente le
197 +résultat de B).
198 +
199 +### Tranche A1 — `login_path` configurable · statut : formulée
200 +Rendre le chemin de redirection de login injectable via `AuthConfig`
201 +(défaut `/login`), et faire consommer cette valeur par `sanitize_redirect` et
202 +tous les sites de redirection. Comportement UI inchangé par défaut.
203 +
204 +```gherkin
205 +Feature: Chemin de redirection de login configurable
206 +
207 + Background:
208 + Given une route protégée par le middleware d'authentification
209 +
210 + # --- Boucle interne : la fonction pure sanitize_redirect ---
211 + Scenario: Chemin de login par défaut préservé
212 + Given aucune configuration de login_path (défaut "/login")
213 + When une cible relative sûre "/settings" doit être sanitisée
214 + Then le résultat est "/login?redirect=%2Fsettings"
215 +
216 + Scenario: Chemin de login personnalisé honoré
217 + Given un login_path configuré à "/auth/signin"
218 + When une cible relative sûre "/settings" doit être sanitisée
219 + Then le résultat est "/auth/signin?redirect=%2Fsettings"
220 +
221 + Scenario: Cible non sûre réduite au chemin de login nu
222 + Given un login_path configuré à "/auth/signin"
223 + When une cible dangereuse "//evil.example" doit être sanitisée
224 + Then le résultat est "/auth/signin" sans paramètre redirect
225 +
226 + # --- Boucle externe : comportement observable du middleware ---
227 + Scenario: Redirection anonyme vers le chemin de login configuré
228 + Given un login_path configuré à "/auth/signin"
229 + And aucun JWT valide sur la requête
230 + When un client browser demande "/settings"
231 + Then la réponse est une redirection dont l'en-tête Location commence par "/auth/signin"
232 +```
233 +
234 +### Tranche A2 — Mode d'échec auth Redirect | Json401 · statut : à formuler
235 +Introduire un mode d'échec d'authentification. Par défaut `Redirect` (302 vers
236 +`login_path`, comportement actuel). En `Json401`, la branche protégée renvoie
237 +`401` + corps JSON `{ "error": "unauthorized" }` sans redirection. Exposé via un
238 +`RustwardenBuilder`/`AuthConfig` setter ; `.headless()` bascule le défaut sur
239 +`Json401`.
240 +
241 +### Tranche A3 — Défauts `protected_prefixes` propres · statut : à formuler
242 +Retirer `/test-base` des défauts ; le set de préfixes UI (`/home`, `/profile`,
243 +`/settings`, `/admin`) devient l'apanage de `with_ui`/`rustwarden-ui`, de sorte
244 +qu'un projet headless ne protège que ce qu'il déclare explicitement. Compat
245 +préservée pour les consommateurs UI existants.
246 +
247 +### Tranche B1 — Scaffold login/refresh/logout JSON · statut : à formuler
248 +Générer en headless `src/auth_routes.rs` avec les handlers `POST /api/login`
249 +(mot de passe → cookie JWT + refresh, anti-fixation de session, détection du
250 +challenge 2FA), `POST /api/refresh`, `POST /api/logout` (blacklist JWT), câblés
251 +dans `main.rs`. Projet généré compile.
252 +
253 +### Tranche B2 — Scaffold 2FA + changement de mot de passe · statut : à formuler
254 +Ajouter au scaffold `POST /api/2fa/verify` (challenge TOTP) et
255 +`POST /api/password` (changement, avec `must_change_password`), plus un
256 +`GET /api/me` protégé démontrant `AuthUser`/`Claims`.
257 +
258 +### Tranche B3 — Vert e2e du projet headless généré · statut : à formuler
259 +Le projet headless généré passe `cargo check`, `cargo clippy` (zéro warning) et
260 +un smoke test des routes auth (login → me → logout) via un agent QA. Documenté.
261 +
262 +### Tranche C1 — Référence skill = scaffold canonique · statut : à formuler
263 +Réécrire `references/routes-ecrans-sans-ui.md` pour décrire le scaffold généré
264 +(fichiers, routes, services appelés) au lieu de re-spécifier en prose ; élaguer
265 +la redondance avec `services-api.md`.
266 +
267 +### Tranche C2 — SKILL.md aligné · statut : à formuler
268 +Documenter dans `SKILL.md` le mode d'échec `Json401` / `login_path` et le fait
269 +qu'un projet headless démarre désormais vert ; renvoyer vers la référence C1.
270 +
271 +## Hors périmètre (explicite)
272 +
273 +- Pas de refonte du CSRF (déjà propre).
274 +- Pas de re-découpage de la skill (déjà décomposée).
275 +- Pas de génération d'UI HTML en headless (le scaffold est JSON-first).
276 +- Aucune opération git (working tree laissé à l'utilisateur).
A docs/plans/2026-08-02-screen-coverage-completude.md
+240 -0
@@ -0,0 +1,240 @@
1 +# Spec — Wizard `screen-select` : pense-bête exhaustif des écrans à coder
2 +
3 +> Statut : **design validé (itéré le 2026-08-02)**, prêt pour un plan d'implémentation.
4 +> Modèle retenu : **wizard générateur de todo** (pas de trait/registre à compiler).
5 +
6 +## Problème
7 +
8 +Un projet qui consomme `rustwarden-core` avec une **UI custom** (design propre,
9 +sans `rustwarden-ui`) doit coder lui-même les écrans exposant les fonctionnalités
10 +du framework — surtout des fonctionnalités de **sécurité** : login, inscription,
11 +2FA (challenge + setup + backup codes), changement/reset de mot de passe,
12 +validation d'email, gestion des sessions, liaison OAuth, teams, jetons d'accès
13 +personnels, et toute l'admin (utilisateurs, rôles, réglages, journal d'erreurs).
14 +
15 +**En pratique on en oublie**, et on le découvre après coup — ce qui ne fait pas
16 +sérieux vu le poids sécurité de ces écrans. La doc existe
17 +(`references/routes-ecrans-sans-ui.md`, `SERVICES_REFERENCE.md`) **mais ne garantit
18 +rien** ; elle repose sur la mémoire du dev. Preuve récente : même un plan de
19 +migration rédigé à la main a raté `/profile`.
20 +
21 +## Objectif
22 +
23 +Un **wizard à lancer** — un **binaire de `rustwarden-core`** (`cargo run --bin
24 +screen-select`) — qui, **selon les fonctionnalités que le projet active**, produit
25 +la **liste exhaustive des écrans à coder** — un **pense-bête pour développeur ou
26 +agent de code** — et permet de **contrôler le réalisé** (recocher automatiquement
27 +ce qui est déjà implémenté).
28 +
29 +On **ne génère pas d'UI** : on génère le **plan + le tableau de todo** à suivre,
30 +avec, pour chaque écran, les **routes et méthodes `rustwarden-core` d'exemple**
31 +rattachées au code réel des crates.
32 +
33 +### Contrainte d'indépendance (décisive pour le placement)
34 +
35 +L'outil vit **dans `rustwarden-core`**, pas dans `rustwarden-cli`. Raison : le
36 +CLI ne sert qu'au scaffolding/sync et **n'est pas forcément présent** chez le dev
37 +qui reprend un projet déjà généré. `rustwarden-core` étant **vendored (copié)**
38 +dans le projet, un binaire `src/bin/screen-select.rs` (comme les bins existants
39 +`migrate`, `seed`, `hash_password`, `change_password`) **voyage avec le projet** :
40 +il tourne via `cargo run --bin screen-select` **sans aucune dépendance au dépôt
41 +framework ni au `rustwarden-cli`**. Le **catalogue** (fonctionnalité→écrans→
42 +méthodes) vit lui aussi dans `rustwarden-core`, donc il est resynchronisé
43 +automatiquement par `update-project` (comme le reste de la crate).
44 +
45 +## Non-goals (YAGNI)
46 +
47 +- **Aucune UI générée**, aucun style, aucun chemin imposé.
48 +- **Aucune contrainte compilateur** (pas de trait/registre exhaustif à
49 + implémenter — abandonné à l'itération design).
50 +- Le **contrôle du réalisé est best-effort** (analyse statique des routes du
51 + projet ; « si c'est possible » — un écran présent mais stub ne peut pas être
52 + jugé sur le fond).
53 +- Ne remplace pas l'API JSON fournie par le core (`auth_api_routes()`).
54 +
55 +## Base de connaissance — le catalogue fonctionnalités → écrans
56 +
57 +Cœur du dispositif : un **catalogue structuré**, dérivé du **code** (recoupé avec
58 +les 24 modules de `crates/rustwarden-core/src/services/mod.rs`, audit 2026-08-02),
59 +maintenu **dans le dépôt framework** et consommé par le wizard. Pour chaque
60 +**fonctionnalité activable**, il liste ses **écrans**, et pour chaque écran :
61 +
62 +- une **route suggérée** (défaut sensé, librement modifiable par le dev) ;
63 +- les **méthodes / routes `rustwarden-core`** à appeler (rattachement au code) ;
64 +- un résumé du **contrat I/O** ;
65 +- le **canal** (browser SSR / machine) et si l'écran est **protégé**.
66 +
67 +Représentation : **données statiques Rust (`const`) typées, dans un module de
68 +`rustwarden-core`** (une source, testée), exportables aussi en Markdown pour
69 +lecture humaine. L'« enum par fonctionnalité » reste l'ossature conceptuelle ;
70 +ce n'est plus un trait que le consommateur implémente, mais la **table de vérité**
71 +qu'exploite le binaire `screen-select` (embarqué dans la crate vendored).
72 +
73 +### Fonctionnalités activables et leurs écrans
74 +
75 +| Fonctionnalité (activable) | Écrans requis quand activée | Services core source |
76 +|---|---|---|
77 +| **Authentication** *(toujours)* | Login | `AuthService`, `JwtService`, `auth_api_routes()` |
78 +| **Registration** | Register (formulaire) *(le toggle admin vit dans AdminSettings)* | `UserService`, `AppSettingsService` (`allow_registration`) |
79 +| **PasswordReset** | ForgotPassword, ResetPassword | `PasswordResetService` |
80 +| **EmailValidation** | ResendVerification *(page de résultat rendue par le core)* | `EmailValidationService` |
81 +| **TwoFactor** | TwoFactorChallenge, TwoFactorSetup, TwoFactorBackupCodes | `TotpService` |
82 +| **AccountProfile** | Profile | `UserService` |
83 +| **PasswordChange** | PasswordChange, ConfirmPasswordChange | `PasswordChangeService` |
84 +| **SessionManagement** | SessionManagement | `RefreshTokenService`, `JwtBlacklistService` |
85 +| **Preferences** | Preferences | `UserPreferencesService` |
86 +| **OAuthLinking** *(feature `oauth`)* | OAuthAccountLinking (+ boutons sur Login) | `OAuthService` |
87 +| **Teams** | TeamsManagement | `TeamService` |
88 +| **PersonalAccessTokens** | PatManagement | `PersonalTokenService<S>` |
89 +| **Admin · Users** | AdminUsers | `UserService`, `RoleService`, `PasswordResetService`, `TotpService` |
90 +| **Admin · Roles** | AdminRoles | `RoleService`, `PermissionService` |
91 +| **Admin · Settings** | AdminSettings | `AppSettingsService`, `CryptoService` |
92 +| **Admin · ErrorLog** | AdminErrorLog | `error_handler`, `I18nService` |
93 +| **Admin · EmailQueue** | AdminEmailQueue | `EmailQueueService` |
94 +| **Admin · Dashboard** *(requis si ≥1 bloc admin activé)* | AdminDashboard | cartes `DashboardCardDTO` |
95 +
96 +**Primitives sans écran fixe** (documentées, hors catalogue) : `ResourceService`
97 +(le partage se greffe sur *tes* entités métier) et `NotificationBus`/SSE
98 +(transport temps réel). Mécanismes purs sans écran : `PasswordService`,
99 +`CryptoService`, `RateLimitService`, `EmailService`, `AuthLoggingService`, i18n,
100 +hooks, processors de tâches de fond.
101 +
102 +### Exemple d'entrée de catalogue (une ligne détaillée)
103 +
104 +```
105 +SessionManagement (fonctionnalité)
106 + écran: SessionsList
107 + route_suggérée: GET /settings/sessions (browser, protégé)
108 + core:
109 + - RefreshTokenService::list_active(&db, user_id) -> Vec<ActiveSession>
110 + - RefreshTokenService::revoke(&db, token_id)
111 + - (déconnexion globale) POST /api/v1/auth/logout
112 + io: rend la liste des sessions (device, ip, date) + action révoquer
113 +```
114 +
115 +## Le wizard `screen-select` (binaire de `rustwarden-core`)
116 +
117 +**Entrées (prompts) :**
118 +1. **Fonctionnalités activées** (multi-sélection, comme on active oauth/i18n) :
119 + Registration, PasswordReset, EmailValidation, TwoFactor, AccountProfile,
120 + PasswordChange, SessionManagement, Preferences, OAuthLinking *(proposé si la
121 + feature `oauth` est détectée)*, Teams, PersonalAccessTokens, Administration
122 + (+ AdminEmailQueue). `Authentication` est toujours incluse.
123 +2. **`design.md`** (optionnel) : **charte graphique** (couleurs, **police**, ton
124 + visuel) reprise en tête du plan comme **contraintes de style à respecter** —
125 + elle n'influence pas la liste des écrans requis.
126 +3. **Structure de page** (optionnel) : position navbar (haut/gauche), nombre de
127 + colonnes, layout — injecté comme contexte de mise en page dans le plan.
128 +
129 +**Sorties générées :**
130 +- **`docs/plans/ui-<projet>.md`** — un **plan UI** : rappel de la charte
131 + (design.md), structure de page choisie, puis le **tableau de todo exhaustif**.
132 +- Le **tableau de todo** — une ligne par écran des fonctionnalités activées :
133 +
134 + | Statut | Fonctionnalité | Écran | Route (à confirmer) | Canal | Protégé | Méthodes core d'exemple | Contrat I/O |
135 + |---|---|---|---|---|---|---|---|
136 + | ☐ | SessionManagement | SessionsList | `GET /settings/sessions` | browser | oui | `RefreshTokenService::list_active`, `::revoke` | liste + révoquer |
137 + | ☐ | TwoFactor | TotpSetup | `GET /settings/2fa` | browser | oui | `TotpService::generate_setup`, `::confirm` | QR + code |
138 + | … | | | | | | | |
139 +
140 +Le dev/agent code chaque écran, ajuste la route si besoin **dans le tableau**, et
141 +coche `☑`.
142 +
143 +## Contrôle du réalisé (`screen-select --check`)
144 +
145 +Re-jouable à tout moment. Pour chaque ligne du todo :
146 +- **analyse statique** des routes du projet (`grep`/parse des `.route("…")` dans
147 + `src/` et les crates du projet) : la route inscrite dans le todo est-elle
148 + montée ? → marque **réalisé** / **manquant**.
149 +- met à jour la **colonne Statut** du `docs/plans/ui-<projet>.md` (ou imprime un
150 + rapport), et **liste les écrans manquants**.
151 +
152 +Best-effort assumé : détecte « route absente » (l'oubli visé), pas « handler
153 +stub ». Pas de DB requise (statique). *(Option future : sonde runtime `oneshot`
154 +≠404 si on veut aller plus loin — hors périmètre initial.)*
155 +
156 +## Placement & cycle de vie
157 +
158 +- **Binaire** dans `crates/rustwarden-core/src/bin/screen-select.rs` + déclaration
159 + `[[bin]]` dans le `Cargo.toml` de core (même pattern que `migrate` / `seed` /
160 + `hash_password` / `change_password`). **Aucun code** dans `rustwarden-cli` en
161 + dehors des messages d'invite ci-dessous.
162 +- **`start-project` et `update-project` expliquent comment le lancer** : dans leur
163 + section « Prochaines étapes » finale, ils impriment
164 + `cargo run --bin screen-select` (générer/compléter le plan d'écrans) et
165 + `cargo run --bin screen-select -- --check` (contrôler le réalisé).
166 +- **À la création** : le dev lance `screen-select` quand il attaque son UI.
167 +- **Après `update-project`** : binaire et catalogue sont resynchronisés avec la
168 + crate ; relancer `screen-select` **complète** le todo avec les écrans des
169 + **nouvelles fonctionnalités** (nouvelles lignes `☐`), **sans écraser les statuts
170 + existants** (merge par clé fonctionnalité+écran).
171 +- Le **catalogue** (dans `rustwarden-core`) est la **source de vérité** de la
172 + complétude ; `references/routes-ecrans-sans-ui.md` y renvoie.
173 +
174 +## Décisions verrouillées
175 +
176 +- **Binaire de `rustwarden-core`** (`src/bin/screen-select.rs`), vendored avec le
177 + projet → **indépendant du framework et du `rustwarden-cli`** ; le CLI ne fait
178 + qu'imprimer l'invite de lancement.
179 +- Wizard **`screen-select`** exploitant le catalogue (enum-idée)
180 + fonctionnalité→écrans→méthodes.
181 +- **Activation par fonctionnalité** (sélection dans le wizard), pas de waiver
182 + écran par écran.
183 +- Entrées optionnelles **`design.md`** + **structure de page**.
184 +- Sortie = **plan + tableau de todo** (routes + méthodes core d'exemple).
185 +- **Todo seul** (pas de trait/test compile) + **contrôle du réalisé** par analyse
186 + statique re-jouable.
187 +
188 +## Décisions ouvertes (pour le plan)
189 +
190 +1. **Support du catalogue** : ✅ tranché — `const` Rust typé dans un module de
191 + `rustwarden-core` (une source, testée), embarqué dans le binaire ; export MD
192 + optionnel pour lecture humaine.
193 +2. **Format du todo** : ✅ tranché — **Markdown** (diffable, éditable à la main ;
194 + cases `☐/☑` relisibles par `--check`).
195 +3. **Granularité admin** : ✅ tranché — **sous-fonctionnalités** activables
196 + séparément (Users / Roles / Settings / ErrorLog / EmailQueue), + `AdminDashboard`
197 + requis dès qu'un bloc admin est activé.
198 +4. **Portée du `--check`** : ✅ tranché — **Niveau 1 statique seul** (analyse du
199 + code source, sans base de données). La sonde runtime (Niveau 2) reste une piste
200 + future explicitement **hors périmètre**.
201 +5. **`design.md` / structure de page** : ✅ tranché — `design.md` = **charte
202 + graphique** (couleurs, police, ton) reprise comme **contraintes de style** en
203 + tête du plan ; la structure de page (navbar/colonnes) = contexte de mise en
204 + page. **Aucune influence** sur les écrans requis (issus du catalogue).
205 +
206 +### Portée du `--check` — ✅ tranché : Niveau 1 (statique seul)
207 +
208 +Question : **quand `--check` déclare un écran « fait », à quel point veut-on en
209 +être sûr ?** Trois profondeurs possibles ; **le Niveau 1 est retenu**, les
210 +Niveaux 2 et 3 sont hors périmètre.
211 +
212 +- **Niveau 1 — statique (analyse du code source)** : `--check` cherche dans les
213 + sources du projet une route `.route("<chemin du todo>")`. Trouvée → ☑, sinon ☐.
214 + *Pour* : instantané, hors-ligne, sans base de données ; attrape le cas visé
215 + (« j'ai complètement oublié l'écran »). *Contre* : prouve seulement qu'une route
216 + est **déclarée** dans le code — pas qu'elle est réellement **montée** dans le
217 + routeur final, ni que le handler n'est pas un stub.
218 +- **Niveau 2 — runtime (sonde HTTP)** : `--check` **démarre l'app** (donc base
219 + requise) et fait `GET <chemin>` en vérifiant ≠ 404. *Pour* : prouve que la
220 + route est **effectivement montée/atteignable** (attrape « déclaré mais pas
221 + mergé »). *Contre* : nécessite une base de dev + le boot de l'app ; un stub qui
222 + répond 200 passerait quand même.
223 +- **Niveau 3 — contenu** : vérifier que l'écran *fait la bonne chose*
224 + (rend les bons éléments, appelle le bon service) → **hors périmètre** (non
225 + automatisable de façon fiable).
226 +
227 +Reco : **Niveau 1 par défaut** (léger, couvre l'oubli — le vrai problème), avec le
228 +**Niveau 2 en option** via un flag `--check --runtime` pour qui veut la garantie
229 +« vraiment câblé ». Le Niveau 3 reste explicitement exclu.
230 +
231 +## Stratégie de test (du wizard lui-même)
232 +
233 +- Tests unitaires : le catalogue couvre toutes les fonctionnalités UI (cohérence
234 + avec une liste de référence dérivée de `services/mod.rs`) ; génération du todo
235 + pour un set de fonctionnalités donné → tableau attendu ; `--check` sur un projet
236 + factice (une route présente, une absente) → statuts attendus ; merge après
237 + « update » (nouvelle fonctionnalité) → nouvelles lignes sans perte de statut.
238 +- Test e2e : `start-project` (UI custom) → `screen-select` → todo généré →
239 + implémenter une route → `--check` la voit réalisée (réutilise le harnais type
240 + B3).
A docs/plans/2026-08-02-screen-select-implementation.md
+1175 -0
@@ -0,0 +1,1175 @@
1 +# screen-select Implementation Plan
2 +
3 +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4 +>
5 +> **Politique git de ce projet** : versioning **manuel**. Les étapes « Commit » sont des **points de checkpoint** — le développeur committe s'il le souhaite ; **un agent ne committe jamais sans accord explicite**. Ne pas `git add/commit/push` automatiquement.
6 +
7 +**Goal:** Fournir un binaire `screen-select` dans `rustwarden-core` (vendored) qui génère, selon les fonctionnalités activées, un plan Markdown listant tous les écrans à coder, et recoche le réalisé par analyse statique des routes.
8 +
9 +**Architecture:** Un binaire à modules bin-locaux (`src/bin/screen-select/*`) — le **catalogue** (fonctionnalité→écrans, données `const`) et la logique (render/parse/merge/check) restent **hors de l'API publique** de la lib. Wizard interactif en `std::io` (aucune dépendance ajoutée). Le CLI (`start-project`/`update-project`) imprime seulement l'invite de lancement.
10 +
11 +**Tech Stack:** Rust 2024, `std` uniquement (pas de nouvelle crate), `cargo`.
12 +
13 +**Réf spec :** `docs/plans/2026-08-02-screen-coverage-completude.md`
14 +
15 +---
16 +
17 +## Structure des fichiers
18 +
19 +| Fichier | Responsabilité |
20 +|---|---|
21 +| `crates/rustwarden-core/Cargo.toml` | déclaration `[[bin]] name = "screen-select"` |
22 +| `crates/rustwarden-core/src/bin/screen-select/main.rs` | binaire : args (`--check`), wizard stdin, I/O fichiers |
23 +| `.../screen-select/catalogue.rs` | types + données `const CATALOGUE` (source de vérité) |
24 +| `.../screen-select/todo.rs` | render / parse / replace_table / merge du tableau Markdown |
25 +| `.../screen-select/generate.rs` | `rows_for` + `render_plan` (charte + structure + tableau) |
26 +| `.../screen-select/check.rs` | `mark` (pur) + `collect_sources` (I/O) — contrôle statique |
27 +| `tools/rustwarden-cli/src/start_project.rs` | invite de lancement en « Prochaines étapes » |
28 +| `tools/rustwarden-cli/src/update_project.rs` | idem après update |
29 +
30 +Le fichier généré est **`docs/plans/ui-screens.md`** (chemin fixe, pour que `--check` retrouve le même fichier).
31 +
32 +---
33 +
34 +## Task 1: Squelette du binaire + catalogue (types & données)
35 +
36 +**Files:**
37 +- Modify: `crates/rustwarden-core/Cargo.toml` (ajout `[[bin]]`)
38 +- Create: `crates/rustwarden-core/src/bin/screen-select/main.rs`
39 +- Create: `crates/rustwarden-core/src/bin/screen-select/catalogue.rs`
40 +
41 +- [ ] **Step 1: Déclarer le binaire dans Cargo.toml**
42 +
43 +Ajouter à la fin de la section des `[[bin]]` de `crates/rustwarden-core/Cargo.toml` :
44 +
45 +```toml
46 +[[bin]]
47 +name = "screen-select"
48 +path = "src/bin/screen-select/main.rs"
49 +```
50 +
51 +- [ ] **Step 2: Créer le main minimal**
52 +
53 +`crates/rustwarden-core/src/bin/screen-select/main.rs` :
54 +
55 +```rust
56 +//! `screen-select` : génère/complète le plan des écrans à coder pour une UI
57 +//! custom consommant rustwarden-core, et recoche le réalisé (`--check`).
58 +
59 +mod catalogue;
60 +
61 +fn main() {
62 + println!("screen-select : {} fonctionnalités au catalogue", catalogue::all().len());
63 +}
64 +```
65 +
66 +- [ ] **Step 3: Écrire le test d'invariants du catalogue (rouge)**
67 +
68 +Dans `catalogue.rs`, prévoir le module de test AVANT les données pour piloter la forme. Créer `crates/rustwarden-core/src/bin/screen-select/catalogue.rs` avec seulement les types + `CATALOGUE` vide + ce test :
69 +
70 +```rust
71 +//! Catalogue statique fonctionnalité → écrans (source de vérité de complétude).
72 +
73 +/// Canal de montage d'un écran.
74 +#[derive(Clone, Copy, PartialEq, Eq, Debug)]
75 +#[allow(dead_code)] // `Machine` réservé à de futurs écrans (aucun aujourd'hui)
76 +pub enum Channel {
77 + /// Route SSR pilotée par cookie de session (CSRF appliqué).
78 + Browser,
79 + /// API JSON / Bearer (hors CSRF).
80 + Machine,
81 +}
82 +
83 +impl Channel {
84 + /// Libellé court pour le tableau.
85 + pub fn label(self) -> &'static str {
86 + match self {
87 + Channel::Browser => "browser",
88 + Channel::Machine => "machine",
89 + }
90 + }
91 +}
92 +
93 +/// Un écran requis quand sa fonctionnalité est activée.
94 +#[derive(Clone, Copy, Debug)]
95 +pub struct Screen {
96 + pub name: &'static str,
97 + pub suggested_route: &'static str,
98 + pub channel: Channel,
99 + pub protected: bool,
100 + pub core_methods: &'static str,
101 + pub io: &'static str,
102 +}
103 +
104 +/// Une fonctionnalité activable et ses écrans.
105 +#[derive(Clone, Copy, Debug)]
106 +pub struct Functionality {
107 + pub key: &'static str,
108 + pub label: &'static str,
109 + pub always: bool,
110 + pub screens: &'static [Screen],
111 +}
112 +
113 +/// Toutes les fonctionnalités du catalogue.
114 +pub fn all() -> &'static [Functionality] {
115 + CATALOGUE
116 +}
117 +
118 +/// Recherche par clé stable.
119 +#[allow(dead_code)] // utilisé par les tests des autres modules du binaire
120 +pub fn by_key(key: &str) -> Option<&'static Functionality> {
121 + CATALOGUE.iter().find(|f| f.key == key)
122 +}
123 +
124 +const CATALOGUE: &[Functionality] = &[];
125 +
126 +#[cfg(test)]
127 +mod tests {
128 + use super::*;
129 +
130 + #[test]
131 + fn catalogue_invariants() {
132 + assert!(!all().is_empty(), "catalogue non vide");
133 + // clés uniques
134 + for (i, f) in all().iter().enumerate() {
135 + assert!(!f.key.is_empty() && !f.label.is_empty());
136 + assert!(!f.screens.is_empty(), "{} a au moins un écran", f.key);
137 + assert!(
138 + all().iter().skip(i + 1).all(|g| g.key != f.key),
139 + "clé dupliquée: {}",
140 + f.key
141 + );
142 + }
143 + // exactement une fonctionnalité "always" (Authentication)
144 + assert_eq!(all().iter().filter(|f| f.always).count(), 1);
145 + }
146 +}
147 +```
148 +
149 +- [ ] **Step 4: Lancer le test — il échoue (catalogue vide)**
150 +
151 +Run: `cargo test -p rustwarden-core --bin screen-select -- catalogue_invariants`
152 +Expected: FAIL sur `assert!(!all().is_empty())`.
153 +
154 +- [ ] **Step 5: Remplir le catalogue**
155 +
156 +Remplacer `const CATALOGUE: &[Functionality] = &[];` par les données (transcription exacte du tableau du catalogue de la spec) :
157 +
158 +```rust
159 +const CATALOGUE: &[Functionality] = &[
160 + Functionality { key: "authentication", label: "Authentication", always: true, screens: &[
161 + Screen { name: "Login", suggested_route: "/login", channel: Channel::Browser, protected: false,
162 + core_methods: "AuthService, JwtService, POST /api/v1/auth/login", io: "form login → cookie JWT / challenge 2FA" },
163 + ]},
164 + Functionality { key: "registration", label: "Registration", always: false, screens: &[
165 + Screen { name: "Register", suggested_route: "/register", channel: Channel::Browser, protected: false,
166 + core_methods: "UserService, AppSettingsService(allow_registration), POST /api/v1/auth/register", io: "form inscription" },
167 + ]},
168 + Functionality { key: "password_reset", label: "PasswordReset", always: false, screens: &[
169 + Screen { name: "ForgotPassword", suggested_route: "/forgot-password", channel: Channel::Browser, protected: false,
170 + core_methods: "PasswordResetService, POST /api/v1/auth/forgot-password", io: "email → lien reset" },
171 + Screen { name: "ResetPassword", suggested_route: "/reset-password", channel: Channel::Browser, protected: false,
172 + core_methods: "PasswordResetService, POST /api/v1/auth/reset-password", io: "token + nouveau mdp" },
173 + ]},
174 + Functionality { key: "email_validation", label: "EmailValidation", always: false, screens: &[
175 + Screen { name: "ResendVerification", suggested_route: "/resend-verification", channel: Channel::Browser, protected: false,
176 + core_methods: "EmailValidationService, POST /api/v1/auth/resend-verification", io: "renvoi email (résultat rendu par le core)" },
177 + ]},
178 + Functionality { key: "two_factor", label: "TwoFactor", always: false, screens: &[
179 + Screen { name: "TwoFactorChallenge", suggested_route: "/login/2fa", channel: Channel::Browser, protected: false,
180 + core_methods: "TotpService, POST /api/v1/auth/2fa/verify", io: "code TOTP ou backup" },
181 + Screen { name: "TwoFactorSetup", suggested_route: "/settings/2fa", channel: Channel::Browser, protected: true,
182 + core_methods: "TotpService::generate_setup/confirm/disable", io: "QR + confirmation" },
183 + Screen { name: "TwoFactorBackupCodes", suggested_route: "/settings/2fa/backup", channel: Channel::Browser, protected: true,
184 + core_methods: "TotpService (backup codes)", io: "génération/affichage codes" },
185 + ]},
186 + Functionality { key: "account_profile", label: "AccountProfile", always: false, screens: &[
187 + Screen { name: "Profile", suggested_route: "/profile", channel: Channel::Browser, protected: true,
188 + core_methods: "UserService", io: "voir/éditer profil" },
189 + ]},
190 + Functionality { key: "password_change", label: "PasswordChange", always: false, screens: &[
191 + Screen { name: "PasswordChange", suggested_route: "/settings/password", channel: Channel::Browser, protected: true,
192 + core_methods: "PasswordChangeService", io: "changement mdp (confirmation email)" },
193 + Screen { name: "ConfirmPasswordChange", suggested_route: "/confirm-password-change", channel: Channel::Browser, protected: false,
194 + core_methods: "PasswordChangeService::find_by_token/confirm_request", io: "confirme via token" },
195 + ]},
196 + Functionality { key: "session_management", label: "SessionManagement", always: false, screens: &[
197 + Screen { name: "SessionsList", suggested_route: "/settings/sessions", channel: Channel::Browser, protected: true,
198 + core_methods: "RefreshTokenService::list_active/revoke, JwtBlacklistService", io: "liste sessions + révoquer" },
199 + ]},
200 + Functionality { key: "preferences", label: "Preferences", always: false, screens: &[
201 + Screen { name: "Preferences", suggested_route: "/settings/preferences", channel: Channel::Browser, protected: true,
202 + core_methods: "UserPreferencesService", io: "préférences utilisateur" },
203 + ]},
204 + Functionality { key: "oauth_linking", label: "OAuthLinking", always: false, screens: &[
205 + Screen { name: "OAuthAccountLinking", suggested_route: "/settings/oauth", channel: Channel::Browser, protected: true,
206 + core_methods: "OAuthService, GET /api/v1/auth/oauth/{provider}/redirect", io: "lier/délier comptes (+ boutons login)" },
207 + ]},
208 + Functionality { key: "teams", label: "Teams", always: false, screens: &[
209 + Screen { name: "TeamsManagement", suggested_route: "/teams", channel: Channel::Browser, protected: true,
210 + core_methods: "TeamService (create/add_member/…), TeamRole", io: "équipes + membres (owner/admin/member)" },
211 + ]},
212 + Functionality { key: "personal_access_tokens", label: "PersonalAccessTokens", always: false, screens: &[
213 + Screen { name: "PatManagement", suggested_route: "/settings/tokens", channel: Channel::Browser, protected: true,
214 + core_methods: "PersonalTokenService::create/list_for_user/revoke", io: "créer/révoquer jetons (plaintext 1 fois)" },
215 + ]},
216 + Functionality { key: "admin_users", label: "Admin · Users", always: false, screens: &[
217 + Screen { name: "AdminUsers", suggested_route: "/admin/users", channel: Channel::Browser, protected: true,
218 + core_methods: "UserService, RoleService, PasswordResetService, TotpService", io: "liste + CRUD + reset" },
219 + ]},
220 + Functionality { key: "admin_roles", label: "Admin · Roles", always: false, screens: &[
221 + Screen { name: "AdminRoles", suggested_route: "/admin/roles", channel: Channel::Browser, protected: true,
222 + core_methods: "RoleService, PermissionService", io: "liste + CRUD rôles/permissions" },
223 + ]},
224 + Functionality { key: "admin_settings", label: "Admin · Settings", always: false, screens: &[
225 + Screen { name: "AdminSettings", suggested_route: "/admin/settings", channel: Channel::Browser, protected: true,
226 + core_methods: "AppSettingsService, CryptoService", io: "réglages applicatifs" },
227 + ]},
228 + Functionality { key: "admin_error_log", label: "Admin · ErrorLog", always: false, screens: &[
229 + Screen { name: "AdminErrorLog", suggested_route: "/admin/errors", channel: Channel::Browser, protected: true,
230 + core_methods: "error_handler, I18nService", io: "journal d'erreurs" },
231 + ]},
232 + Functionality { key: "admin_email_queue", label: "Admin · EmailQueue", always: false, screens: &[
233 + Screen { name: "AdminEmailQueue", suggested_route: "/admin/emails", channel: Channel::Browser, protected: true,
234 + core_methods: "EmailQueueService", io: "file d'emails" },
235 + ]},
236 + Functionality { key: "admin_dashboard", label: "Admin · Dashboard", always: false, screens: &[
237 + Screen { name: "AdminDashboard", suggested_route: "/admin", channel: Channel::Browser, protected: true,
238 + core_methods: "cartes DashboardCardDTO", io: "accueil admin" },
239 + ]},
240 +];
241 +```
242 +
243 +- [ ] **Step 6: Lancer le test + le binaire**
244 +
245 +Run: `cargo test -p rustwarden-core --bin screen-select -- catalogue_invariants`
246 +Expected: PASS.
247 +Run: `cargo run -p rustwarden-core --bin screen-select`
248 +Expected: affiche `screen-select : 18 fonctionnalités au catalogue`.
249 +
250 +- [ ] **Step 7: Commit (checkpoint)**
251 +
252 +```bash
253 +git add crates/rustwarden-core/Cargo.toml crates/rustwarden-core/src/bin/screen-select/
254 +git commit -m "feat(screen-select): binaire + catalogue fonctionnalités→écrans"
255 +```
256 +
257 +---
258 +
259 +## Task 2: Rendu Markdown du tableau (`todo::render`)
260 +
261 +**Files:**
262 +- Create: `crates/rustwarden-core/src/bin/screen-select/todo.rs`
263 +- Modify: `crates/rustwarden-core/src/bin/screen-select/main.rs` (déclarer `mod todo;`)
264 +
265 +- [ ] **Step 1: Déclarer le module**
266 +
267 +Dans `main.rs`, ajouter sous `mod catalogue;` :
268 +
269 +```rust
270 +mod todo;
271 +```
272 +
273 +- [ ] **Step 2: Écrire le test de rendu (rouge)**
274 +
275 +Créer `todo.rs` avec le type + un `render` vide + ce test :
276 +
277 +```rust
278 +//! Rendu / lecture / fusion du tableau de todo Markdown.
279 +
280 +/// Une ligne du tableau de todo.
281 +#[derive(Clone, Debug, PartialEq, Eq)]
282 +pub struct TodoRow {
283 + pub done: bool,
284 + pub functionality: String,
285 + pub screen: String,
286 + pub route: String,
287 + pub channel: String,
288 + pub protected: bool,
289 + pub methods: String,
290 + pub io: String,
291 +}
292 +
293 +const HEADER: &str =
294 + "| Statut | Fonctionnalité | Écran | Route | Canal | Protégé | Méthodes core | I/O |";
295 +const SEP: &str = "|---|---|---|---|---|---|---|---|";
296 +
297 +/// Rend les lignes en tableau Markdown (en-tête + séparateur + lignes).
298 +pub fn render(rows: &[TodoRow]) -> String {
299 + String::new() // remplacé au Step 4
300 +}
301 +
302 +#[cfg(test)]
303 +mod tests {
304 + use super::*;
305 +
306 + fn row() -> TodoRow {
307 + TodoRow {
308 + done: false,
309 + functionality: "TwoFactor".into(),
310 + screen: "TwoFactorSetup".into(),
311 + route: "/settings/2fa".into(),
312 + channel: "browser".into(),
313 + protected: true,
314 + methods: "TotpService".into(),
315 + io: "QR".into(),
316 + }
317 + }
318 +
319 + #[test]
320 + fn render_contient_entete_et_ligne() {
321 + let out = render(&[row()]);
322 + assert!(out.contains(HEADER));
323 + assert!(out.contains(SEP));
324 + assert!(out.contains("| ☐ | TwoFactor | TwoFactorSetup | `/settings/2fa` | browser | oui | TotpService | QR |"));
325 + }
326 +
327 + #[test]
328 + fn render_coche_les_faits() {
329 + let mut r = row();
330 + r.done = true;
331 + assert!(render(&[r]).contains("| ☑ |"));
332 + }
333 +}
334 +```
335 +
336 +- [ ] **Step 3: Lancer — échoue**
337 +
338 +Run: `cargo test -p rustwarden-core --bin screen-select -- todo::tests::render`
339 +Expected: FAIL (render renvoie une chaîne vide).
340 +
341 +- [ ] **Step 4: Implémenter `render`**
342 +
343 +```rust
344 +pub fn render(rows: &[TodoRow]) -> String {
345 + let mut out = String::new();
346 + out.push_str(HEADER);
347 + out.push('\n');
348 + out.push_str(SEP);
349 + out.push('\n');
350 + for r in rows {
351 + out.push_str(&format!(
352 + "| {} | {} | {} | `{}` | {} | {} | {} | {} |\n",
353 + if r.done { "☑" } else { "☐" },
354 + r.functionality,
355 + r.screen,
356 + r.route,
357 + r.channel,
358 + if r.protected { "oui" } else { "non" },
359 + r.methods,
360 + r.io,
361 + ));
362 + }
363 + out
364 +}
365 +```
366 +
367 +- [ ] **Step 5: Lancer — passe**
368 +
369 +Run: `cargo test -p rustwarden-core --bin screen-select -- todo::tests::render`
370 +Expected: PASS (2 tests).
371 +
372 +- [ ] **Step 6: Commit (checkpoint)**
373 +
374 +```bash
375 +git add crates/rustwarden-core/src/bin/screen-select/
376 +git commit -m "feat(screen-select): rendu Markdown du tableau de todo"
377 +```
378 +
379 +---
380 +
381 +## Task 3: Lecture du tableau (`todo::parse`)
382 +
383 +**Files:**
384 +- Modify: `crates/rustwarden-core/src/bin/screen-select/todo.rs`
385 +
386 +- [ ] **Step 1: Test round-trip (rouge)**
387 +
388 +Ajouter dans `todo.rs` (module `tests`) :
389 +
390 +```rust
391 + #[test]
392 + fn parse_round_trip() {
393 + let mut a = row();
394 + a.done = true;
395 + let b = TodoRow { screen: "SessionsList".into(), route: "/settings/sessions".into(), ..row() };
396 + let rendered = render(&[a.clone(), b.clone()]);
397 + let parsed = parse(&rendered);
398 + assert_eq!(parsed, vec![a, b]);
399 + }
400 +
401 + #[test]
402 + fn parse_ignore_hors_tableau() {
403 + let md = format!("# Titre\n\ntexte\n\n{}", render(&[row()]));
404 + assert_eq!(parse(&md).len(), 1);
405 + }
406 +```
407 +
408 +- [ ] **Step 2: Ajouter la signature `parse` vide**
409 +
410 +```rust
411 +/// Extrait les lignes de données du premier bloc-tableau rencontré.
412 +pub fn parse(content: &str) -> Vec<TodoRow> {
413 + Vec::new() // remplacé au Step 4
414 +}
415 +```
416 +
417 +- [ ] **Step 3: Lancer — échoue**
418 +
419 +Run: `cargo test -p rustwarden-core --bin screen-select -- todo::tests::parse`
420 +Expected: FAIL.
421 +
422 +- [ ] **Step 4: Implémenter `parse`**
423 +
424 +```rust
425 +pub fn parse(content: &str) -> Vec<TodoRow> {
426 + let mut rows = Vec::new();
427 + for line in content.lines() {
428 + let line = line.trim();
429 + if !line.starts_with('|') {
430 + continue;
431 + }
432 + // ignorer en-tête et séparateur
433 + if line.starts_with("| Statut") || line.starts_with("|---") {
434 + continue;
435 + }
436 + let cells: Vec<&str> = line
437 + .trim_matches('|')
438 + .split('|')
439 + .map(|c| c.trim())
440 + .collect();
441 + if cells.len() != 8 {
442 + continue;
443 + }
444 + rows.push(TodoRow {
445 + done: cells[0].contains('☑'),
446 + functionality: cells[1].to_string(),
447 + screen: cells[2].to_string(),
448 + route: cells[3].trim_matches('`').to_string(),
449 + channel: cells[4].to_string(),
450 + protected: cells[5] == "oui",
451 + methods: cells[6].to_string(),
452 + io: cells[7].to_string(),
453 + });
454 + }
455 + rows
456 +}
457 +```
458 +
459 +- [ ] **Step 5: Lancer — passe**
460 +
461 +Run: `cargo test -p rustwarden-core --bin screen-select -- todo::tests::parse`
462 +Expected: PASS.
463 +
464 +- [ ] **Step 6: Commit (checkpoint)**
465 +
466 +```bash
467 +git add crates/rustwarden-core/src/bin/screen-select/todo.rs
468 +git commit -m "feat(screen-select): lecture du tableau Markdown"
469 +```
470 +
471 +---
472 +
473 +## Task 4: Fusion & remplacement du tableau (`merge`, `replace_table`)
474 +
475 +**Files:**
476 +- Modify: `crates/rustwarden-core/src/bin/screen-select/todo.rs`
477 +
478 +- [ ] **Step 1: Tests (rouge)**
479 +
480 +Ajouter dans le module `tests` :
481 +
482 +```rust
483 + #[test]
484 + fn merge_preserve_statut_et_route() {
485 + let mut existing = row();
486 + existing.done = true;
487 + existing.route = "/custom/2fa".into(); // route éditée par le dev
488 + // "desired" = catalogue (route suggérée par défaut), même clé (func+screen)
489 + let desired = row();
490 + let merged = merge(&[existing.clone()], &[desired.clone()]);
491 + assert_eq!(merged.len(), 1);
492 + assert!(merged[0].done, "statut conservé");
493 + assert_eq!(merged[0].route, "/custom/2fa", "route éditée conservée");
494 + }
495 +
496 + #[test]
497 + fn merge_ajoute_les_nouveaux() {
498 + let desired_new = TodoRow { functionality: "Teams".into(), screen: "TeamsManagement".into(), ..row() };
499 + let merged = merge(&[], &[row(), desired_new.clone()]);
500 + assert_eq!(merged.len(), 2);
501 + assert!(merged.iter().any(|r| r.screen == "TeamsManagement" && !r.done));
502 + }
503 +
504 + #[test]
505 + fn replace_table_remplace_le_bloc() {
506 + let content = format!("# Titre\n\n## Écrans\n\n{}\n(fin)\n", render(&[row()]));
507 + let mut r = row();
508 + r.done = true;
509 + let out = replace_table(&content, &[r]);
510 + assert!(out.contains("# Titre"), "en-tête préservé");
511 + assert!(out.contains("(fin)"), "suffixe préservé");
512 + assert!(out.contains("| ☑ |"), "tableau mis à jour");
513 + }
514 +```
515 +
516 +- [ ] **Step 2: Signatures vides**
517 +
518 +```rust
519 +/// Clé d'identité d'une ligne (indépendante de la route, éditable).
520 +fn key(r: &TodoRow) -> (&str, &str) {
521 + (r.functionality.as_str(), r.screen.as_str())
522 +}
523 +
524 +/// Produit les lignes pour l'ensemble `desired`, en conservant `done` et
525 +/// `route` des lignes `existing` de même (fonctionnalité, écran).
526 +pub fn merge(existing: &[TodoRow], desired: &[TodoRow]) -> Vec<TodoRow> {
527 + Vec::new() // remplacé au Step 4
528 +}
529 +
530 +/// Remplace le premier bloc-tableau (`|`…) de `content` par le rendu de `rows`.
531 +pub fn replace_table(content: &str, rows: &[TodoRow]) -> String {
532 + String::new() // remplacé au Step 4
533 +}
534 +```
535 +
536 +- [ ] **Step 3: Lancer — échoue**
537 +
538 +Run: `cargo test -p rustwarden-core --bin screen-select -- todo::tests::merge todo::tests::replace_table`
539 +Expected: FAIL.
540 +
541 +- [ ] **Step 4: Implémenter**
542 +
543 +```rust
544 +pub fn merge(existing: &[TodoRow], desired: &[TodoRow]) -> Vec<TodoRow> {
545 + desired
546 + .iter()
547 + .map(|d| match existing.iter().find(|e| key(e) == key(d)) {
548 + Some(prev) => TodoRow {
549 + done: prev.done,
550 + route: prev.route.clone(),
551 + ..d.clone()
552 + },
553 + None => d.clone(),
554 + })
555 + .collect()
556 +}
557 +
558 +pub fn replace_table(content: &str, rows: &[TodoRow]) -> String {
559 + let lines: Vec<&str> = content.lines().collect();
560 + let start = lines.iter().position(|l| l.trim_start().starts_with('|'));
561 + let Some(start) = start else {
562 + // pas de tableau existant : on ajoute à la fin
563 + let mut out = content.to_string();
564 + if !out.ends_with('\n') {
565 + out.push('\n');
566 + }
567 + out.push_str(&render(rows));
568 + return out;
569 + };
570 + let mut end = start;
571 + while end < lines.len() && lines[end].trim_start().starts_with('|') {
572 + end += 1;
573 + }
574 + let mut out = String::new();
575 + for l in &lines[..start] {
576 + out.push_str(l);
577 + out.push('\n');
578 + }
579 + out.push_str(&render(rows));
580 + for l in &lines[end..] {
581 + out.push_str(l);
582 + out.push('\n');
583 + }
584 + out
585 +}
586 +```
587 +
588 +- [ ] **Step 5: Lancer — passe**
589 +
590 +Run: `cargo test -p rustwarden-core --bin screen-select -- todo::tests`
591 +Expected: PASS (tous les tests todo).
592 +
593 +- [ ] **Step 6: Commit (checkpoint)**
594 +
595 +```bash
596 +git add crates/rustwarden-core/src/bin/screen-select/todo.rs
597 +git commit -m "feat(screen-select): fusion + remplacement du bloc-tableau"
598 +```
599 +
600 +---
601 +
602 +## Task 5: Génération du plan (`generate`)
603 +
604 +**Files:**
605 +- Create: `crates/rustwarden-core/src/bin/screen-select/generate.rs`
606 +- Modify: `main.rs` (`mod generate;`)
607 +
608 +- [ ] **Step 1: Déclarer le module**
609 +
610 +Dans `main.rs`, ajouter `mod generate;`.
611 +
612 +- [ ] **Step 2: Tests (rouge)**
613 +
614 +Créer `generate.rs` :
615 +
616 +```rust
617 +//! Construction des lignes de todo et rendu du plan Markdown complet.
618 +
619 +use crate::catalogue::Functionality;
620 +use crate::todo::{self, TodoRow};
621 +
622 +/// Entrées de génération du plan.
623 +pub struct PlanInputs<'a> {
624 + pub project: &'a str,
625 + pub selected: Vec<&'static Functionality>,
626 + pub design_md: Option<String>,
627 + pub layout: Option<String>,
628 +}
629 +
630 +/// Aplati les fonctionnalités sélectionnées en lignes de todo (non faites).
631 +pub fn rows_for(selected: &[&'static Functionality]) -> Vec<TodoRow> {
632 + Vec::new() // remplacé au Step 4
633 +}
634 +
635 +/// Rend le document complet : titre, charte, structure, tableau.
636 +pub fn render_plan(inputs: &PlanInputs, rows: &[TodoRow]) -> String {
637 + String::new() // remplacé au Step 4
638 +}
639 +
640 +#[cfg(test)]
641 +mod tests {
642 + use super::*;
643 + use crate::catalogue;
644 +
645 + #[test]
646 + fn rows_for_aplati_les_ecrans() {
647 + let two_factor = catalogue::by_key("two_factor").unwrap();
648 + let rows = rows_for(&[two_factor]);
649 + assert_eq!(rows.len(), 3); // challenge + setup + backup
650 + assert!(rows.iter().all(|r| !r.done));
651 + assert!(rows.iter().any(|r| r.screen == "TwoFactorSetup"));
652 + }
653 +
654 + #[test]
655 + fn render_plan_inclut_charte_et_tableau() {
656 + let auth = catalogue::by_key("authentication").unwrap();
657 + let inputs = PlanInputs {
658 + project: "demo",
659 + selected: vec![auth],
660 + design_md: Some("Police: Inter. Couleur: #123456".into()),
661 + layout: Some("navbar: gauche; 2 colonnes".into()),
662 + };
663 + let rows = rows_for(&inputs.selected);
664 + let out = render_plan(&inputs, &rows);
665 + assert!(out.contains("# Plan UI — demo"));
666 + assert!(out.contains("Police: Inter"));
667 + assert!(out.contains("navbar: gauche"));
668 + assert!(out.contains("| Statut | Fonctionnalité |"));
669 + assert!(out.contains("Login"));
670 + }
671 +}
672 +```
673 +
674 +- [ ] **Step 3: Lancer — échoue**
675 +
676 +Run: `cargo test -p rustwarden-core --bin screen-select -- generate::tests`
677 +Expected: FAIL.
678 +
679 +- [ ] **Step 4: Implémenter**
680 +
681 +```rust
682 +pub fn rows_for(selected: &[&'static Functionality]) -> Vec<TodoRow> {
683 + let mut rows = Vec::new();
684 + for f in selected {
685 + for s in f.screens {
686 + rows.push(TodoRow {
687 + done: false,
688 + functionality: f.label.to_string(),
689 + screen: s.name.to_string(),
690 + route: s.suggested_route.to_string(),
691 + channel: s.channel.label().to_string(),
692 + protected: s.protected,
693 + methods: s.core_methods.to_string(),
694 + io: s.io.to_string(),
695 + });
696 + }
697 + }
698 + rows
699 +}
700 +
701 +pub fn render_plan(inputs: &PlanInputs, rows: &[TodoRow]) -> String {
702 + let mut out = String::new();
703 + out.push_str(&format!("# Plan UI — {}\n\n", inputs.project));
704 + out.push_str("> Généré par `cargo run --bin screen-select`. Recochez le réalisé via `cargo run --bin screen-select -- --check`.\n\n");
705 + if let Some(design) = &inputs.design_md {
706 + out.push_str("## Charte graphique (à respecter)\n\n");
707 + out.push_str(design.trim());
708 + out.push_str("\n\n");
709 + }
710 + if let Some(layout) = &inputs.layout {
711 + out.push_str("## Structure de page\n\n");
712 + out.push_str(layout.trim());
713 + out.push_str("\n\n");
714 + }
715 + out.push_str("## Écrans à coder\n\n");
716 + out.push_str(&todo::render(rows));
717 + out
718 +}
719 +```
720 +
721 +- [ ] **Step 5: Lancer — passe**
722 +
723 +Run: `cargo test -p rustwarden-core --bin screen-select -- generate::tests`
724 +Expected: PASS.
725 +
726 +- [ ] **Step 6: Commit (checkpoint)**
727 +
728 +```bash
729 +git add crates/rustwarden-core/src/bin/screen-select/generate.rs crates/rustwarden-core/src/bin/screen-select/main.rs
730 +git commit -m "feat(screen-select): génération du plan Markdown"
731 +```
732 +
733 +---
734 +
735 +## Task 6: Contrôle statique du réalisé (`check`)
736 +
737 +**Files:**
738 +- Create: `crates/rustwarden-core/src/bin/screen-select/check.rs`
739 +- Modify: `main.rs` (`mod check;`)
740 +
741 +- [ ] **Step 1: Déclarer le module**
742 +
743 +Dans `main.rs`, ajouter `mod check;`.
744 +
745 +- [ ] **Step 2: Tests (rouge)**
746 +
747 +Créer `check.rs` :
748 +
749 +```rust
750 +//! Contrôle statique du réalisé : une route du todo est « faite » si un
751 +//! `.route("<chemin>")` (chaîne exacte) apparaît dans les sources du projet.
752 +
753 +use std::path::Path;
754 +
755 +use crate::todo::TodoRow;
756 +
757 +/// (pur) Marque `done` selon la présence de `"<route>"` dans `haystack`.
758 +pub fn mark(rows: &mut [TodoRow], haystack: &str) {
759 + // remplacé au Step 4
760 +}
761 +
762 +/// (I/O) Concatène le contenu des `.rs` sous `root`, en ignorant `target/`
763 +/// et les crates vendored `rustwarden-*`.
764 +pub fn collect_sources(root: &Path) -> String {
765 + String::new() // remplacé au Step 4
766 +}
767 +
768 +#[cfg(test)]
769 +mod tests {
770 + use super::*;
771 +
772 + fn row(route: &str) -> TodoRow {
773 + TodoRow {
774 + done: false, functionality: "F".into(), screen: "S".into(),
775 + route: route.into(), channel: "browser".into(), protected: true,
776 + methods: "".into(), io: "".into(),
777 + }
778 + }
779 +
780 + #[test]
781 + fn mark_detecte_route_presente() {
782 + let mut rows = vec![row("/profile"), row("/settings/sessions")];
783 + let haystack = r#"router.route("/profile", get(profile))"#;
784 + mark(&mut rows, haystack);
785 + assert!(rows[0].done, "/profile trouvée");
786 + assert!(!rows[1].done, "/settings/sessions absente");
787 + }
788 +
789 + #[test]
790 + fn mark_evite_les_faux_positifs_de_prefixe() {
791 + // "/settings" ne doit PAS matcher "/settings/sessions"
792 + let mut rows = vec![row("/settings")];
793 + let haystack = r#".route("/settings/sessions", get(h))"#;
794 + mark(&mut rows, haystack);
795 + assert!(!rows[0].done);
796 + }
797 +
798 + #[test]
799 + fn collect_sources_ignore_target_et_vendored() {
800 + let base = std::env::temp_dir().join(format!("scrsel_{}", std::process::id()));
801 + let _ = std::fs::remove_dir_all(&base);
802 + std::fs::create_dir_all(base.join("src")).unwrap();
803 + std::fs::create_dir_all(base.join("crates/rustwarden-core/src")).unwrap();
804 + std::fs::create_dir_all(base.join("target")).unwrap();
805 + std::fs::write(base.join("src/app.rs"), r#".route("/mine", get(h))"#).unwrap();
806 + std::fs::write(base.join("crates/rustwarden-core/src/x.rs"), r#".route("/vendored", get(h))"#).unwrap();
807 + std::fs::write(base.join("target/y.rs"), r#".route("/built", get(h))"#).unwrap();
808 +
809 + let hay = collect_sources(&base);
810 + assert!(hay.contains("/mine"));
811 + assert!(!hay.contains("/vendored"));
812 + assert!(!hay.contains("/built"));
813 + let _ = std::fs::remove_dir_all(&base);
814 + }
815 +}
816 +```
817 +
818 +- [ ] **Step 3: Lancer — échoue**
819 +
820 +Run: `cargo test -p rustwarden-core --bin screen-select -- check::tests`
821 +Expected: FAIL.
822 +
823 +- [ ] **Step 4: Implémenter**
824 +
825 +```rust
826 +pub fn mark(rows: &mut [TodoRow], haystack: &str) {
827 + for r in rows.iter_mut() {
828 + // chaîne littérale exacte, guillemet fermant inclus → évite les
829 + // faux positifs de préfixe (/settings vs /settings/sessions).
830 + let needle = format!("\"{}\"", r.route);
831 + r.done = haystack.contains(&needle);
832 + }
833 +}
834 +
835 +pub fn collect_sources(root: &Path) -> String {
836 + let mut out = String::new();
837 + walk(root, &mut out);
838 + out
839 +}
840 +
841 +fn walk(dir: &Path, out: &mut String) {
842 + let Ok(entries) = std::fs::read_dir(dir) else {
843 + return;
844 + };
845 + for entry in entries.flatten() {
846 + let path = entry.path();
847 + let name = entry.file_name();
848 + let name = name.to_string_lossy();
849 + if path.is_dir() {
850 + if name == "target" || name.starts_with("rustwarden-") {
851 + continue;
852 + }
853 + walk(&path, out);
854 + } else if path.extension().is_some_and(|e| e == "rs") {
855 + if let Ok(s) = std::fs::read_to_string(&path) {
856 + out.push_str(&s);
857 + out.push('\n');
858 + }
859 + }
860 + }
861 +}
862 +```
863 +
864 +- [ ] **Step 5: Lancer — passe**
865 +
866 +Run: `cargo test -p rustwarden-core --bin screen-select -- check::tests`
867 +Expected: PASS (3 tests).
868 +
869 +- [ ] **Step 6: Commit (checkpoint)**
870 +
871 +```bash
872 +git add crates/rustwarden-core/src/bin/screen-select/check.rs crates/rustwarden-core/src/bin/screen-select/main.rs
873 +git commit -m "feat(screen-select): contrôle statique du réalisé"
874 +```
875 +
876 +---
877 +
878 +## Task 7: Câblage du binaire (wizard stdin + `--check`)
879 +
880 +**Files:**
881 +- Modify: `crates/rustwarden-core/src/bin/screen-select/main.rs`
882 +
883 +- [ ] **Step 1: Écrire le main complet**
884 +
885 +Remplacer intégralement `main.rs` par :
886 +
887 +```rust
888 +//! `screen-select` : génère/complète le plan des écrans à coder pour une UI
889 +//! custom consommant rustwarden-core, et recoche le réalisé (`--check`).
890 +
891 +mod catalogue;
892 +mod check;
893 +mod generate;
894 +mod todo;
895 +
896 +use std::io::{self, Write};
897 +use std::path::Path;
898 +
899 +const PLAN_PATH: &str = "docs/plans/ui-screens.md";
900 +
901 +fn main() -> io::Result<()> {
902 + if std::env::args().any(|a| a == "--check") {
903 + run_check()
904 + } else {
905 + run_wizard()
906 + }
907 +}
908 +
909 +/// Lit une ligne au clavier (chaîne vide si EOF).
910 +fn prompt(msg: &str) -> io::Result<String> {
911 + print!("{msg}");
912 + io::stdout().flush()?;
913 + let mut line = String::new();
914 + io::stdin().read_line(&mut line)?;
915 + Ok(line.trim().to_string())
916 +}
917 +
918 +fn run_wizard() -> io::Result<()> {
919 + println!("== screen-select : écrans à coder ==\n");
920 +
921 + // 1. Sélection des fonctionnalités (les "always" sont incluses d'office).
922 + let choosable: Vec<&catalogue::Functionality> =
923 + catalogue::all().iter().filter(|f| !f.always).collect();
924 + println!("Fonctionnalités activées ? (numéros séparés par des virgules)\n");
925 + for (i, f) in choosable.iter().enumerate() {
926 + println!(" {:>2}. {}", i + 1, f.label);
927 + }
928 + let raw = prompt("\nSélection : ")?;
929 + let picked: Vec<usize> = raw
930 + .split(',')
931 + .filter_map(|s| s.trim().parse::<usize>().ok())
932 + .filter(|n| *n >= 1 && *n <= choosable.len())
933 + .map(|n| n - 1)
934 + .collect();
935 +
936 + let mut selected: Vec<&catalogue::Functionality> =
937 + catalogue::all().iter().filter(|f| f.always).collect();
938 + for i in picked {
939 + selected.push(choosable[i]);
940 + }
941 +
942 + // 2. Entrées optionnelles.
943 + let design_path = prompt("Chemin d'un design.md (charte graphique, vide si aucun) : ")?;
944 + let design_md = if design_path.is_empty() {
945 + None
946 + } else {
947 + Some(std::fs::read_to_string(&design_path)?)
948 + };
949 + let layout = prompt("Structure de page (ex. 'navbar: gauche; 2 colonnes', vide si aucune) : ")?;
950 + let layout = if layout.is_empty() { None } else { Some(layout) };
951 +
952 + let project = std::env::current_dir()?
953 + .file_name()
954 + .map(|n| n.to_string_lossy().into_owned())
955 + .unwrap_or_else(|| "projet".into());
956 +
957 + let inputs = generate::PlanInputs { project: &project, selected, design_md, layout };
958 + let desired = generate::rows_for(&inputs.selected);
959 +
960 + // 3. Fusion si un plan existe déjà (préserve statuts + routes éditées).
961 + let path = Path::new(PLAN_PATH);
962 + let content = if path.exists() {
963 + let existing = std::fs::read_to_string(path)?;
964 + let prev = todo::parse(&existing);
965 + let merged = todo::merge(&prev, &desired);
966 + // conserve l'en-tête existant, remplace le tableau
967 + todo::replace_table(&existing, &merged)
968 + } else {
969 + generate::render_plan(&inputs, &desired)
970 + };
971 +
972 + if let Some(parent) = path.parent() {
973 + std::fs::create_dir_all(parent)?;
974 + }
975 + std::fs::write(path, content)?;
976 + println!("\n✓ Plan écrit dans {PLAN_PATH} ({} écrans).", desired.len());
977 + println!(" Contrôlez le réalisé : cargo run --bin screen-select -- --check");
978 + Ok(())
979 +}
980 +
981 +fn run_check() -> io::Result<()> {
982 + let path = Path::new(PLAN_PATH);
983 + if !path.exists() {
984 + eprintln!("Aucun plan à {PLAN_PATH} — lancez d'abord `screen-select` sans --check.");
985 + std::process::exit(1);
986 + }
987 + let content = std::fs::read_to_string(path)?;
988 + let mut rows = todo::parse(&content);
989 + let haystack = check::collect_sources(&std::env::current_dir()?);
990 + check::mark(&mut rows, &haystack);
991 + let updated = todo::replace_table(&content, &rows);
992 + std::fs::write(path, updated)?;
993 +
994 + let done = rows.iter().filter(|r| r.done).count();
995 + let missing: Vec<&todo::TodoRow> = rows.iter().filter(|r| !r.done).collect();
996 + println!("Couverture : {}/{} écrans réalisés.", done, rows.len());
997 + if missing.is_empty() {
998 + println!("✓ Tous les écrans des fonctionnalités activées sont câblés.");
999 + } else {
1000 + println!("Écrans manquants :");
1001 + for r in missing {
1002 + println!(" ☐ {} — {} ({})", r.functionality, r.screen, r.route);
1003 + }
1004 + }
1005 + Ok(())
1006 +}
1007 +```
1008 +
1009 +- [ ] **Step 2: Compiler + clippy**
1010 +
1011 +Run: `cargo clippy -p rustwarden-core --bin screen-select -- -D warnings`
1012 +Expected: 0 warning.
1013 +
1014 +- [ ] **Step 3: Smoke test manuel (génération)**
1015 +
1016 +Run (depuis la racine du dépôt, dans un sous-dossier jetable pour ne pas polluer) :
1017 +```bash
1018 +cd "$(mktemp -d)" && printf '2\n\n\n' | cargo run -p rustwarden-core --bin screen-select --manifest-path "$OLDPWD/crates/rustwarden-core/Cargo.toml"
1019 +```
1020 +Expected : crée `docs/plans/ui-screens.md` avec Authentication + PasswordReset (choix `2`), affiche « Plan écrit ». *(Note : en usage réel, la commande se lance depuis le projet consommateur : `cargo run --bin screen-select`.)*
1021 +
1022 +- [ ] **Step 4: Commit (checkpoint)**
1023 +
1024 +```bash
1025 +git add crates/rustwarden-core/src/bin/screen-select/main.rs
1026 +git commit -m "feat(screen-select): wizard stdin + commande --check"
1027 +```
1028 +
1029 +---
1030 +
1031 +## Task 8: Invite de lancement dans le CLI
1032 +
1033 +**Files:**
1034 +- Modify: `tools/rustwarden-cli/src/start_project.rs` (section « Prochaines etapes » de `print_summary`, ~ligne 849)
1035 +- Modify: `tools/rustwarden-cli/src/update_project.rs` (message final)
1036 +
1037 +- [ ] **Step 1: Ajouter l'invite dans `start_project.rs`**
1038 +
1039 +Dans `print_summary`, après l'étape « Lancer l'application » (repérer le bloc `println!(" {} Lancer l'application :", style("3.")…)` et les lignes qui suivent), ajouter une nouvelle étape :
1040 +
1041 +```rust
1042 + println!(
1043 + " {} Générer la liste des écrans d'UI à coder :",
1044 + style("4.").cyan().bold()
1045 + );
1046 + println!(" {}", style("cargo run --bin screen-select").green());
1047 + println!(
1048 + " puis {} pour recocher le réalisé",
1049 + style("cargo run --bin screen-select -- --check").green()
1050 + );
1051 +```
1052 +
1053 +- [ ] **Step 2: Ajouter l'invite dans `update_project.rs`**
1054 +
1055 +Repérer le bloc de messages finaux de `update_project.rs` (après la resync, là où sont listées les actions post-update) et ajouter :
1056 +
1057 +```rust
1058 + println!(
1059 + " • Nouveaux écrans éventuels : {} (puis {})",
1060 + style("cargo run --bin screen-select").green(),
1061 + style("-- --check").green()
1062 + );
1063 +```
1064 +
1065 +- [ ] **Step 3: Compiler le CLI**
1066 +
1067 +Run: `cargo check -p rustwarden-cli`
1068 +Expected: OK, 0 erreur.
1069 +
1070 +- [ ] **Step 4: Commit (checkpoint)**
1071 +
1072 +```bash
1073 +git add tools/rustwarden-cli/src/start_project.rs tools/rustwarden-cli/src/update_project.rs
1074 +git commit -m "feat(cli): invite de lancement screen-select après start/update"
1075 +```
1076 +
1077 +---
1078 +
1079 +## Task 9: Test de complétude du catalogue + vérif finale
1080 +
1081 +**Files:**
1082 +- Modify: `crates/rustwarden-core/src/bin/screen-select/catalogue.rs`
1083 +
1084 +- [ ] **Step 1: Test de garde-fou (rouge/vert)**
1085 +
1086 +Ce test fige la liste attendue de fonctionnalités : si un futur dev ajoute un
1087 +service UI sans écran au catalogue, ou renomme une clé, le test tombe et force la
1088 +mise à jour consciente. Ajouter au module `tests` de `catalogue.rs` :
1089 +
1090 +```rust
1091 + #[test]
1092 + fn catalogue_couvre_les_fonctionnalites_attendues() {
1093 + let expected = [
1094 + "authentication", "registration", "password_reset", "email_validation",
1095 + "two_factor", "account_profile", "password_change", "session_management",
1096 + "preferences", "oauth_linking", "teams", "personal_access_tokens",
1097 + "admin_users", "admin_roles", "admin_settings", "admin_error_log",
1098 + "admin_email_queue", "admin_dashboard",
1099 + ];
1100 + let keys: Vec<&str> = all().iter().map(|f| f.key).collect();
1101 + for k in expected {
1102 + assert!(keys.contains(&k), "fonctionnalité manquante au catalogue : {k}");
1103 + }
1104 + assert_eq!(keys.len(), expected.len(), "nombre de fonctionnalités inattendu");
1105 + }
1106 +```
1107 +
1108 +- [ ] **Step 2: Lancer toute la suite du binaire**
1109 +
1110 +Run: `cargo test -p rustwarden-core --bin screen-select`
1111 +Expected: PASS (catalogue, todo, generate, check).
1112 +
1113 +- [ ] **Step 3: Vérification workspace complète**
1114 +
1115 +Run: `cargo clippy --workspace -- -D warnings && cargo test -p rustwarden-core --bin screen-select`
1116 +Expected: 0 warning ; tous les tests du binaire verts.
1117 +
1118 +- [ ] **Step 4: Vérification e2e (génération → implémentation fictive → check)**
1119 +
1120 +```bash
1121 +tmp="$(mktemp -d)"; cd "$tmp"
1122 +# génère un plan avec SessionManagement (n°7 des fonctionnalités choisissables —
1123 +# à adapter à l'ordre réellement affiché par le wizard)
1124 +printf '7\n\n\n' | cargo run -p rustwarden-core --bin screen-select --manifest-path "$OLDPWD/crates/rustwarden-core/Cargo.toml"
1125 +# simule l'implémentation de l'écran à la route par défaut
1126 +mkdir -p src && printf 'fn r(){ .route("/settings/sessions", get(h)); }\n' > src/ui.rs
1127 +printf 'x\n' | cargo run -p rustwarden-core --bin screen-select --manifest-path "$OLDPWD/crates/rustwarden-core/Cargo.toml" -- --check
1128 +```
1129 +Expected: le second appel affiche `SessionsList` comme **réalisé** (☑) et le
1130 +recoche dans `docs/plans/ui-screens.md`.
1131 +
1132 +- [ ] **Step 5: Commit (checkpoint)**
1133 +
1134 +```bash
1135 +git add crates/rustwarden-core/src/bin/screen-select/catalogue.rs
1136 +git commit -m "test(screen-select): garde-fou de complétude du catalogue"
1137 +```
1138 +
1139 +---
1140 +
1141 +## Suivi post-implémentation (hors code)
1142 +
1143 +- [x] Mettre à jour `references/routes-ecrans-sans-ui.md` (skill) pour pointer vers le
1144 + catalogue de `screen-select` comme source de vérité de complétude.
1145 +- [x] Documenter `screen-select` dans `docs/SERVICES_REFERENCE.md` (nouvelle sous-section §22).
1146 +- [x] Vérifier que `update-project` copie bien `src/bin/screen-select/` (il resynchronise
1147 + les fichiers présents de la crate — le binaire voyage donc avec la crate).
1148 +
1149 +---
1150 +
1151 +## Journal de réalisation (2026-08-02)
1152 +
1153 +Plan exécuté intégralement en subagent-driven development : un implémenteur par tâche,
1154 +puis double revue (conformité spec, qualité de code) par des agents distincts, avec
1155 +boucle de correction jusqu'à approbation. Les 9 tâches sont **terminées et approuvées**.
1156 +
1157 +**Vérifications finales** : 14/14 tests du binaire verts ; `cargo clippy --workspace -- -D warnings`
1158 +sans warning ; e2e wizard→check validé en répertoire isolé (`Couverture : 1/2`,
1159 +`SessionsList` ☐→☑) ; aucun commit effectué (versioning manuel, tout est en working tree).
1160 +
1161 +**Écarts au plan (assumés)** :
1162 +- Task 8 : `start_project.rs` avait déjà une étape 4 (« Ouvrir dans le navigateur ») —
1163 + insérée en 4, l'existante renumérotée en 5.
1164 +- Étapes « Commit (checkpoint) » sautées conformément à la politique git du projet.
1165 +- Doc §22 : décompte corrigé à 22 écrans (le catalogue en contient 22, pas 21).
1166 +
1167 +**Findings mineurs non bloquants (au choix du mainteneur)** :
1168 +- Wizard : une sélection dupliquée (`2,2`) produit des lignes en double (dédup absente du plan).
1169 +- Wizard : sur re-run avec plan existant, les invites design/layout sont posées mais ignorées.
1170 +- `parse` lit toutes les lignes `|` du fichier, pas seulement le premier bloc (sans effet
1171 + sur `ui-screens.md` qui n'a qu'un tableau).
1172 +- Clé de fusion = label affiché (renommer un label du catalogue casse la continuité du merge).
1173 +- Détection `--check` textuelle : routes commentées comptées, routes hors `.rs` invisibles,
1174 + ☑ manuels écrasés à chaque `--check` (documenté en §22 de SERVICES_REFERENCE).
1175 +- `ui-screens.md` généré non gitignoré (décision projet à prendre).
A docs/plans/2026-08-02-test-support-harness.md
+170 -0
@@ -0,0 +1,170 @@
1 +# Spec — Harnais de test à base éphémère (`rustwarden-core` feature `test-support`)
2 +
3 +> Statut : **design validé (brainstorming 2026-08-02)**, prêt pour un plan.
4 +> Socle retenu : **template PostgreSQL + clone par test** (Option 1).
5 +
6 +## Problème
7 +
8 +Les tests d'intégration tapent aujourd'hui la **base de développement** (via le
9 +`.env` global). Deux douleurs :
10 +
11 +1. **Résidu** : les comptes/données créés par les tests s'accumulent dans la base
12 + de dev.
13 +2. **Config figée** : impossible d'évaluer d'autres configurations (autre
14 + `auth_failure_mode`, `JWT_SECRET`, timeouts…) sans éditer le `.env`.
15 +
16 +Le même problème frappe les projets consommateurs (gitrust, BotPlace).
17 +
18 +## Objectif
19 +
20 +Fournir un **harnais de test** qui donne à chaque test une **base PostgreSQL
21 +éphémère, migrée et seedée à neuf**, isolée de la base de dev, avec une **config
22 +de test découplée du `.env`** — et **réutilisable par les projets consommateurs**.
23 +
24 +## Non-goals (YAGNI)
25 +
26 +- Pas de **testcontainers** (Option 2 écartée pour ce socle).
27 +- Pas de **rollback-par-transaction** (Option 3 écartée).
28 +- Suppose un **PostgreSQL accessible** avec un rôle pouvant `CREATE DATABASE` (le
29 + `database/` docker-compose existant convient ; idéalement un port/DB de test
30 + dédié via `.env.test`).
31 +- Ne fournit pas d'UI ni de données métier — juste le seed baseline framework +
32 + un point d'extension pour les fixtures du consommateur.
33 +
34 +## Placement
35 +
36 +- **Feature Cargo `test-support`** dans `rustwarden-core`, gated
37 + `#[cfg(feature = "test-support")]`. Module `rustwarden_core::test_support`.
38 +- Consommé en `dev-dependencies` :
39 + `rustwarden-core = { path = "…", features = ["test-support"] }`.
40 +- Vendored → resynchronisé par `update-project`, réutilisable par les
41 + consommateurs. Dépendances additionnelles (ex. `sqlx`/`tokio-postgres` pour la
42 + connexion de maintenance) **gated par la feature** pour ne pas alourdir la lib
43 + en usage normal.
44 +
45 +## Configuration de test — `.env.test`
46 +
47 +- Le harnais charge **`.env.test`** via `dotenvy::from_filename(".env.test")`
48 + (jamais `.env`). Les tests **n'appellent PAS `.from_env()`** (qui lit `.env` +
49 + init le logging).
50 +- `.env.test` porte le **socle** : URL du serveur de test (rôle `CREATE
51 + DATABASE`), `JWT_SECRET` de test, `APP_DEBUG=true`, etc.
52 +- **Variations ponctuelles** par test via les setters existants
53 + (`.auth_failure_mode(...)`, `.login_path(...)`). Pas de `.auth_config()` ajouté.
54 +- `DATABASE_URL` de `.env.test` désigne le **serveur** ; le nom de base réel est
55 + injecté par le harnais (`.database(clone_conn)`), pas lu du `.env.test`.
56 +
57 +## Mécanique — template + clone (sans course entre processus)
58 +
59 +`cargo test` lance plusieurs binaires de test **en parallèle** (processus
60 +distincts). Invariants :
61 +
62 +1. **Nom de template versionné** : `rw_tmpl_<hash>` où `<hash>` est un hash
63 + court et stable des **identifiants de migration** (`core_migrations()` +
64 + migrations projet). Tous les binaires d'un même `cargo test` calculent le
65 + **même** hash → même template. Changer les migrations change le hash → nouveau
66 + template bâti automatiquement, **sans jamais dropper un template en cours
67 + d'usage**.
68 +2. **Prune au démarrage** (au 1er `fresh_db()` du processus, init `OnceLock`
69 + async) : supprime — **uniquement parmi les bases sans connexion active**
70 + (vérifié via `pg_stat_activity`, pour ne JAMAIS dropper un clone en cours
71 + d'usage par un binaire de test concurrent) — les clones résiduels `rw_test_%`
72 + et les templates `rw_tmpl_%` **de hash différent** (résidus de runs
73 + précédents / de panics). Préfixes disjoints (`rw_test_` ≠ `rw_tmpl_`) : le
74 + prune des clones ne touche pas le template courant.
75 +3. **Création idempotente du template**, protégée par **`pg_advisory_lock(key)`**
76 + (key dérivée du hash) : le premier processus prend le verrou, crée
77 + `rw_tmpl_<hash>` si absent (applique migrations + `seed_baseline`),
78 + relâche ; les autres attendent puis constatent qu'il existe. **Aucune
79 + connexion n'est laissée ouverte** au template (sinon les clones se bloquent).
80 +4. **Clone par test** : `CREATE DATABASE rw_test_<uuid> TEMPLATE
81 + rw_tmpl_<hash>` (quelques ms → **données initiales à neuf**). Retry court
82 + sur l'erreur transitoire « source database is being accessed by other users ».
83 +5. **Nettoyage** : prune au run suivant (robuste aux panics) **+** DROP
84 + best-effort en fin de test via le guard (voir API). Pas de `Drop` async — le
85 + drop best-effort passe par une connexion de maintenance dédiée, l'échec est
86 + toléré (le prune rattrape).
87 +
88 +### Connexion de maintenance
89 +
90 +Les `CREATE/DROP/TEMPLATE` et `pg_advisory_lock` s'exécutent sur la base
91 +d'administration **`postgres`** du même serveur (host/port/rôle dérivés de l'URL
92 +`.env.test`). Séparée de la connexion applicative (qui pointe sur le clone).
93 +
94 +## Seed baseline
95 +
96 +Rejoué **à la création du template** (donc présent dans chaque clone) :
97 +
98 +- admin par défaut (`default_admin_service::ensure_default_admin`),
99 +- `app_settings` par défaut (`AppSettingsService::initialize_default_settings`),
100 +- rôles/permissions de base si applicable.
101 +
102 +Extension consommateur : variante `fresh_db_seeded(seed_fn)` où `seed_fn:
103 +FnOnce(&DatabaseConnection) -> Future` ajoute les fixtures métier **dans le
104 +clone** (après le baseline). *(Note : les fixtures via `fresh_db_seeded`
105 +s'appliquent au clone, pas au template — donc pas partagées ; pour des fixtures
106 +lourdes communes, une évolution future pourrait les intégrer au template.)*
107 +
108 +## API côté test
109 +
110 +```rust
111 +use rustwarden_core::test_support;
112 +
113 +#[tokio::test]
114 +async fn login_redirige_en_mode_ui() {
115 + let db = test_support::fresh_db().await; // clone migré + seedé
116 + let app = RustwardenApp::builder()
117 + .database(db.conn()) // pas de DATABASE_URL du .env
118 + .auth_failure_mode(AuthFailureMode::Redirect) // override ponctuel
119 + .without_email()
120 + .build().await.unwrap();
121 + // … oneshot(app.into_router(), GET /settings) → 303 …
122 +} // db: DROP best-effort ; sinon prune au prochain run
123 +```
124 +
125 +- `fresh_db() -> TestDb` : garantit `.env.test` chargé, template prêt (une fois),
126 + prune (une fois), puis clone. Idempotent et concurrent-safe.
127 +- `TestDb::conn() -> DatabaseConnection` (clone applicatif).
128 +- `TestDb` porte le nom du clone ; son `Drop` tente un DROP best-effort.
129 +- `fresh_db_seeded(seed_fn)` : idem + fixtures métier dans le clone.
130 +
131 +## Documentation (demandée)
132 +
133 +- Rustdoc `///` sur toute l'API `test_support` (anglais, comme la lib).
134 +- Section « Tests isolés » dans `docs/SERVICES_REFERENCE.md` (propagée aux
135 + consommateurs) : mise en place de `.env.test`, `dev-dependencies` avec la
136 + feature, exemple, et la note « ne pas utiliser en prod ».
137 +- Un `.env.test.example` à la racine du dépôt + généré par le wizard
138 + `start-project`.
139 +
140 +## Décisions verrouillées
141 +
142 +- Socle **template + clone** ; feature **`test-support`** dans `rustwarden-core` ;
143 + config via **`.env.test`** + setters ; nettoyage par **prune au démarrage** ;
144 + template unique versionné par **hash de migrations**, création sous **verrou
145 + d'avis**.
146 +
147 +## Décisions ouvertes (pour le plan)
148 +
149 +1. **Client de maintenance** : `sqlx::PgPool` (déjà transitif) vs
150 + `tokio-postgres`. Reco : `sqlx` (cohérent avec SeaORM, déjà présent), gated par
151 + la feature.
152 +2. **Forme du hash de migrations** : hash des `name()` de `core_migrations()` (le
153 + consommateur ajoute ses migrations → le hash doit les inclure aussi). À définir :
154 + API pour que le consommateur fournisse sa liste de migrations au harnais.
155 +3. **Génération de `.env.test`** par `start-project` : systématique vs opt-in.
156 +4. **Emplacement du seed baseline** : réutiliser tel quel `ensure_default_admin` /
157 + `initialize_default_settings`, ou un `seed_baseline()` dédié qui les compose.
158 +
159 +## Stratégie de test (du harnais lui-même)
160 +
161 +- Test d'intégration `rustwarden-core` (feature `test-support` activée) :
162 + - `fresh_db()` deux fois en parallèle → deux bases distinctes, toutes deux
163 + migrées+seedées (admin présent) ;
164 + - une écriture dans un clone n'est **pas** visible dans l'autre (isolation) ;
165 + - un nouveau `fresh_db()` après écriture → données **baseline à neuf** (pas de
166 + résidu) ;
167 + - le prune supprime un `rw_test_%` résiduel créé à la main.
168 +- Test de bout en bout : un test qui build l'app via `.database(db.conn())` +
169 + `.auth_failure_mode(Json401)` et vérifie le 401 sur route protégée — prouve la
170 + variation de config sans `.env`.
A docs/plans/2026-08-03-screen-select-securite-ui.md
+930 -0
@@ -0,0 +1,930 @@
1 +# screen-select — Enrichissement sécurité du plan généré
2 +
3 +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4 +>
5 +> **Politique git de ce projet** : versioning **manuel**. Les étapes « Commit » sont des **points de checkpoint** — le développeur committe s'il le souhaite ; **un agent ne committe jamais sans accord explicite**. Ne pas `git add/commit/push` automatiquement.
6 +
7 +**Goal:** Enrichir le binaire `screen-select` pour que le plan Markdown généré porte, dès le départ, le **contrat de sécurité de chaque écran** (niveau d'auth, pièges spécifiques) plus deux sections transversales fixes (garde-fous globaux + conventions de rendu sûr), de sorte que les findings récurrents d'un audit d'UI d'administration soient pré-imprimés au lieu d'être découverts après coup.
8 +
9 +**Architecture:** On ne touche NI au parseur/tableau (`todo.rs`) NI au contrôle statique (`check.rs`). Dans `catalogue.rs`, `protected: bool` devient `auth_level: AuthLevel` (la colonne « Protégé » du tableau en dérive à l'identique via `AuthLevel::is_protected()`), et chaque `Screen` gagne `security_notes: &'static [&'static str]`. Deux nouveaux modules bin-locaux : `security.rs` (contenu de sécurité : bloc statique + contrat par écran dérivé du catalogue) et `section.rs` (remplacement **idempotent** de sections délimitées par marqueurs HTML, pour que le re-run wizard régénère proprement). Le tableau reste la source de vérité de `--check` ; les sections sécurité vivent hors tableau et ne sont jamais touchées par `--check`.
10 +
11 +**Tech Stack:** Rust 2024, `std` uniquement (aucune nouvelle crate), `cargo`.
12 +
13 +**Réf :** rapport d'audit UI (XSS token en contexte JS, open redirect `?redirect=`, `RawQuery` pass-through, `.env` dangereux, headers manquants, énumération username) + « Positive Findings » de l'audit transformés en invariants imprimés (CSRF POST browser, IDOR/ownership, `ensure_admin()`, anti-énumération, `textContent`).
14 +
15 +---
16 +
17 +## Structure des fichiers
18 +
19 +| Fichier | Responsabilité | Action |
20 +|---|---|---|
21 +| `crates/rustwarden-core/src/bin/screen-select/catalogue.rs` | `AuthLevel` + `security_notes` sur `Screen` ; données des 18 fonctionnalités enrichies ; tests garde-fous | Modify |
22 +| `.../screen-select/security.rs` | bloc statique (transversal + conventions) + contrat de sécurité par écran (dérivé du catalogue) | Create |
23 +| `.../screen-select/section.rs` | `block()` / `upsert()` : sections idempotentes à marqueurs HTML | Create |
24 +| `.../screen-select/generate.rs` | `rows_for` (dérive `protected` de `auth_level`) + `render_plan` (tisse les sections) | Modify |
25 +| `.../screen-select/main.rs` | `mod security; mod section;` + re-run : `upsert` des sections sécurité | Modify |
26 +| `.../screen-select/todo.rs` | **inchangé** (le tableau garde sa colonne « Protégé » oui/non) | — |
27 +| `.../screen-select/check.rs` | **inchangé** (`--check` ne touche que le tableau) | — |
28 +| `docs/SERVICES_REFERENCE.md` | §22 : documenter les nouvelles sections | Modify |
29 +
30 +**Invariant clé de non-régression :** `AuthLevel::is_protected()` reproduit exactement les anciens booléens `protected` (`Public ⇒ false`, tout le reste ⇒ `true`). La colonne « Protégé » du tableau, le round-trip `parse`/`render`/`merge` et `--check` restent donc identiques bit à bit.
31 +
32 +---
33 +
34 +## Task 1: `AuthLevel` + `security_notes` dans le catalogue
35 +
36 +**Files:**
37 +- Modify: `crates/rustwarden-core/src/bin/screen-select/catalogue.rs`
38 +
39 +- [ ] **Step 1: Écrire les tests garde-fous (rouge)**
40 +
41 +Ajouter au module `tests` de `catalogue.rs` (après `catalogue_couvre_les_fonctionnalites_attendues`) :
42 +
43 +```rust
44 + #[test]
45 + fn auth_level_preserve_la_protection_existante() {
46 + // Public ⇒ non protégé ; tout le reste ⇒ protégé (colonne du tableau inchangée).
47 + assert!(!AuthLevel::Public.is_protected());
48 + assert!(AuthLevel::Authenticated.is_protected());
49 + assert!(AuthLevel::ResourceOwner.is_protected());
50 + assert!(AuthLevel::Admin.is_protected());
51 + // Login reste public ; tous les écrans admin restent en niveau Admin.
52 + assert_eq!(
53 + by_key("authentication").unwrap().screens[0].auth_level,
54 + AuthLevel::Public
55 + );
56 + assert!(
57 + all()
58 + .iter()
59 + .filter(|f| f.key.starts_with("admin_"))
60 + .flat_map(|f| f.screens)
61 + .all(|s| s.auth_level == AuthLevel::Admin),
62 + "tous les écrans admin doivent être AuthLevel::Admin"
63 + );
64 + }
65 +
66 + #[test]
67 + fn contrats_de_securite_couvrent_les_pieges_de_l_audit() {
68 + let note = |key: &str, screen: &str, needle: &str| {
69 + by_key(key)
70 + .unwrap()
71 + .screens
72 + .iter()
73 + .find(|s| s.name == screen)
74 + .unwrap_or_else(|| panic!("écran {screen} introuvable"))
75 + .security_notes
76 + .iter()
77 + .any(|n| n.to_lowercase().contains(needle))
78 + };
79 + // #1 XSS — token jamais dans un <script>, passer par data-*.
80 + assert!(note("password_reset", "ResetPassword", "data-"));
81 + // #2 open redirect ?redirect=.
82 + assert!(note("authentication", "Login", "redirect"));
83 + // #4 RawQuery pass-through.
84 + assert!(note("two_factor", "TwoFactorSetup", "rawquery"));
85 +
86 + // Invariants par niveau + présence d'au moins une note partout.
87 + for f in all() {
88 + for s in f.screens {
89 + assert!(
90 + !s.security_notes.is_empty(),
91 + "{} doit porter au moins une note de sécurité",
92 + s.name
93 + );
94 + match s.auth_level {
95 + AuthLevel::Admin => assert!(
96 + s.security_notes.iter().any(|n| n.contains("ensure_admin")),
97 + "{} (Admin) doit rappeler ensure_admin()",
98 + s.name
99 + ),
100 + AuthLevel::ResourceOwner => assert!(
101 + s.security_notes.iter().any(|n| {
102 + let l = n.to_lowercase();
103 + l.contains("idor")
104 + || l.contains("claims.sub")
105 + || l.contains("appartenance")
106 + }),
107 + "{} (ResourceOwner) doit rappeler le contrôle d'appartenance (IDOR)",
108 + s.name
109 + ),
110 + _ => {}
111 + }
112 + }
113 + }
114 + }
115 +```
116 +
117 +- [ ] **Step 2: Lancer — échec de compilation attendu**
118 +
119 +Run: `cargo test -p rustwarden-core --bin screen-select -- catalogue::tests`
120 +Expected: FAIL (compilation) — `AuthLevel`, `is_protected`, `auth_level`, `security_notes` n'existent pas encore.
121 +
122 +- [ ] **Step 3: Déclarer `AuthLevel`**
123 +
124 +Dans `catalogue.rs`, juste après le bloc `impl Channel { … }` (avant `struct Screen`), insérer :
125 +
126 +```rust
127 +/// Niveau d'autorisation attendu pour monter l'écran (contrat de sécurité).
128 +#[derive(Clone, Copy, PartialEq, Eq, Debug)]
129 +pub enum AuthLevel {
130 + /// Accessible sans session (flux publics : login, reset, vérification…).
131 + Public,
132 + /// Session valide requise (n'importe quel utilisateur authentifié).
133 + Authenticated,
134 + /// Authentifié + l'écran manipule une ressource nominative : vérifier
135 + /// l'appartenance avant lecture/mutation (défense IDOR).
136 + ResourceOwner,
137 + /// Réservé aux administrateurs : `ensure_admin()` en défense en profondeur.
138 + Admin,
139 +}
140 +
141 +impl AuthLevel {
142 + /// Libellé pour le contrat de sécurité par écran.
143 + pub fn label(self) -> &'static str {
144 + match self {
145 + AuthLevel::Public => "Public",
146 + AuthLevel::Authenticated => "Authentifié",
147 + AuthLevel::ResourceOwner => "Propriétaire de ressource",
148 + AuthLevel::Admin => "Administrateur",
149 + }
150 + }
151 +
152 + /// Reporté tel quel dans la colonne « Protégé » du tableau :
153 + /// tout niveau non public exige une session.
154 + pub fn is_protected(self) -> bool {
155 + !matches!(self, AuthLevel::Public)
156 + }
157 +}
158 +```
159 +
160 +- [ ] **Step 4: Étendre le `struct Screen`**
161 +
162 +Remplacer la définition actuelle :
163 +
164 +```rust
165 +#[derive(Clone, Copy, Debug)]
166 +pub struct Screen {
167 + pub name: &'static str,
168 + pub suggested_route: &'static str,
169 + pub channel: Channel,
170 + pub protected: bool,
171 + pub core_methods: &'static str,
172 + pub io: &'static str,
173 +}
174 +```
175 +
176 +par :
177 +
178 +```rust
179 +#[derive(Clone, Copy, Debug)]
180 +pub struct Screen {
181 + pub name: &'static str,
182 + pub suggested_route: &'static str,
183 + pub channel: Channel,
184 + pub auth_level: AuthLevel,
185 + pub core_methods: &'static str,
186 + pub io: &'static str,
187 + /// Pièges de sécurité spécifiques à cet écran (imprimés dans le plan).
188 + pub security_notes: &'static [&'static str],
189 +}
190 +```
191 +
192 +- [ ] **Step 5: Réécrire les données `CATALOGUE`**
193 +
194 +Remplacer intégralement le `const CATALOGUE: &[Functionality] = &[ … ];` par :
195 +
196 +```rust
197 +const CATALOGUE: &[Functionality] = &[
198 + Functionality { key: "authentication", label: "Authentication", always: true, screens: &[
199 + Screen { name: "Login", suggested_route: "/login", channel: Channel::Browser, auth_level: AuthLevel::Public,
200 + core_methods: "AuthService, JwtService, POST /api/v1/auth/login", io: "form login → cookie JWT / challenge 2FA",
201 + security_notes: &[
202 + "redirect post-login : n'autoriser qu'un chemin relatif same-origin (rejeter les URL absolues et `//host`) — bloque l'open redirect `?redirect=`",
203 + "flux public : ne pas divulguer si l'identifiant/email existe (message d'échec générique)",
204 + ] },
205 + ]},
206 + Functionality { key: "registration", label: "Registration", always: false, screens: &[
207 + Screen { name: "Register", suggested_route: "/register", channel: Channel::Browser, auth_level: AuthLevel::Public,
208 + core_methods: "UserService, AppSettingsService(allow_registration), POST /api/v1/auth/register", io: "form inscription",
209 + security_notes: &[
210 + "respecter `allow_registration` (403 si désactivé) ; valider/normaliser l'email côté serveur",
211 + "flux public : ne pas révéler qu'un email est déjà enregistré",
212 + ] },
213 + ]},
214 + Functionality { key: "password_reset", label: "PasswordReset", always: false, screens: &[
215 + Screen { name: "ForgotPassword", suggested_route: "/forgot-password", channel: Channel::Browser, auth_level: AuthLevel::Public,
216 + core_methods: "PasswordResetService, POST /api/v1/auth/forgot-password", io: "email → lien reset",
217 + security_notes: &[
218 + "réponse identique que l'email existe ou non (anti-énumération)",
219 + ] },
220 + Screen { name: "ResetPassword", suggested_route: "/reset-password", channel: Channel::Browser, auth_level: AuthLevel::Public,
221 + core_methods: "PasswordResetService, POST /api/v1/auth/reset-password", io: "token + nouveau mdp",
222 + security_notes: &[
223 + "le token ne doit JAMAIS être interpolé dans un `<script>` : le passer par un attribut `data-*` et le lire via `dataset` (l'escaping HTML d'Askama n'échappe pas le contexte JS ni le backslash)",
224 + "consommer le token en un seul usage ; ne pas confirmer si le token est valide avant soumission",
225 + ] },
226 + ]},
227 + Functionality { key: "email_validation", label: "EmailValidation", always: false, screens: &[
228 + Screen { name: "ResendVerification", suggested_route: "/resend-verification", channel: Channel::Browser, auth_level: AuthLevel::Public,
229 + core_methods: "EmailValidationService, POST /api/v1/auth/resend-verification", io: "renvoi email (résultat rendu par le core)",
230 + security_notes: &[
231 + "réponse identique quel que soit l'état du compte (anti-énumération) ; rate-limit les renvois",
232 + ] },
233 + ]},
234 + Functionality { key: "two_factor", label: "TwoFactor", always: false, screens: &[
235 + Screen { name: "TwoFactorChallenge", suggested_route: "/login/2fa", channel: Channel::Browser, auth_level: AuthLevel::Public,
236 + core_methods: "TotpService, POST /api/v1/auth/2fa/verify", io: "code TOTP ou backup",
237 + security_notes: &[
238 + "étape intermédiaire liée à une pré-session : rate-limit strict, ne pas révéler la validité partielle du code",
239 + ] },
240 + Screen { name: "TwoFactorSetup", suggested_route: "/settings/2fa", channel: Channel::Browser, auth_level: AuthLevel::Authenticated,
241 + core_methods: "TotpService::generate_setup/confirm/disable", io: "QR + confirmation",
242 + security_notes: &[
243 + "toute redirection de sécurité doit parser et whitelister les query params attendus — ne jamais forwarder un `RawQuery` brut",
244 + "exiger une confirmation par code avant activation ; le secret TOTP reste chiffré (CryptoService)",
245 + ] },
246 + Screen { name: "TwoFactorBackupCodes", suggested_route: "/settings/2fa/backup", channel: Channel::Browser, auth_level: AuthLevel::Authenticated,
247 + core_methods: "TotpService (backup codes)", io: "génération/affichage codes",
248 + security_notes: &[
249 + "codes affichés une seule fois, jamais logués ; ne stocker que leur hash",
250 + ] },
251 + ]},
252 + Functionality { key: "account_profile", label: "AccountProfile", always: false, screens: &[
253 + Screen { name: "Profile", suggested_route: "/profile", channel: Channel::Browser, auth_level: AuthLevel::Authenticated,
254 + core_methods: "UserService", io: "voir/éditer profil",
255 + security_notes: &[
256 + "n'exposer et n'éditer que le profil de `claims.sub`",
257 + ] },
258 + ]},
259 + Functionality { key: "password_change", label: "PasswordChange", always: false, screens: &[
260 + Screen { name: "PasswordChange", suggested_route: "/settings/password", channel: Channel::Browser, auth_level: AuthLevel::Authenticated,
261 + core_methods: "PasswordChangeService", io: "changement mdp (confirmation email)",
262 + security_notes: &[
263 + "exiger le mot de passe courant ; invalider les autres sessions après changement",
264 + ] },
265 + Screen { name: "ConfirmPasswordChange", suggested_route: "/confirm-password-change", channel: Channel::Browser, auth_level: AuthLevel::Public,
266 + core_methods: "PasswordChangeService::find_by_token/confirm_request", io: "confirme via token",
267 + security_notes: &[
268 + "valider le token côté core ; token à usage unique et expirant",
269 + ] },
270 + ]},
271 + Functionality { key: "session_management", label: "SessionManagement", always: false, screens: &[
272 + Screen { name: "SessionsList", suggested_route: "/settings/sessions", channel: Channel::Browser, auth_level: AuthLevel::ResourceOwner,
273 + core_methods: "RefreshTokenService::list_active/revoke, JwtBlacklistService", io: "liste sessions + révoquer",
274 + security_notes: &[
275 + "IDOR : lister et révoquer uniquement les sessions dont l'`id` appartient à `claims.sub` (vérifier l'appartenance avant révocation)",
276 + ] },
277 + ]},
278 + Functionality { key: "preferences", label: "Preferences", always: false, screens: &[
279 + Screen { name: "Preferences", suggested_route: "/settings/preferences", channel: Channel::Browser, auth_level: AuthLevel::Authenticated,
280 + core_methods: "UserPreferencesService", io: "préférences utilisateur",
281 + security_notes: &[
282 + "ne modifier que les préférences de `claims.sub`",
283 + ] },
284 + ]},
285 + Functionality { key: "oauth_linking", label: "OAuthLinking", always: false, screens: &[
286 + Screen { name: "OAuthAccountLinking", suggested_route: "/settings/oauth", channel: Channel::Browser, auth_level: AuthLevel::Authenticated,
287 + core_methods: "OAuthService, GET /api/v1/auth/oauth/{provider}/redirect", io: "lier/délier comptes (+ boutons login)",
288 + security_notes: &[
289 + "vérifier le paramètre `state` anti-CSRF au retour du provider",
290 + "données renvoyées par l'API en `textContent`, jamais `innerHTML` ; URLs construites via `encodeURIComponent`",
291 + ] },
292 + ]},
293 + Functionality { key: "teams", label: "Teams", always: false, screens: &[
294 + Screen { name: "TeamsManagement", suggested_route: "/teams", channel: Channel::Browser, auth_level: AuthLevel::ResourceOwner,
295 + core_methods: "TeamService (create/add_member/…), TeamRole", io: "équipes + membres (owner/admin/member)",
296 + security_notes: &[
297 + "IDOR/RBAC : vérifier le rôle de l'appelant dans CETTE équipe (`role_of`) avant chaque mutation ; owner non ré-attribuable",
298 + "à l'ajout d'un membre : message générique si l'utilisateur est introuvable (anti-énumération)",
299 + ] },
300 + ]},
301 + Functionality { key: "personal_access_tokens", label: "PersonalAccessTokens", always: false, screens: &[
302 + Screen { name: "PatManagement", suggested_route: "/settings/tokens", channel: Channel::Browser, auth_level: AuthLevel::ResourceOwner,
303 + core_methods: "PersonalTokenService::create/list_for_user/revoke", io: "créer/révoquer jetons (plaintext 1 fois)",
304 + security_notes: &[
305 + "jeton en clair affiché une seule fois, jamais reconsultable ni logué",
306 + "IDOR : révocation et listage filtrés par `claims.sub`",
307 + ] },
308 + ]},
309 + Functionality { key: "admin_users", label: "Admin · Users", always: false, screens: &[
310 + Screen { name: "AdminUsers", suggested_route: "/admin/users", channel: Channel::Browser, auth_level: AuthLevel::Admin,
311 + core_methods: "UserService, RoleService, PasswordResetService, TotpService", io: "liste + CRUD + reset",
312 + security_notes: &[
313 + "`ensure_admin()` dans le handler en plus du middleware (défense en profondeur)",
314 + "empêcher l'auto-suppression ; message générique si utilisateur introuvable",
315 + ] },
316 + ]},
317 + Functionality { key: "admin_roles", label: "Admin · Roles", always: false, screens: &[
318 + Screen { name: "AdminRoles", suggested_route: "/admin/roles", channel: Channel::Browser, auth_level: AuthLevel::Admin,
319 + core_methods: "RoleService, PermissionService", io: "liste + CRUD rôles/permissions",
320 + security_notes: &[
321 + "`ensure_admin()` en défense en profondeur ; protéger le dernier admin",
322 + "ne pas permettre l'auto-escalade de permissions",
323 + ] },
324 + ]},
325 + Functionality { key: "admin_settings", label: "Admin · Settings", always: false, screens: &[
326 + Screen { name: "AdminSettings", suggested_route: "/admin/settings", channel: Channel::Browser, auth_level: AuthLevel::Admin,
327 + core_methods: "AppSettingsService, CryptoService", io: "réglages applicatifs",
328 + security_notes: &[
329 + "`ensure_admin()` en défense en profondeur ; secrets chiffrés via CryptoService, jamais rendus en clair",
330 + ] },
331 + ]},
332 + Functionality { key: "admin_error_log", label: "Admin · ErrorLog", always: false, screens: &[
333 + Screen { name: "AdminErrorLog", suggested_route: "/admin/errors", channel: Channel::Browser, auth_level: AuthLevel::Admin,
334 + core_methods: "error_handler, I18nService", io: "journal d'erreurs",
335 + security_notes: &[
336 + "`ensure_admin()` en défense en profondeur ; ne pas exposer de stack traces / chemins internes en production",
337 + ] },
338 + ]},
339 + Functionality { key: "admin_email_queue", label: "Admin · EmailQueue", always: false, screens: &[
340 + Screen { name: "AdminEmailQueue", suggested_route: "/admin/emails", channel: Channel::Browser, auth_level: AuthLevel::Admin,
341 + core_methods: "EmailQueueService", io: "file d'emails",
342 + security_notes: &[
343 + "`ensure_admin()` en défense en profondeur ; ne pas afficher de contenu d'email sensible en clair",
344 + ] },
345 + ]},
346 + Functionality { key: "admin_dashboard", label: "Admin · Dashboard", always: false, screens: &[
347 + Screen { name: "AdminDashboard", suggested_route: "/admin", channel: Channel::Browser, auth_level: AuthLevel::Admin,
348 + core_methods: "cartes DashboardCardDTO", io: "accueil admin",
349 + security_notes: &[
350 + "`ensure_admin()` en défense en profondeur ; cartes injectées correctement échappées",
351 + ] },
352 + ]},
353 +];
354 +```
355 +
356 +- [ ] **Step 6: Lancer — passe**
357 +
358 +Run: `cargo test -p rustwarden-core --bin screen-select -- catalogue::tests`
359 +Expected: PASS (les 4 tests du catalogue, dont les 2 nouveaux garde-fous).
360 +
361 +- [ ] **Step 7: Commit (checkpoint)**
362 +
363 +```bash
364 +git add crates/rustwarden-core/src/bin/screen-select/catalogue.rs
365 +git commit -m "feat(screen-select): AuthLevel + notes de sécurité par écran au catalogue"
366 +```
367 +
368 +---
369 +
370 +## Task 2: Module `section` — sections idempotentes à marqueurs
371 +
372 +**Files:**
373 +- Create: `crates/rustwarden-core/src/bin/screen-select/section.rs`
374 +- Modify: `crates/rustwarden-core/src/bin/screen-select/main.rs` (`mod section;`)
375 +
376 +- [ ] **Step 1: Déclarer le module**
377 +
378 +Dans `main.rs`, ajouter la ligne `mod section;` au groupe des `mod` (ordre alphabétique, après `mod generate;`).
379 +
380 +- [ ] **Step 2: Écrire `section.rs` avec tests (rouge)**
381 +
382 +Créer `section.rs` avec les signatures vides + tests :
383 +
384 +```rust
385 +//! Remplacement idempotent d'une section délimitée par des marqueurs HTML.
386 +//! Permet de régénérer les blocs de sécurité lors d'un re-run du wizard sans
387 +//! écraser le reste du document (charte, tableau, prose ajoutée par le dev).
388 +
389 +/// Bloc encadré : `<!-- id -->\n{body}\n<!-- /id -->`.
390 +pub fn block(id: &str, body: &str) -> String {
391 + String::new() // remplacé au Step 4
392 +}
393 +
394 +/// Insère (à la fin) ou remplace en place la section `id` par `body`.
395 +pub fn upsert(content: &str, id: &str, body: &str) -> String {
396 + String::new() // remplacé au Step 4
397 +}
398 +
399 +#[cfg(test)]
400 +mod tests {
401 + use super::*;
402 +
403 + #[test]
404 + fn block_encadre_le_corps() {
405 + let b = block("demo:x", "corps\n");
406 + assert_eq!(b, "<!-- demo:x -->\ncorps\n<!-- /demo:x -->");
407 + }
408 +
409 + #[test]
410 + fn upsert_ajoute_a_la_fin_si_absent() {
411 + let out = upsert("# Titre\n", "demo:x", "corps");
412 + assert!(out.starts_with("# Titre\n"));
413 + assert!(out.contains("<!-- demo:x -->\ncorps\n<!-- /demo:x -->"));
414 + }
415 +
416 + #[test]
417 + fn upsert_remplace_en_place_et_est_idempotent() {
418 + let base = format!("A\n\n{}\n\nB\n", block("demo:x", "v1"));
419 + let once = upsert(&base, "demo:x", "v2");
420 + assert!(once.contains("A\n"), "préfixe préservé");
421 + assert!(once.contains("B\n"), "suffixe préservé");
422 + assert!(once.contains("v2"));
423 + assert!(!once.contains("v1"), "ancien corps remplacé");
424 + // idempotence : ré-appliquer avec le même corps ne change rien.
425 + assert_eq!(upsert(&once, "demo:x", "v2"), once);
426 + }
427 +}
428 +```
429 +
430 +- [ ] **Step 3: Lancer — échoue**
431 +
432 +Run: `cargo test -p rustwarden-core --bin screen-select -- section::tests`
433 +Expected: FAIL (les fonctions renvoient une chaîne vide).
434 +
435 +- [ ] **Step 4: Implémenter**
436 +
437 +Remplacer les deux fonctions par :
438 +
439 +```rust
440 +pub fn block(id: &str, body: &str) -> String {
441 + format!("<!-- {id} -->\n{}\n<!-- /{id} -->", body.trim_end())
442 +}
443 +
444 +pub fn upsert(content: &str, id: &str, body: &str) -> String {
445 + let block = block(id, body);
446 + let open = format!("<!-- {id} -->");
447 + let close = format!("<!-- /{id} -->");
448 + if let (Some(start), Some(close_at)) = (content.find(&open), content.find(&close)) {
449 + if start < close_at {
450 + let end = close_at + close.len();
451 + let mut out = String::with_capacity(content.len() + block.len());
452 + out.push_str(&content[..start]);
453 + out.push_str(&block);
454 + out.push_str(&content[end..]);
455 + return out;
456 + }
457 + }
458 + let mut out = content.to_string();
459 + if !out.ends_with('\n') {
460 + out.push('\n');
461 + }
462 + out.push('\n');
463 + out.push_str(&block);
464 + out.push('\n');
465 + out
466 +}
467 +```
468 +
469 +Note : `close` étant préfixé par `<!-- /`, `content.find(&open)` peut aussi matcher le début de `close` ? Non : `open` = `<!-- id -->`, `close` = `<!-- /id -->` (le `/` après l'espace diffère), donc `find(&open)` ne tombe jamais sur le marqueur de fermeture.
470 +
471 +- [ ] **Step 5: Lancer — passe**
472 +
473 +Run: `cargo test -p rustwarden-core --bin screen-select -- section::tests`
474 +Expected: PASS (3 tests).
475 +
476 +- [ ] **Step 6: Commit (checkpoint)**
477 +
478 +```bash
479 +git add crates/rustwarden-core/src/bin/screen-select/section.rs crates/rustwarden-core/src/bin/screen-select/main.rs
480 +git commit -m "feat(screen-select): sections Markdown idempotentes à marqueurs"
481 +```
482 +
483 +---
484 +
485 +## Task 3: Module `security` — contenu de sécurité
486 +
487 +**Files:**
488 +- Create: `crates/rustwarden-core/src/bin/screen-select/security.rs`
489 +- Modify: `crates/rustwarden-core/src/bin/screen-select/main.rs` (`mod security;`)
490 +
491 +- [ ] **Step 1: Déclarer le module**
492 +
493 +Dans `main.rs`, ajouter `mod security;` au groupe des `mod` (après `mod section;`).
494 +
495 +- [ ] **Step 2: Écrire `security.rs` avec tests (rouge)**
496 +
497 +Créer `security.rs` :
498 +
499 +```rust
500 +//! Contenu de sécurité injecté dans le plan : garde-fous transversaux (statiques)
501 +//! et contrat de sécurité par écran (dérivé du catalogue).
502 +
503 +use crate::catalogue::{Channel, Functionality};
504 +
505 +/// Identifiant de section pour le bloc statique (transversal + conventions).
506 +pub const GUIDES_ID: &str = "screen-select:security-guides";
507 +/// Identifiant de section pour le contrat de sécurité par écran.
508 +pub const PER_SCREEN_ID: &str = "screen-select:security-per-screen";
509 +
510 +/// Corps statique : sécurité transversale (obligatoire, hors écran) + conventions
511 +/// de rendu sûr. Indépendant de la sélection.
512 +pub fn guides_body() -> String {
513 + String::new() // remplacé au Step 4
514 +}
515 +
516 +/// Corps dérivé du catalogue : un paragraphe par écran des fonctionnalités
517 +/// sélectionnées (niveau d'auth, CSRF si browser, pièges spécifiques).
518 +pub fn reference_body(selected: &[&'static Functionality]) -> String {
519 + let _ = Channel::Browser; // remplacé au Step 4
520 + String::new()
521 +}
522 +
523 +#[cfg(test)]
524 +mod tests {
525 + use super::*;
526 + use crate::catalogue;
527 +
528 + #[test]
529 + fn guides_couvre_les_axes_transversaux() {
530 + let g = guides_body();
531 + assert!(g.contains("Sécurité transversale"));
532 + assert!(g.contains("Content-Security-Policy"));
533 + assert!(g.contains("X-Frame-Options"));
534 + assert!(g.contains("ADMIN_PASSWORD"));
535 + assert!(g.contains("HMAC"));
536 + assert!(g.contains("Conventions de rendu sûr"));
537 + assert!(g.contains("textContent"));
538 + }
539 +
540 + #[test]
541 + fn reference_rend_le_contrat_par_ecran() {
542 + let two_factor = catalogue::by_key("two_factor").unwrap();
543 + let r = reference_body(&[two_factor]);
544 + assert!(r.contains("## Contrat de sécurité par écran"));
545 + assert!(r.contains("### TwoFactorSetup (`/settings/2fa`) — Authentifié"));
546 + // canal browser ⇒ rappel CSRF automatique
547 + assert!(r.contains("CSRF"));
548 + // piège spécifique repris du catalogue
549 + assert!(r.to_lowercase().contains("rawquery"));
550 + }
551 +
552 + #[test]
553 + fn reference_marque_le_niveau_admin() {
554 + let admin = catalogue::by_key("admin_users").unwrap();
555 + let r = reference_body(&[admin]);
556 + assert!(r.contains("— Administrateur"));
557 + assert!(r.contains("ensure_admin"));
558 + }
559 +}
560 +```
561 +
562 +- [ ] **Step 3: Lancer — échoue**
563 +
564 +Run: `cargo test -p rustwarden-core --bin screen-select -- security::tests`
565 +Expected: FAIL (corps vides).
566 +
567 +- [ ] **Step 4: Implémenter**
568 +
569 +Remplacer les deux fonctions par :
570 +
571 +```rust
572 +pub fn guides_body() -> String {
573 + let mut s = String::new();
574 + s.push_str("## Sécurité transversale (obligatoire, hors écran)\n\n");
575 + s.push_str("- [ ] Middleware de headers sur TOUTES les réponses : `Content-Security-Policy` (viser sans `unsafe-inline`/`unsafe-eval`), `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`.\n");
576 + s.push_str("- [ ] Validation de configuration au démarrage : refuser `ADMIN_PASSWORD=password` ; avertir si `APP_DEBUG=true`, `COOKIE_SECURE=false` ou `RUST_LOG=debug` en profil production ; secrets uniquement via variables d'environnement.\n");
577 + s.push_str("- [ ] Cookies de session : `HttpOnly`, `Secure` en production, `SameSite`.\n");
578 + s.push_str("- [ ] `cargo audit` régulier ; confirmer que le JWT est signé en **HMAC** (HS256/384/512), pas RSA (neutralise RUSTSEC-2023-0071) ; `accept_invalid_certs` SMTP/IMAP à `false` en production.\n\n");
579 + s.push_str("## Conventions de rendu sûr\n\n");
580 + s.push_str("- **Escaping contextuel** : ne jamais interpoler une valeur contrôlée par l'utilisateur dans un contexte JavaScript (`<script>`). L'échappement HTML d'Askama n'échappe PAS le contexte JS (le backslash notamment). Passer la valeur par un attribut `data-*` et la lire via `dataset`.\n");
581 + s.push_str("- Données dynamiques d'API côté client : `textContent`, jamais `innerHTML`.\n");
582 + s.push_str("- URLs : `encodeURIComponent` / helper `urlenc()` du core ; aucune donnée personnelle en query string.\n");
583 + s.push_str("- Aucun filtre `|safe` Askama sur une valeur contrôlée par l'utilisateur.\n");
584 + s
585 +}
586 +
587 +pub fn reference_body(selected: &[&'static Functionality]) -> String {
588 + let mut s = String::new();
589 + s.push_str("## Contrat de sécurité par écran\n\n");
590 + for f in selected {
591 + for sc in f.screens {
592 + s.push_str(&format!(
593 + "### {} (`{}`) — {}\n\n",
594 + sc.name,
595 + sc.suggested_route,
596 + sc.auth_level.label()
597 + ));
598 + if matches!(sc.channel, Channel::Browser) {
599 + s.push_str("- Canal *browser* ⇒ valider le token CSRF sur chaque `POST` (`validate_csrf_token`).\n");
600 + }
601 + for note in sc.security_notes {
602 + s.push_str(&format!("- {note}\n"));
603 + }
604 + s.push('\n');
605 + }
606 + }
607 + s
608 +}
609 +```
610 +
611 +- [ ] **Step 5: Lancer — passe**
612 +
613 +Run: `cargo test -p rustwarden-core --bin screen-select -- security::tests`
614 +Expected: PASS (3 tests).
615 +
616 +- [ ] **Step 6: Commit (checkpoint)**
617 +
618 +```bash
619 +git add crates/rustwarden-core/src/bin/screen-select/security.rs crates/rustwarden-core/src/bin/screen-select/main.rs
620 +git commit -m "feat(screen-select): contenu de sécurité (transversal + contrat par écran)"
621 +```
622 +
623 +---
624 +
625 +## Task 4: Tisser les sections dans `generate::render_plan`
626 +
627 +**Files:**
628 +- Modify: `crates/rustwarden-core/src/bin/screen-select/generate.rs`
629 +
630 +- [ ] **Step 1: Mettre à jour le test de rendu existant + ajouter le test des sections (rouge)**
631 +
632 +Dans `generate.rs`, module `tests`, remplacer le test `render_plan_inclut_charte_et_tableau` par une version qui compile avec la nouvelle API et vérifie aussi les sections. Remplacer :
633 +
634 +```rust
635 + #[test]
636 + fn render_plan_inclut_charte_et_tableau() {
637 + let auth = catalogue::by_key("authentication").unwrap();
638 + let inputs = PlanInputs {
639 + project: "demo",
640 + selected: vec![auth],
641 + design_md: Some("Police: Inter. Couleur: #123456".into()),
642 + layout: Some("navbar: gauche; 2 colonnes".into()),
643 + };
644 + let rows = rows_for(&inputs.selected);
645 + let out = render_plan(&inputs, &rows);
646 + assert!(out.contains("# Plan UI — demo"));
647 + assert!(out.contains("Police: Inter"));
648 + assert!(out.contains("navbar: gauche"));
649 + assert!(out.contains("| Statut | Fonctionnalité |"));
650 + assert!(out.contains("Login"));
651 + }
652 +```
653 +
654 +par :
655 +
656 +```rust
657 + #[test]
658 + fn render_plan_inclut_charte_et_tableau() {
659 + let auth = catalogue::by_key("authentication").unwrap();
660 + let inputs = PlanInputs {
661 + project: "demo",
662 + selected: vec![auth],
663 + design_md: Some("Police: Inter. Couleur: #123456".into()),
664 + layout: Some("navbar: gauche; 2 colonnes".into()),
665 + };
666 + let rows = rows_for(&inputs.selected);
667 + let out = render_plan(&inputs, &rows);
668 + assert!(out.contains("# Plan UI — demo"));
669 + assert!(out.contains("Police: Inter"));
670 + assert!(out.contains("navbar: gauche"));
671 + assert!(out.contains("| Statut | Fonctionnalité |"));
672 + assert!(out.contains("Login"));
673 + }
674 +
675 + #[test]
676 + fn rows_for_derive_protected_du_niveau_auth() {
677 + // Login est Public ⇒ non protégé ; TwoFactorSetup est Authentifié ⇒ protégé.
678 + let auth = catalogue::by_key("authentication").unwrap();
679 + let tf = catalogue::by_key("two_factor").unwrap();
680 + let login = &rows_for(&[auth])[0];
681 + assert!(!login.protected);
682 + let setup = rows_for(&[tf]).into_iter().find(|r| r.screen == "TwoFactorSetup").unwrap();
683 + assert!(setup.protected);
684 + }
685 +
686 + #[test]
687 + fn render_plan_inclut_les_sections_securite() {
688 + let auth = catalogue::by_key("authentication").unwrap();
689 + let inputs = PlanInputs { project: "demo", selected: vec![auth], design_md: None, layout: None };
690 + let rows = rows_for(&inputs.selected);
691 + let out = render_plan(&inputs, &rows);
692 + assert!(out.contains("<!-- screen-select:security-guides -->"));
693 + assert!(out.contains("Sécurité transversale"));
694 + assert!(out.contains("Conventions de rendu sûr"));
695 + assert!(out.contains("<!-- screen-select:security-per-screen -->"));
696 + assert!(out.contains("## Contrat de sécurité par écran"));
697 + assert!(out.contains("### Login (`/login`) — Public"));
698 + // le bloc statique doit précéder le tableau (vu en premier par le dev)
699 + let guides = out.find("Sécurité transversale").unwrap();
700 + let table = out.find("| Statut").unwrap();
701 + assert!(guides < table, "les garde-fous transversaux doivent précéder le tableau");
702 + }
703 +```
704 +
705 +- [ ] **Step 2: Lancer — échoue (compilation)**
706 +
707 +Run: `cargo test -p rustwarden-core --bin screen-select -- generate::tests`
708 +Expected: FAIL — `render_plan` n'émet pas encore les sections (et `rows_for` référence encore `s.protected`).
709 +
710 +- [ ] **Step 3: Adapter `rows_for` (dériver `protected`) et importer les modules**
711 +
712 +En tête de `generate.rs`, sous le `use` existant, ajouter :
713 +
714 +```rust
715 +use crate::{section, security};
716 +```
717 +
718 +Dans `rows_for`, remplacer la ligne :
719 +
720 +```rust
721 + protected: s.protected,
722 +```
723 +
724 +par :
725 +
726 +```rust
727 + protected: s.auth_level.is_protected(),
728 +```
729 +
730 +- [ ] **Step 4: Émettre les sections dans `render_plan`**
731 +
732 +Remplacer intégralement la fonction `render_plan` par :
733 +
734 +```rust
735 +pub fn render_plan(inputs: &PlanInputs, rows: &[TodoRow]) -> String {
736 + let mut out = String::new();
737 + out.push_str(&format!("# Plan UI — {}\n\n", inputs.project));
738 + out.push_str("> Généré par `cargo run -p rustwarden-core --bin screen-select`. Recochez le réalisé via `cargo run -p rustwarden-core --bin screen-select -- --check`.\n\n");
739 + // Garde-fous transversaux + conventions de rendu sûr (statique, vu en premier).
740 + out.push_str(&section::block(security::GUIDES_ID, &security::guides_body()));
741 + out.push_str("\n\n");
742 + if let Some(design) = &inputs.design_md {
743 + out.push_str("## Charte graphique (à respecter)\n\n");
744 + out.push_str(design.trim());
745 + out.push_str("\n\n");
746 + }
747 + if let Some(layout) = &inputs.layout {
748 + out.push_str("## Structure de page\n\n");
749 + out.push_str(layout.trim());
750 + out.push_str("\n\n");
751 + }
752 + out.push_str("## Écrans à coder\n\n");
753 + out.push_str(&todo::render(rows));
754 + out.push('\n');
755 + // Contrat de sécurité par écran (dérivé du catalogue, en référence sous le tableau).
756 + out.push_str(&section::block(security::PER_SCREEN_ID, &security::reference_body(&inputs.selected)));
757 + out.push('\n');
758 + out
759 +}
760 +```
761 +
762 +- [ ] **Step 5: Lancer — passe**
763 +
764 +Run: `cargo test -p rustwarden-core --bin screen-select -- generate::tests`
765 +Expected: PASS (tous les tests `generate`, dont les 2 nouveaux).
766 +
767 +- [ ] **Step 6: Commit (checkpoint)**
768 +
769 +```bash
770 +git add crates/rustwarden-core/src/bin/screen-select/generate.rs
771 +git commit -m "feat(screen-select): tissage des sections sécurité dans le plan"
772 +```
773 +
774 +---
775 +
776 +## Task 5: Re-run wizard — régénérer les sections + vérif e2e
777 +
778 +**Files:**
779 +- Modify: `crates/rustwarden-core/src/bin/screen-select/main.rs`
780 +
781 +- [ ] **Step 1: Régénérer les sections sécurité au re-run**
782 +
783 +Dans `run_wizard` de `main.rs`, localiser le bloc de fusion (plan existant) :
784 +
785 +```rust
786 + let path = Path::new(PLAN_PATH);
787 + let content = if path.exists() {
788 + let existing = std::fs::read_to_string(path)?;
789 + let prev = todo::parse(&existing);
790 + let merged = todo::merge(&prev, &desired);
791 + // conserve l'en-tête existant, remplace le tableau
792 + todo::replace_table(&existing, &merged)
793 + } else {
794 + generate::render_plan(&inputs, &desired)
795 + };
796 +```
797 +
798 +et le remplacer par :
799 +
800 +```rust
801 + let path = Path::new(PLAN_PATH);
802 + let content = if path.exists() {
803 + let existing = std::fs::read_to_string(path)?;
804 + let prev = todo::parse(&existing);
805 + let merged = todo::merge(&prev, &desired);
806 + // conserve l'en-tête existant, remplace le tableau…
807 + let mut c = todo::replace_table(&existing, &merged);
808 + // …puis régénère les sections sécurité (idempotent, suit la sélection courante).
809 + c = section::upsert(&c, security::GUIDES_ID, &security::guides_body());
810 + c = section::upsert(&c, security::PER_SCREEN_ID, &security::reference_body(&inputs.selected));
811 + c
812 + } else {
813 + generate::render_plan(&inputs, &desired)
814 + };
815 +```
816 +
817 +- [ ] **Step 2: Compiler + clippy**
818 +
819 +Run: `cargo clippy -p rustwarden-core --bin screen-select -- -D warnings`
820 +Expected: 0 warning.
821 +
822 +- [ ] **Step 3: Smoke test — génération neuve porte les sections**
823 +
824 +Run (depuis la racine du dépôt, dans un dossier jetable) :
825 +```bash
826 +tmp="$(mktemp -d)"; cd "$tmp"
827 +printf '4\n\n\n' | cargo run -p rustwarden-core --bin screen-select --manifest-path "$OLDPWD/crates/rustwarden-core/Cargo.toml"
828 +grep -q "Sécurité transversale" docs/plans/ui-screens.md && echo "OK transversal"
829 +grep -q "Contrat de sécurité par écran" docs/plans/ui-screens.md && echo "OK par écran"
830 +```
831 +Expected : les deux `echo` s'affichent ; le plan contient le bloc garde-fous et le contrat par écran (choix `4` = PasswordReset → écrans `ForgotPassword`/`ResetPassword`, dont la note « data-* » sur le token). *(L'ordre exact des fonctionnalités choisissables est celui affiché par le wizard ; ajuster le numéro si besoin.)*
832 +
833 +- [ ] **Step 4: Vérification e2e — `--check` n'altère pas les sections sécurité**
834 +
835 +```bash
836 +cd "$tmp"
837 +# simule l'implémentation d'un écran à sa route par défaut
838 +mkdir -p src && printf 'fn r(){ .route("/reset-password", get(h)); }\n' > src/ui.rs
839 +printf 'x\n' | cargo run -p rustwarden-core --bin screen-select --manifest-path "$OLDPWD/crates/rustwarden-core/Cargo.toml" -- --check
840 +grep -q "Contrat de sécurité par écran" docs/plans/ui-screens.md && echo "sections préservées"
841 +grep -q "| ☑ |" docs/plans/ui-screens.md && echo "route recochée"
842 +```
843 +Expected : `ResetPassword` passe à ☑ dans le tableau **et** la section « Contrat de sécurité par écran » (+ garde-fous transversaux) est toujours présente intacte — `--check` ne touche que le tableau.
844 +
845 +- [ ] **Step 5: Commit (checkpoint)**
846 +
847 +```bash
848 +git add crates/rustwarden-core/src/bin/screen-select/main.rs
849 +git commit -m "feat(screen-select): re-run régénère les sections sécurité"
850 +```
851 +
852 +---
853 +
854 +## Task 6: Documentation + vérification finale
855 +
856 +**Files:**
857 +- Modify: `docs/SERVICES_REFERENCE.md` (§22, section `screen-select`)
858 +
859 +- [ ] **Step 1: Documenter les sections sécurité dans §22**
860 +
861 +Repérer la section 22 (« screen-select ») de `docs/SERVICES_REFERENCE.md` et, à la fin de cette section, ajouter le sous-bloc suivant :
862 +
863 +```markdown
864 +#### Sections de sécurité du plan
865 +
866 +Le plan généré porte, en plus du tableau des écrans :
867 +
868 +- **Sécurité transversale (obligatoire, hors écran)** — garde-fous globaux à
869 + câbler une fois : middleware de headers (CSP, `X-Frame-Options`,
870 + `X-Content-Type-Options`, `Referrer-Policy`), validation de configuration au
871 + démarrage (refus de `ADMIN_PASSWORD=password`, alerte `APP_DEBUG`/`COOKIE_SECURE`),
872 + cookies `HttpOnly`/`Secure`/`SameSite`, `cargo audit` + JWT en HMAC.
873 +- **Conventions de rendu sûr** — escaping contextuel (jamais de valeur utilisateur
874 + dans un `<script>` : `data-*` + `dataset`), `textContent` plutôt qu'`innerHTML`,
875 + `encodeURIComponent`/`urlenc()`, pas de filtre `|safe`.
876 +- **Contrat de sécurité par écran** — pour chaque écran sélectionné : niveau
877 + d'autorisation (Public / Authentifié / Propriétaire de ressource / Administrateur),
878 + rappel CSRF automatique pour les écrans *browser*, et pièges spécifiques
879 + (IDOR/ownership, `ensure_admin()`, anti-énumération, token de reset hors contexte JS…).
880 +
881 +Ces sections sont délimitées par des marqueurs HTML
882 +(`<!-- screen-select:security-guides -->`, `<!-- screen-select:security-per-screen -->`)
883 +et régénérées à chaque exécution du wizard. `--check` ne modifie que le tableau :
884 +les sections de sécurité sont préservées telles quelles.
885 +```
886 +
887 +- [ ] **Step 2: Suite de tests complète du binaire**
888 +
889 +Run: `cargo test -p rustwarden-core --bin screen-select`
890 +Expected: PASS (catalogue + section + security + todo + generate + check).
891 +
892 +- [ ] **Step 3: Vérification workspace**
893 +
894 +Run: `cargo clippy --workspace -- -D warnings && cargo test -p rustwarden-core --bin screen-select`
895 +Expected: 0 warning ; tous les tests du binaire verts.
896 +
897 +- [ ] **Step 4: Commit (checkpoint)**
898 +
899 +```bash
900 +git add docs/SERVICES_REFERENCE.md
901 +git commit -m "docs(screen-select): sections de sécurité du plan (§22)"
902 +```
903 +
904 +---
905 +
906 +## Couverture spec ↔ findings de l'audit
907 +
908 +| Finding audit | Où c'est adressé dans le plan généré |
909 +|---|---|
910 +| #1 XSS token en contexte JS | Note `ResetPassword` (`data-*` + `dataset`) + section « Conventions de rendu sûr » |
911 +| #2 Open redirect `?redirect=` | Note `Login` (chemin relatif same-origin) |
912 +| #3 `.env` dangereux | Section transversale (validation config au démarrage) |
913 +| #4 `RawQuery` pass-through | Note `TwoFactorSetup` (whitelist des params) |
914 +| #5 RSA Marvin (RUSTSEC-2023-0071) | Section transversale (`cargo audit` + confirmer JWT HMAC) |
915 +| #6 Headers manquants (CSP, X-Frame-Options…) | Section transversale (middleware headers) |
916 +| #7 Énumération username (admin/teams) | Notes `AdminUsers` + `TeamsManagement` (message générique) |
917 +| Positive: CSRF POST browser | Rappel CSRF auto pour tout écran *browser* |
918 +| Positive: IDOR sessions/tokens/teams | Niveau `ResourceOwner` + notes IDOR (garde-fou testé) |
919 +| Positive: `ensure_admin()` défense en profondeur | Niveau `Admin` + note `ensure_admin()` (garde-fou testé) |
920 +| Positive: anti-énumération flux publics | Notes `ForgotPassword`/`ResendVerification`/`Register` |
921 +| Positive: `textContent`/`encodeURIComponent` | Note `OAuthAccountLinking` + section « Conventions » |
922 +
923 +**Hors périmètre (assumé) :** correctifs upstream des dépendances du core (#5 `rsa`, #8 `lexical-core`) — le plan génère seulement le rappel de vérification, pas le fix ; durcissement effectif des valeurs `.env` (dépend du déploiement).
924 +
925 +---
926 +
927 +## Suivi post-implémentation (hors code)
928 +
929 +- [ ] Vérifier que `update-project` propage `security.rs` et `section.rs` (il resynchronise les fichiers présents de la crate — les nouveaux modules bin voyagent avec).
930 +- [ ] Envisager d'aligner la référence `routes-ecrans-sans-ui.md` (skill `rustwarden-boilerplate`) pour pointer le contrat de sécurité comme partie intégrante du plan.
M tools/rustwarden-cli/Cargo.toml
+1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "rustwarden-cli"
3 -version = "0.1.0"
3 +version = "1.0.0"
4 4 edition.workspace = true
5 5 rust-version.workspace = true
6 6 description = "CLI wizard to scaffold new Rustwarden applications"
M tools/rustwarden-cli/src/start_project.rs
+13 -1
@@ -849,11 +849,23 @@ fn print_summary(cfg: &ProjectConfig, project_dir: &Path) {
849 849 println!(" {} Lancer l'application :", style("3.").cyan().bold());
850 850 println!(" {}", style("cargo run").green());
851 851 println!(
852 - " {} Ouvrir dans le navigateur :",
852 + " {} Générer la liste des écrans d'UI à coder :",
853 853 style("4.").cyan().bold()
854 854 );
855 855 println!(
856 856 " {}",
857 + style("cargo run -p rustwarden-core --bin screen-select").green()
858 + );
859 + println!(
860 + " puis {} pour recocher le réalisé",
861 + style("cargo run -p rustwarden-core --bin screen-select -- --check").green()
862 + );
863 + println!(
864 + " {} Ouvrir dans le navigateur :",
865 + style("5.").cyan().bold()
866 + );
867 + println!(
868 + " {}",
857 869 style(format!("http://localhost:{}", cfg.port)).underlined()
858 870 );
859 871
M tools/rustwarden-cli/src/templates.rs
+50 -8
@@ -187,6 +187,7 @@ tracing = "0.1"
187 187 anyhow = "1.0"
188 188 async-trait = "0.1"
189 189 uuid = {{ version = "1.0", features = ["v4"] }}
190 +serde_json = "1.0"
190 191
191 192 [lints.clippy]
192 193 empty_line_after_doc_comments = "allow"
@@ -209,7 +210,11 @@ pub fn main_rs(cfg: &ProjectConfig) -> String {
209 210 // `/api/v1/*` JSON API rides the machine channel (no CSRF).
210 211 " .with_ui(rustwarden_ui::browser_routes)\n .merge_machine_routes(rustwarden_ui::api_routes())\n"
211 212 } else {
212 - " .headless()\n"
213 + // Headless: mount the framework's built-in JSON auth API so the
214 + // project ships a complete, working authentication surface
215 + // (login, refresh, logout, me, 2FA verify, ...) on the machine
216 + // channel (Bearer/JSON, not CSRF-vulnerable).
217 + " .headless()\n .merge_machine_routes(rustwarden_core::auth::auth_api_routes())\n"
213 218 };
214 219
215 220 let migrate_line = if cfg.auto_migrate {
@@ -321,12 +326,27 @@ async fn main() -> anyhow::Result<()> {{
321 326 fn app_routes() -> Router<DatabaseConnection> {{
322 327 Router::new()
323 328 .route("/api/hello", get(hello))
329 + .route("/api/whoami", get(whoami))
324 330 }}
325 331
326 332 async fn hello() -> &'static str {{
327 333 "Hello from {name}!"
328 334 }}
329 335
336 +/// Example protected endpoint. The `AuthUser` extractor validates the JWT
337 +/// (`Authorization: Bearer <token>` or the auth cookie) before this handler
338 +/// runs — unauthenticated requests get `401`. Obtain a token via
339 +/// `POST /api/v1/auth/login`.
340 +async fn whoami(
341 + user: rustwarden_core::auth::extractors::AuthUser,
342 +) -> axum::Json<serde_json::Value> {{
343 + axum::Json(serde_json::json!({{
344 + "user_id": user.user_id,
345 + "username": user.username,
346 + "roles": user.roles,
347 + }}))
348 +}}
349 +
330 350 // ----------------------------------------------------------------------------
331 351 // Primitives framework optionnelles (Teams, PAT, SSE, Hooks)
332 352 //
@@ -782,14 +802,36 @@ fn app_routes() -> Router<DatabaseConnection> {{
782 802 }}
783 803 ```
784 804
805 +## Planifier l'interface (screen-select)
806 +
807 +Le binaire `screen-select` genere un plan Markdown (`docs/plans/ui-screens.md`) des
808 +ecrans d'interface a coder selon les fonctionnalites activees. Pour chaque ecran il
809 +precise le niveau d'autorisation (Public / Authentifie / Proprietaire de ressource /
810 +Admin) et les points de securite a respecter (CSRF, IDOR, anti-enumeration, token de
811 +reset hors contexte JS...), plus deux sections transversales (headers de securite,
812 +conventions de rendu sur). Utile surtout pour une UI custom ou en mode API seule.
813 +
814 +```bash
815 +# Generer / completer le plan des ecrans a coder
816 +cargo run -p rustwarden-core --bin screen-select
817 +
818 +# Recocher automatiquement les ecrans deja cables (analyse des routes)
819 +cargo run -p rustwarden-core --bin screen-select -- --check
820 +```
821 +
822 +Codez ces ecrans dans une crate dediee `{name}-ui` consommant `rustwarden-core`,
823 +montee dans le routeur principal.
824 +
785 825 ## Commandes utiles
786 826
787 827 | Commande | Description |
788 828 |----------|-------------|
789 829 | `cargo run` | Lancer l'application |
790 -| `cargo run --bin migrate` | Executer les migrations manuellement |
791 -| `cargo run --bin seed` | Creer l'utilisateur admin |
792 -| `cargo run --bin hash_password` | Hasher un mot de passe |
830 +| `cargo run -p rustwarden-core --bin migrate` | Executer les migrations manuellement |
831 +| `cargo run -p rustwarden-core --bin seed` | Creer l'utilisateur admin |
832 +| `cargo run -p rustwarden-core --bin hash_password` | Hasher un mot de passe |
833 +| `cargo run -p rustwarden-core --bin screen-select` | Planifier les ecrans d'UI a coder |
834 +| `cargo run -p rustwarden-core --bin screen-select -- --check` | Recocher les ecrans deja implementes |
793 835 "#,
794 836 name = cfg.name,
795 837 description = cfg.description,
@@ -1275,10 +1317,10 @@ RustwardenApp::builder()
1275 1317 | Commande | Description |
1276 1318 |----------|-------------|
1277 1319 | `cargo run` | Lancer l'application |
1278 -| `cargo run --bin migrate` | Executer les migrations manuellement |
1279 -| `cargo run --bin seed` | Creer/mettre a jour l'admin initial |
1280 -| `cargo run --bin hash_password` | Hasher un mot de passe bcrypt |
1281 -| `cargo run --bin change_password` | Changer le mot de passe d'un utilisateur |
1320 +| `cargo run -p rustwarden-core --bin migrate` | Executer les migrations manuellement |
1321 +| `cargo run -p rustwarden-core --bin seed` | Creer/mettre a jour l'admin initial |
1322 +| `cargo run -p rustwarden-core --bin hash_password` | Hasher un mot de passe bcrypt |
1323 +| `cargo run -p rustwarden-core --bin change_password` | Changer le mot de passe d'un utilisateur |
1282 1324 | `cargo test` | Lancer les tests |
1283 1325 | `cargo build --release` | Build optimise pour la production |
1284 1326
M tools/rustwarden-cli/src/update_project.rs
+14 -2
@@ -490,10 +490,19 @@ fn print_summary(
490 490 );
491 491 println!(" {}", style("cargo clippy -- -D warnings").green());
492 492 println!(" {} Verifier les migrations :", style("3.").cyan().bold());
493 - println!(" {}", style("cargo run --bin migrate").green());
493 + println!(
494 + " {}",
495 + style("cargo run -p rustwarden-core --bin migrate").green()
496 + );
494 497 println!(" {} Lancer les tests :", style("4.").cyan().bold());
495 498 println!(" {}", style("cargo test").green());
496 499 println!();
500 + println!(
501 + " • Nouveaux écrans éventuels : {} (puis {})",
502 + style("cargo run -p rustwarden-core --bin screen-select").green(),
503 + style("-- --check").green()
504 + );
505 + println!();
497 506
498 507 if !newly_added.is_empty() {
499 508 let divider = style("─".repeat(55)).dim();
@@ -567,7 +576,10 @@ fn print_summary(
567 576 " {} Appliquer les migrations fediverse :",
568 577 style("d)").cyan()
569 578 );
570 - println!(" {}", style("cargo run --bin migrate").green());
579 + println!(
580 + " {}",
581 + style("cargo run -p rustwarden-core --bin migrate").green()
582 + );
571 583 println!();
572 584 println!(
573 585 " {} Guide complet : {}",