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:
+12
-10
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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())
|
||||
sqlx::query("UPDATE invitations SET identity_id = ? WHERE token_hash = ?")
|
||||
.bind(&identity_id)
|
||||
.bind(&inv.token_hash)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if used.rows_affected() != 1 {
|
||||
return Err(AppError::Invalid("invitation_invalid"));
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user