v3 : points de la relecture indépendante — une personne une voix, ordre de déploiement, durcissements

- Dans Monnaie libre (électorat = toile de confiance), seuls les membres de la
  toile votent (trustin_access::votes_in) ; une identité d'invitation y lit,
  propose et signale. L'interface le dit à la place des boutons (fr/en/es).
- Un lien d'invitation ne vise jamais une identité Ğ1 ; le lien est réservé
  avant de créer l'identité (deux acceptations simultanées : une seule passe).
- Un nombre n'est jamais une coquille (« 50 » → « 5,0 », « 1 5 » → « 15 »).
- Redirection après connexion : caractères de contrôle refusés.
- En-têtes : X-Frame-Options DENY, frame-ancestors 'none', nosniff, Referrer-Policy.
- Déploiement : le produit passe en premier ; la maquette attend son succès.
  Les conteneurs, même arrêtés, qui revendiquent encore la racine sortent de
  « restart: always » (ils reviendraient au redémarrage de Docker).
- Tests : 57 Rust (dont 14 scénarios d'API), e2e 12 / 12 avec C11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 04:23:51 +02:00
co-authored by Claude Opus 5.5
parent 1f9c241b41
commit 94907afb20
25 changed files with 229 additions and 49 deletions
+12 -10
View File
@@ -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.
+6 -3
View File
@@ -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
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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.
+10 -4
View File
@@ -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
+2 -2
View File
@@ -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/
+17
View File
@@ -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<String> {
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);
+6 -2
View File
@@ -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?;
+34 -15
View File
@@ -169,7 +169,16 @@ pub async fn me(Viewer(viewer): Viewer) -> Json<serde_json::Value> {
/// 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<AppState>, Json(body): Json<InvitationBody>) -> AppResult<Response> {
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<AppState>, Json(body): Json<I
}
let groups: Vec<String> = 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();
+8
View File
@@ -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<bool> {
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),
+14 -3
View File
@@ -123,9 +123,20 @@ pub fn api_router() -> Router<AppState> {
}
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())
+4
View File
@@ -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(
+35
View File
@@ -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;
+27 -1
View File
@@ -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")));
@@ -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 ;
@@ -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.
@@ -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)
@@ -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 |
@@ -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.
+22
View File
@@ -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();
});
+3 -1
View File
@@ -255,7 +255,9 @@
</div>
{#if p.blocked}<p class="warn">{t('vote.blocked')}</p>{/if}
{#if votable}
{#if votable && !view?.canVote}
<p class="muted">{t('vote.wotOnly')}</p>
{:else if votable}
{#if p.myVoteStale}<p class="warn">{t('vote.stale')}</p>{/if}
<div class="choice" role="group" aria-label={t('vote.yours')}>
<span class="label">{t('vote.yours')}</span>
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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;