diff --git a/.woodpecker/app.yml b/.woodpecker/app.yml index 0025906..e9cdde9 100644 --- a/.woodpecker/app.yml +++ b/.woodpecker/app.yml @@ -8,11 +8,9 @@ when: - branch: main event: push -# La maquette libère d'abord la racine (elle part sur maquette.decision.librodrome.org) ; -# le produit se déploie ensuite, même si la maquette a échoué. -depends_on: - - maquette -runs_on: [success, failure] +# Le produit passe en premier : tant que son image n'est pas construite et testée, la +# maquette continue de servir la racine. La maquette (maquette.yml) attend ce workflow +# et ne part sur maquette.decision.librodrome.org qu'après un déploiement réussi. steps: @@ -102,15 +100,19 @@ steps: - DOMAIN=decision.librodrome.org - echo "APP_DOMAIN=$DOMAIN" > /opt/libredecision-app/.env - cp docker/app-compose.yml /opt/libredecision-app/docker-compose.yml - # Tout conteneur qui revendique encore la racine (maquette pas encore déplacée, - # ou en échec) est arrêté : la racine est au produit, et fabio partagerait le trafic. + # Tout conteneur, même arrêté, qui revendique encore la racine (la maquette avant son + # déplacement, d'anciennes stacks ehv-decision-main-*) est arrêté et sort de + # « restart: always » : sinon un redémarrage de Docker le ramènerait, et fabio lui + # donnerait /api/* (route la plus longue). Rien n'est supprimé ; la maquette est + # recréée par son propre workflow, sous son nouveau domaine. - | for TAG in "urlprefix-$DOMAIN/*" "urlprefix-$DOMAIN/api/*"; do - for C in $(docker ps -q --filter "label=SERVICE_3000_TAGS=$TAG") $(docker ps -q --filter "label=SERVICE_8002_TAGS=$TAG"); do + for C in $(docker ps -aq --filter "label=SERVICE_3000_TAGS=$TAG") $(docker ps -aq --filter "label=SERVICE_8002_TAGS=$TAG"); do NAME=$(docker inspect --format '{{.Name}}' "$C") [ "$NAME" = "/lpk-libredecision-app" ] && continue - echo "Arret de $NAME (revendique $TAG)" - docker stop "$C" + echo "Retrait de $NAME de la racine (revendique $TAG)" + docker update --restart=no "$C" >/dev/null + docker stop "$C" >/dev/null done done # Arrêter avant le challenge ACME : la route hôte /* masquerait le webroot de sonic-acme-1. diff --git a/.woodpecker/maquette.yml b/.woodpecker/maquette.yml index 85ca973..7a88cd6 100644 --- a/.woodpecker/maquette.yml +++ b/.woodpecker/maquette.yml @@ -1,12 +1,15 @@ # La maquette Nuxt v2 (FastAPI + Postgres + IPFS), conservée telle quelle pour # présenter le projet. Depuis la v3 elle vit sur maquette.decision.librodrome.org ; -# le produit (.woodpecker/app.yml) prend la racine decision.librodrome.org et -# attend la fin de ce workflow, qui libère la racine. +# le produit (.woodpecker/app.yml) prend la racine decision.librodrome.org. +# Ce workflow attend le produit : si le produit échoue, la maquette ne bouge pas. # Le nom de projet compose reste lpk-decision-main : mêmes volumes, aucune donnée perdue. when: - branch: main event: push +depends_on: + - app + steps: - name: security-check @@ -252,6 +255,6 @@ steps: - name: notify-failure image: alpine:3.20 commands: - - 'echo "ECHEC pipeline #$CI_BUILD_NUMBER sur $CI_COMMIT_BRANCH ($CI_COMMIT_SHA)"' + - 'echo "ECHEC pipeline #$CI_PIPELINE_NUMBER sur $CI_COMMIT_BRANCH ($CI_COMMIT_SHA)"' when: - status: failure diff --git a/CLAUDE.md b/CLAUDE.md index 4a806e0..b97f1b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ développer. - **Déploiement** : une image (`docker/app.Dockerfile`), Woodpecker `ci.open.us.org` dépôt LPK/decision → sonic (fabio). `.woodpecker/app.yml` = le produit sur **decision.librodrome.org** ; `.woodpecker/maquette.yml` = la maquette sur - **maquette.decision.librodrome.org** (le produit attend la maquette, qui libère la racine). + **maquette.decision.librodrome.org** (le produit passe en premier et retire de la racine tout conteneur qui la revendique ; la maquette attend un déploiement réussi du produit). Push sur `prod` (`gitea@git.open.us.org:EHV/decision.git`) branche `main` = déploiement. ## Structure @@ -50,7 +50,7 @@ web/src/ routes/ # / · connexion · invitation · groupes · g/[group] · g/[group]/docs/[doc] (lecteur, # panneau dans l'URL ?onglet=&article=) · acces (trustinWallet) · decisions/mandats/ # protocoles (à venir) -web/e2e/ # Playwright C1–C10 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092 +web/e2e/ # Playwright C1–C11 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092 docker/ # app.Dockerfile · app-compose.yml (sonic) .woodpecker/ # app.yml · maquette.yml docs/ # chantiers/ · adr/ · dev/BLUEPRINT-V2.md (doctrine de la maquette) · recherche/ @@ -71,7 +71,7 @@ maquette/ # v2 intacte (frontend Nuxt, backend FastAPI, docker, docs-v ```bash export PATH=$HOME/.cargo/bin:$PATH cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace -cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 11 contrôles +cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 12 contrôles LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build cd web && npm run dev # :3002 à chaud diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 33673ea..8ec4632 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,7 +34,7 @@ cd web npm run check # svelte-check, zéro avertissement npm test # vitest : markdown, parité des catalogues i18n, anti-lexique npm run build -npm run e2e # Playwright : C1–C10 (bureau) + M1 (mobile), serveur jetable sur :8092 +npm run e2e # Playwright : C1–C11 (bureau) + M1 (mobile), serveur jetable sur :8092 ``` L'image Docker rejoue les mêmes portes : un contrôle rouge, pas d'image, pas de déploiement. diff --git a/HANDOFF.md b/HANDOFF.md index 1d024ec..938d0a6 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -7,13 +7,19 @@ **Chantier 1 — Acte d'engagement Certification** : construit, boucle verte, prêt à livrer. - v3 Rust + Svelte à la racine du dépôt ; la maquette v2 intacte dans `maquette/`. -- Boucle : Rust (fmt, clippy `-D warnings`, 54 tests dont 13 scénarios d'API et la +- Boucle : Rust (fmt, clippy `-D warnings`, 57 tests dont 14 scénarios d'API et la publication vers un dépôt nu) · interface (svelte-check 0 avertissement, 9 vitest) · - e2e 11 / 11 (C1–C10, M1). L'image Docker rejoue tout et a été essayée localement : + e2e 12 / 12 (C1–C11, M1). L'image Docker rejoue tout et a été essayée localement : santé, interface, volume non-root, clé de publication, lien admin initial (0600, absent des journaux), publication immédiate au démarrage, redémarrage sans commit superflu. -- Déploiement : `.woodpecker/maquette.yml` (la maquette part sur - maquette.decision.librodrome.org) puis `.woodpecker/app.yml` (le produit à la racine). +- Déploiement : `.woodpecker/app.yml` d'abord (le produit à la racine, qui retire de la + racine tout conteneur qui la revendique), puis `.woodpecker/maquette.yml` (la maquette part + sur maquette.decision.librodrome.org, seulement si le produit a réussi). +- Relecture indépendante avant mise en ligne (`1f9c241 : ok`) ; ses points « à corriger + ensuite » ont été traités avant de livrer : ordre des workflows, anciens conteneurs en + `restart: always`, vote réservé aux membres de la toile dans Monnaie libre (une personne, + une voix), lien d'invitation jamais vers une identité Ğ1, réservation du lien avant usage, + un nombre n'est jamais une coquille, redirection après connexion, en-têtes anti-encadrement. ## Ce qui attend Yvv diff --git a/README.md b/README.md index c795efc..8a5e9eb 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings cd web && npm run check && npm test && npm run build && npm run e2e ``` -Les contrôles d'usage (C1–C10, M1) sont décrits dans +Les contrôles d'usage (C1–C11, M1) sont décrits dans [`docs/chantiers/acte-certification/CONTROLES.md`](docs/chantiers/acte-certification/CONTROLES.md) ; les tests de Chiara en sont la version humaine. @@ -101,7 +101,7 @@ seeds/ # documents de départ (JSON trilingue), instanciés une seu web/ src/lib/ # api, session, i18n (fr source, en, es), composants src/routes/ # accueil, groupes, lecteur du texte, trustinWallet (/acces)… - e2e/ # Playwright : C1–C10, M1 + e2e/ # Playwright : C1–C11, M1 docker/ # app.Dockerfile + app-compose.yml (sonic) .woodpecker/ # app.yml (le produit) · maquette.yml (la maquette) docs/ # chantiers/, adr/, dev/ (blueprint v2), recherche/ diff --git a/crates/ld-engine/src/edit.rs b/crates/ld-engine/src/edit.rs index c6882e3..146d876 100644 --- a/crates/ld-engine/src/edit.rs +++ b/crates/ld-engine/src/edit.rs @@ -48,6 +48,11 @@ fn skeleton(s: &str) -> String { fold(s).chars().filter(|c| c.is_alphanumeric()).collect() } +/// The numbers of a text, as written: "50" and "5,0", "1 5" and "15" differ. +fn numbers(s: &str) -> Vec<&str> { + s.split(|c: char| !c.is_ascii_digit()).filter(|n| !n.is_empty()).collect() +} + /// Folded words (runs of letters/digits). fn words(s: &str) -> Vec { fold(s).split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).map(str::to_owned).collect() @@ -57,6 +62,10 @@ pub fn classify(old: &str, new: &str) -> EditKind { if old == new { return EditKind::Unchanged; } + // A number is never a typo: punctuation or a space inside it changes its value. + if numbers(old) != numbers(new) { + return EditKind::Substantive; + } if skeleton(old) == skeleton(new) { return EditKind::Typographic; } @@ -198,6 +207,14 @@ mod tests { assert_eq!(classify("same", "same"), EditKind::Unchanged); } + #[test] + fn a_number_is_never_a_typo() { + // Same letters and digits, different values (independent review, 2026-10-21). + assert_eq!(classify("au moins 50 voix", "au moins 5,0 voix"), EditKind::Substantive); + assert_eq!(classify("1 5 jours", "15 jours"), EditKind::Substantive); + assert_eq!(classify("50 voix pour", "50 voix pour !"), EditKind::Typographic); + } + #[test] fn misspelt_words() { assert_eq!(classify("un certifieurs existant", "un certifieur existant"), EditKind::Typographic); diff --git a/crates/ld-server/src/access.rs b/crates/ld-server/src/access.rs index c3d7aa1..6bfa025 100644 --- a/crates/ld-server/src/access.rs +++ b/crates/ld-server/src/access.rs @@ -129,8 +129,12 @@ pub async fn invite( if label.is_empty() || label.chars().count() > 60 { return Err(AppError::Invalid("label")); } - if let Some(id) = &b.identity_id { - auth::load_identity(&state, id).await?; + // A new link for an existing person is for invitation identities only: a link + // to a Ğ1 identity would let an administrator open a session in its name. + if let Some(id) = &b.identity_id + && auth::load_identity(&state, id).await?.kind != "invite" + { + return Err(AppError::Invalid("identity_kind")); } let token = auth::create_invitation(&state, label, b.admin, &b.groups, Some(&user.id), b.identity_id.as_deref()).await?; diff --git a/crates/ld-server/src/auth.rs b/crates/ld-server/src/auth.rs index 8d8aadd..1cfb764 100644 --- a/crates/ld-server/src/auth.rs +++ b/crates/ld-server/src/auth.rs @@ -169,7 +169,16 @@ pub async fn me(Viewer(viewer): Viewer) -> Json { /// Only local paths, never `//host` nor a scheme. fn safe_return(raw: Option<&str>) -> String { match raw { - Some(p) if p.starts_with('/') && !p.starts_with("//") && !p.contains('\\') && p.len() < 512 => p.to_owned(), + // Browsers drop tabs and newlines in URLs: "/\t/evil" would become "//evil". + Some(p) + if p.starts_with('/') + && !p.starts_with("//") + && !p.contains('\\') + && !p.chars().any(char::is_control) + && p.len() < 512 => + { + p.to_owned() + } _ => "/groupes".to_owned(), } } @@ -324,18 +333,33 @@ struct InvitationRow { pub async fn accept_invitation(State(state): State, Json(body): Json) -> AppResult { let hash = util::sha256_hex(body.token.trim()); + let now = util::now(); + // Reserve the link first: of two simultaneous acceptances, one only goes on. + let reserved = + sqlx::query("UPDATE invitations SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?") + .bind(&now) + .bind(&hash) + .bind(&now) + .execute(&state.db) + .await?; + if reserved.rows_affected() != 1 { + return Err(AppError::Invalid("invitation_invalid")); + } let inv = sqlx::query_as::<_, InvitationRow>( - "SELECT token_hash, label, admin, groups, created_by, identity_id FROM invitations - WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?", + "SELECT token_hash, label, admin, groups, created_by, identity_id FROM invitations WHERE token_hash = ?", ) .bind(&hash) - .bind(util::now()) .fetch_optional(&state.db) .await? .ok_or(AppError::Invalid("invitation_invalid"))?; let identity_id = match inv.identity_id.clone() { - Some(existing) => existing, + Some(existing) => { + if load_identity(&state, &existing).await?.kind != "invite" { + return Err(AppError::Invalid("invitation_invalid")); + } + existing + } None => { let id = format!("inv:{}", util::id()); sqlx::query( @@ -355,16 +379,11 @@ pub async fn accept_invitation(State(state): State, Json(body): Json = serde_json::from_str(&inv.groups).unwrap_or_default(); grant_groups(&state, &identity_id, &groups, inv.created_by.as_deref()).await?; - let used = - sqlx::query("UPDATE invitations SET used_at = ?, identity_id = ? WHERE token_hash = ? AND used_at IS NULL") - .bind(util::now()) - .bind(&identity_id) - .bind(&inv.token_hash) - .execute(&state.db) - .await?; - if used.rows_affected() != 1 { - return Err(AppError::Invalid("invitation_invalid")); - } + sqlx::query("UPDATE invitations SET identity_id = ? WHERE token_hash = ?") + .bind(&identity_id) + .bind(&inv.token_hash) + .execute(&state.db) + .await?; let session = open_session(&state, &identity_id).await?; let me = load_identity(&state, &identity_id).await?; let mut res = Json(json!({ "me": me })).into_response(); diff --git a/crates/ld-server/src/docs.rs b/crates/ld-server/src/docs.rs index 934a14f..baadf6b 100644 --- a/crates/ld-server/src/docs.rs +++ b/crates/ld-server/src/docs.rs @@ -69,6 +69,12 @@ pub async fn require_open(state: &AppState, user: &IdentityRow, group: &GroupRow Ok(()) } +/// May this identity vote in the group (see `trustin_access::votes_in`)? +pub async fn may_vote(state: &AppState, user: &IdentityRow, group: &GroupRow) -> AppResult { + let has = granted(state, &group.slug, &user.id).await?; + Ok(trustin_access::votes_in(&user.to_identity(), &group.to_group(), has)) +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct GroupView { @@ -523,7 +529,9 @@ pub async fn document( .collect(); let publication = publication_status(&state, &id).await?; + let can_vote = may_vote(&state, &user, &st.group).await?; Ok(Json(json!({ + "canVote": can_vote, "doc": { "id": st.doc.id, "slug": st.doc.slug, "groupSlug": st.group.slug, "groupName": st.group.name, "title": util::parse_lang(&st.doc.title), "summary": util::parse_lang(&st.doc.summary), diff --git a/crates/ld-server/src/lib.rs b/crates/ld-server/src/lib.rs index a83cafe..67f480f 100644 --- a/crates/ld-server/src/lib.rs +++ b/crates/ld-server/src/lib.rs @@ -123,9 +123,20 @@ pub fn api_router() -> Router { } pub fn app(state: AppState) -> Router { - Router::new() - .nest("/api", api_router()) - .fallback_service(web::service(&state.cfg.web_dir)) + use axum::http::{HeaderName, HeaderValue, header}; + use tower_http::set_header::SetResponseHeaderLayer; + // Neighbouring sites under *.librodrome.org are same-site: none may frame us. + let security: [(HeaderName, &'static str); 4] = [ + (header::X_FRAME_OPTIONS, "DENY"), + (header::CONTENT_SECURITY_POLICY, "frame-ancestors 'none'"), + (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), + (header::REFERRER_POLICY, "same-origin"), + ]; + let mut router = Router::new().nest("/api", api_router()).fallback_service(web::service(&state.cfg.web_dir)); + for (name, value) in security { + router = router.layer(SetResponseHeaderLayer::if_not_present(name, HeaderValue::from_static(value))); + } + router .layer(tower_http::limit::RequestBodyLimitLayer::new(256 * 1024)) .layer(tower_http::compression::CompressionLayer::new()) .layer(tower_http::trace::TraceLayer::new_for_http()) diff --git a/crates/ld-server/src/proposals.rs b/crates/ld-server/src/proposals.rs index 6acbaf6..e799a5b 100644 --- a/crates/ld-server/src/proposals.rs +++ b/crates/ld-server/src/proposals.rs @@ -594,6 +594,10 @@ pub async fn vote( if !matches!(p.status.as_str(), "open" | "in_force") { return Err(AppError::Conflict("not_votable")); } + // Withdrawing one's vote is always possible; casting one follows trustinWallet. + if b.choice.is_some() && !docs::may_vote(&state, &user, &st.group).await? { + return Err(AppError::Conflict("wot_members_only")); + } match b.choice.as_deref() { Some(choice @ ("for" | "against")) => { sqlx::query( diff --git a/crates/ld-server/tests/api.rs b/crates/ld-server/tests/api.rs index 5946bdf..5a48a83 100644 --- a/crates/ld-server/tests/api.rs +++ b/crates/ld-server/tests/api.rs @@ -79,6 +79,9 @@ async fn anonymous_sees_nothing_and_csrf_is_enforced() { .expect("request"); let res = c.router.clone().oneshot(req).await.expect("response"); assert_eq!(res.status(), StatusCode::FORBIDDEN); + // No neighbour may frame the product. + assert_eq!(res.headers().get(header::X_FRAME_OPTIONS).map(|v| v.as_bytes()), Some(&b"DENY"[..])); + assert!(res.headers().contains_key(header::X_CONTENT_TYPE_OPTIONS)); } #[tokio::test] @@ -377,6 +380,38 @@ async fn access_management_is_for_administrators_only() { assert!(ov["identities"].as_array().expect("i").iter().any(|i| i["displayName"] == "Chiara")); } +#[tokio::test] +async fn in_monnaie_libre_only_web_of_trust_members_vote() { + let (mut admin, _) = Client::new().await; + admin.login("Yvv", true).await; + let (_, v) = admin + .call("POST", "/api/access/invitations", Some(json!({ "label": "Invitée", "groups": ["monnaie-libre"] }))) + .await; + let token = v["link"].as_str().expect("link").split('#').nth(1).expect("token").to_owned(); + let mut invited = admin.with(None); + let (s, _) = invited.call("POST", "/api/auth/invitation", Some(json!({ "token": token }))).await; + assert_eq!(s, StatusCode::OK); + // She reads the text and may propose, but her vote would not be a web-of-trust vote. + let id = doc_id(&mut invited, "monnaie-libre").await; + let (s, doc) = invited.call("GET", &format!("/api/docs/{id}"), None).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(doc["canVote"], false); + let p = unit(&doc, "E3")["proposals"][0]["id"].as_str().expect("p").to_owned(); + let (s, v) = invited.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await; + assert_eq!(s, StatusCode::CONFLICT, "{v}"); + assert_eq!(v["error"], "wot_members_only"); + // In the test group, every member votes. + let essai = doc_id(&mut admin, "essai").await; + let (_, d) = admin.call("GET", &format!("/api/docs/{essai}"), None).await; + assert_eq!(d["canVote"], true); + // A new link may only target an invitation identity, never another kind. + let (_, me) = admin.call("GET", "/api/me", None).await; + let dev_id = me["me"]["id"].as_str().expect("id").to_owned(); + let (s, _) = + admin.call("POST", "/api/access/invitations", Some(json!({ "label": "Yvv", "identityId": dev_id }))).await; + assert_eq!(s, StatusCode::BAD_REQUEST); +} + #[tokio::test] async fn feedback_reaches_administrators() { let (mut user, _) = Client::new().await; diff --git a/crates/trustin-access/src/lib.rs b/crates/trustin-access/src/lib.rs index 47a63c1..c5c3704 100644 --- a/crates/trustin-access/src/lib.rs +++ b/crates/trustin-access/src/lib.rs @@ -12,7 +12,9 @@ //! - a group opens only to its members; Monnaie libre's members are the //! members of the Ğ1 web of trust, the others are granted one by one; //! - everything is open to a member except access management, which belongs -//! to trustinWallet's administrators. +//! to trustinWallet's administrators; +//! - where the electorate is the web of trust (Monnaie libre), only its members +//! vote (2026-10-21, independent review: one person, one vote). use serde::{Deserialize, Serialize}; @@ -99,6 +101,18 @@ pub fn opens(identity: &Identity, group: &Group, granted: bool) -> bool { is_member(identity, group, granted) } +/// Can this identity vote in the group? Where the electorate is the Ğ1 web of +/// trust (policy `G1Members`), only its members vote: the threshold is computed +/// on the size of the web, and an invitation identity next to a Ğ1 identity +/// would give one person two votes. Development identities stand in for members +/// (local only: the dev login is never enabled in production). +pub fn votes_in(identity: &Identity, group: &Group, granted: bool) -> bool { + opens(identity, group, granted) + && (group.policy != GroupPolicy::G1Members + || identity.kind == IdentityKind::Dev + || (identity.kind == IdentityKind::G1 && identity.g1_member)) +} + /// Access management stays with trustinWallet's administrators. pub fn manages_access(identity: &Identity) -> bool { identity.admin @@ -176,6 +190,18 @@ mod tests { assert!(!opens(&admin, &group("essai"), false)); } + #[test] + fn only_wot_members_vote_where_the_electorate_is_the_web_of_trust() { + let ml = group("monnaie-libre"); + assert!(votes_in(&who(IdentityKind::G1, true, false), &ml, false)); + let invited = who(IdentityKind::Invite, false, true); + assert!(opens(&invited, &ml, true), "an invitation may open Monnaie libre to read and propose"); + assert!(!votes_in(&invited, &ml, true), "but its votes would not be web-of-trust votes"); + assert!(!votes_in(&who(IdentityKind::G1, false, false), &ml, true), "a former member reads, does not vote"); + assert!(votes_in(&invited, &group("essai"), true), "managed groups: every member votes"); + assert!(!votes_in(&invited, &group("essai"), false)); + } + #[test] fn no_atelier_du_canal() { assert!(default_groups().iter().all(|g| !g.slug.contains("atelier"))); diff --git a/docs/adr/0003-corriger-n-est-pas-voter.md b/docs/adr/0003-corriger-n-est-pas-voter.md index bb3efbd..cd1cdfc 100644 --- a/docs/adr/0003-corriger-n-est-pas-voter.md +++ b/docs/adr/0003-corriger-n-est-pas-voter.md @@ -4,6 +4,7 @@ coquille. » Mais une correction ne doit pas pouvoir changer le sens d'un texte voté. **Décision.** Le moteur classe chaque modification (`ld-engine/src/edit.rs`) : +- **jamais coquille** si un nombre change, même par la ponctuation (« 50 » → « 5,0 ») ; - **coquille** si le squelette (sans accents, casse, ponctuation, espaces) est identique, ou si au plus 2 mots diffèrent de 2 lettres au plus, sans chiffre ni mot de négation (ne, pas, jamais, personne…) ; elle s'applique sans vote, garde les votes et les traductions ; diff --git a/docs/adr/0006-acces-connect-trustinwallet.md b/docs/adr/0006-acces-connect-trustinwallet.md index 8d9bfd9..8cafc88 100644 --- a/docs/adr/0006-acces-connect-trustinwallet.md +++ b/docs/adr/0006-acces-connect-trustinwallet.md @@ -13,6 +13,9 @@ Duniter peut se loger et tout faire — sauf la gestion des accès », qui revie en-tête anti-CSRF sur toute écriture. - Les règles (qui voit, qui ouvre, qui administre) vivent dans `trustin-access`, futur cœur du service trustinWallet ; le serveur ne décide jamais seul. +- **Une personne, une voix** : là où l'électorat est la toile de confiance (Monnaie libre), + seuls ses membres votent ; une identité d'invitation y lit et propose. Un nouveau lien ne + vise jamais une identité Ğ1 (un administrateur ouvrirait une session à sa place). - Premier administrateur : un lien d'invitation écrit au premier démarrage dans le volume de données (0600), jamais dans les journaux. diff --git a/docs/chantiers/acte-certification/CHANTIER.md b/docs/chantiers/acte-certification/CHANTIER.md index 338682c..3ad6d3f 100644 --- a/docs/chantiers/acte-certification/CHANTIER.md +++ b/docs/chantiers/acte-certification/CHANTIER.md @@ -80,8 +80,9 @@ l'inertie du cran au-dessus** (« inertie + ») : alourdir ou alléger demande p | Modifier **le fond** d'une proposition | son auteur | nouvelle révision : les votes antérieurs sont « à reconfirmer », les signalements tombent | | Modifier le fond d'un texte **en vigueur** ou d'un texte de départ | personne | refusé : on propose une nouvelle version | -Le classement est prudent : un chiffre ou un mot de négation (ne, pas, jamais, personne…) -touché rend la modification « de fond ». Moteur : `crates/ld-engine/src/edit.rs`. +Le classement est prudent : un nombre changé (même par une virgule ou une espace : « 50 » → +« 5,0 ») ou un mot de négation touché (ne, pas, jamais, personne…) rend la modification « de +fond ». Moteur : `crates/ld-engine/src/edit.rs`. ### Deux articles qui se contredisent @@ -119,6 +120,11 @@ corriger, signaler) **sauf la gestion des accès**, réservée aux administrateu (`/acces`) : membres des groupes, invitations, administrateurs, retours, clé de publication. Le nom trustinWallet remplace trustWallet (déjà pris). Règles : `crates/trustin-access`. +**Une personne, une voix.** Dans Monnaie libre, dont l'électorat W est la toile de confiance, +**seuls les membres de la toile votent** (connexion Ğ1). Une identité d'invitation y lit, +propose, corrige et signale, mais ne vote pas : sinon la même personne, invitée puis connectée +avec sa clé, voterait deux fois. Dans les groupes gérés (essai, Axiom…), tout membre vote. + ## Le groupe d'essai Caché, plancher C = 1, sans publication : on y éprouve tout (adoption, coquilles, découpage) diff --git a/docs/chantiers/acte-certification/CONTROLES.md b/docs/chantiers/acte-certification/CONTROLES.md index ef8de77..c9f2510 100644 --- a/docs/chantiers/acte-certification/CONTROLES.md +++ b/docs/chantiers/acte-certification/CONTROLES.md @@ -19,6 +19,7 @@ Où tester : les contrôles qui écrivent (C5, C6, C7) se font dans le **groupe | C8 | Trois langues | choisir EN puis ES pour le texte | « Certification Commitment Act », « Acta de compromiso de Certificación » ; l'interface suit | | C9 | Les aperçus | « Si tout passe », puis « Si les bien cotées passent » | 33 articles avec la mention d'aperçu, puis 3 | | C10 | trustinWallet | ouvrir `/acces` sans être administratrice | « La gestion des accès est réservée aux administrateurs de trustinWallet. » (côté admin : créer un lien d'invitation) | +| C11 | Une personne, une voix | avec un lien d'invitation, ouvrir le vote d'un article de Monnaie libre | « Ici, seuls les membres de la toile de confiance Ğ1 votent… » à la place des boutons ; dans le groupe d'essai, les boutons sont là | | M1 | Mobile | téléphone (ou fenêtre étroite) | menu en tiroir, les trois gestes visibles sans survol, le panneau s'ouvre en feuille | ## Au-delà de la liste @@ -40,3 +41,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa | Date | Qui | Où | Contrôles | Résultat | |---|---|---|---|---| | 2026-10-21 | automate | local, serveur jetable :8092 | C1–C10, M1 | 11 / 11 verts | +| 2026-10-21 | automate | local, après relecture indépendante | C1–C11, M1 | 12 / 12 verts | diff --git a/docs/chantiers/acte-certification/ONBOARDING.md b/docs/chantiers/acte-certification/ONBOARDING.md index d7bef45..1ad797a 100644 --- a/docs/chantiers/acte-certification/ONBOARDING.md +++ b/docs/chantiers/acte-certification/ONBOARDING.md @@ -24,8 +24,9 @@ ce navigateur pour 30 jours. 1. Ouvre ton lien d'invitation → tu arrives connectée. 2. Les contrôles qui écrivent (voter, corriger, découper) se font dans le **groupe d'essai** : il est caché, une seule voix « pour » suffit à faire passer une proposition, et rien n'en - sort. Le texte de Monnaie libre, lui, est le vrai : lis-le, ne vote que pour de vrai. -3. Passe les contrôles C1–C10 et M1 (sur ton téléphone aussi), puis tout ce qui te gêne. + sort. Le texte de Monnaie libre, lui, est le vrai : avec ton lien d'invitation tu le lis, + tu proposes, tu signales ; le vote y est réservé aux membres de la toile (avec ta clé Ğ1). +3. Passe les contrôles C1–C11 et M1 (sur ton téléphone aussi), puis tout ce qui te gêne. 4. Chaque remarque : **« Faire un retour »** dans le menu de gauche. Écris ce que tu as fait, ce que tu attendais, ce qui s'est passé. La page, la langue et la taille de l'écran sont jointes. diff --git a/web/e2e/reader.spec.ts b/web/e2e/reader.spec.ts index db0ca41..3c059fb 100644 --- a/web/e2e/reader.spec.ts +++ b/web/e2e/reader.spec.ts @@ -133,3 +133,25 @@ test('C10 — trustinWallet : la gestion des accès est réservée', async ({ br await yvv.getByRole('button', { name: "Créer le lien d'invitation" }).click(); await expect(yvv.locator('.link code')).toContainText('/invitation#'); }); + +test('C11 — une personne, une voix : dans Monnaie libre, seuls les membres de la toile votent', async ({ browser }) => { + const yvv = await person(browser, 'Yvv', true); + const r = await yvv.request.post('/api/access/invitations', { + data: { label: 'Invitée', groups: ['monnaie-libre', 'essai'] }, + headers: { 'x-requested-with': 'libredecision' }, + }); + const { link } = (await r.json()) as { link: string }; + const ctx = await browser.newContext({ locale: 'fr-FR' }); + const invited = await ctx.newPage(); + await invited.goto(`/invitation#${link.split('#')[1]}`); + await expect(invited).toHaveURL(/\/groupes$/); + await invited.goto(DOC); + await invited.locator('ul.deep button.row', { hasText: 'Connaissance suffisante' }).first().click(); + const panel = invited.locator('aside.panel'); + await expect(panel.getByText('Ici, seuls les membres de la toile de confiance Ğ1 votent', { exact: false })).toBeVisible(); + await expect(panel.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0); + // In the test group, the same person votes. + await invited.goto(TEST_DOC); + await invited.locator('ul.deep button.row', { hasText: 'Connaissance suffisante' }).first().click(); + await expect(invited.locator('aside.panel').getByRole('button', { name: 'Pour', exact: true })).toBeVisible(); +}); diff --git a/web/src/lib/components/VoteView.svelte b/web/src/lib/components/VoteView.svelte index b70edf3..4659fa9 100644 --- a/web/src/lib/components/VoteView.svelte +++ b/web/src/lib/components/VoteView.svelte @@ -255,7 +255,9 @@ {#if p.blocked}

{t('vote.blocked')}

{/if} - {#if votable} + {#if votable && !view?.canVote} +

{t('vote.wotOnly')}

+ {:else if votable} {#if p.myVoteStale}

{t('vote.stale')}

{/if}
{t('vote.yours')} diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index b4c1837..efe5a3e 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -168,6 +168,7 @@ export const en: Messages = { 'vote.entered.remove': '“{title}” has been removed from the text in force.', 'vote.entered.inertia': 'The inertia of “{title}” has changed.', 'vote.closed': 'This version is no longer open for voting.', + 'vote.wotOnly': 'Here, only members of the Ğ1 web of trust vote: sign in with your Ğ1 key to vote. You can read, propose and flag a contradiction.', 'inertia.title': 'Inertia', 'inertia.explain': 'The higher the inertia, the more votes it takes to change this text when few people vote.', @@ -309,6 +310,7 @@ export const en: Messages = { 'error.last_admin': 'At least one administrator must remain.', 'error.g1_not_configured': "Ğ1 sign-in isn't active yet.", 'error.invitation_invalid': 'This invitation link is no longer valid.', + 'error.wot_members_only': 'Here, only members of the Ğ1 web of trust vote.', 'error.retry': 'Try again', 'common.cancel': 'Cancel', diff --git a/web/src/lib/i18n/es.ts b/web/src/lib/i18n/es.ts index 70ddcb1..8c4c31c 100644 --- a/web/src/lib/i18n/es.ts +++ b/web/src/lib/i18n/es.ts @@ -168,6 +168,7 @@ export const es: Messages = { 'vote.entered.remove': '«{title}» se ha retirado del texto en vigor.', 'vote.entered.inertia': 'La inercia de «{title}» ha cambiado.', 'vote.closed': 'Esta versión ya no está en votación.', + 'vote.wotOnly': 'Aquí solo votan los miembros de la red de confianza Ğ1: conéctate con tu clave Ğ1 para votar. Puedes leer, proponer y señalar una contradicción.', 'inertia.title': 'Inercia', 'inertia.explain': 'Cuanto más alta es la inercia, más votos hacen falta para cambiar este texto cuando vota poca gente.', @@ -309,6 +310,7 @@ export const es: Messages = { 'error.last_admin': 'Debe quedar al menos un administrador.', 'error.g1_not_configured': 'La conexión Ğ1 aún no está activa.', 'error.invitation_invalid': 'Este enlace de invitación ya no es válido.', + 'error.wot_members_only': 'Aquí solo votan los miembros de la red de confianza Ğ1.', 'error.retry': 'Reintentar', 'common.cancel': 'Cancelar', diff --git a/web/src/lib/i18n/fr.ts b/web/src/lib/i18n/fr.ts index c587217..ed7e350 100644 --- a/web/src/lib/i18n/fr.ts +++ b/web/src/lib/i18n/fr.ts @@ -171,6 +171,7 @@ export const fr = { 'vote.entered.remove': '« {title} » est retiré du texte en vigueur.', 'vote.entered.inertia': "L'inertie de « {title} » a changé.", 'vote.closed': 'Cette version ne se vote plus.', + 'vote.wotOnly': 'Ici, seuls les membres de la toile de confiance Ğ1 votent : connecte-toi avec ta clé Ğ1 pour voter. Tu peux lire, proposer et signaler une contradiction.', 'inertia.title': 'Inertie', 'inertia.explain': "Plus l'inertie est haute, plus il faut de voix pour changer ce texte quand peu de gens votent.", @@ -312,6 +313,7 @@ export const fr = { 'error.last_admin': 'Il faut garder au moins un administrateur.', 'error.g1_not_configured': "La connexion Ğ1 n'est pas encore active.", 'error.invitation_invalid': "Ce lien d'invitation n'est plus valable.", + 'error.wot_members_only': 'Ici, seuls les membres de la toile de confiance Ğ1 votent.', 'error.retry': 'Réessayer', 'common.cancel': 'Annuler', diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 6f81130..48bcfc7 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -89,6 +89,8 @@ export interface Publication { } export interface DocView { + /** trustinWallet: where the electorate is the web of trust, only its members vote. */ + canVote: boolean; doc: { id: string; slug: string;