v3 : seconde relecture — une coquille ne touche ni chiffre, ni signe, ni relation ; retour arrière du déploiement

- Correction de coquille : un chiffre, un signe, un séparateur dans un nombre ou
  une relation changés (« >= » → « <= », « + » → « - », « 182.625 » → « 182 625 »,
  « % » → « ‰ ») rendent la modification « de fond ». Testé dans le moteur et
  par l'API sur le vrai texte K1.
- Déploiement : les conteneurs retirés de la racine qui tournaient sont notés ;
  si la suite échoue, l'étape « retablir » arrête le produit et les relance.
  Le produit est recréé à chaque déploiement (--force-recreate).
- Cookies préfixés en https : __Host-ld_session, __Secure-ld_oauth (pas de
  cookie planté par un site voisin sous librodrome.org).
- syft épinglé (v1.55.0) dans les deux pipelines : ce conteneur a le socket Docker.
- Docs : limites connues assumées, groupe d'essai à deux voix, error.identity_kind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 04:54:30 +02:00
co-authored by Claude Opus 5.5
parent 94907afb20
commit ac9c904e7e
11 changed files with 152 additions and 28 deletions
+47 -3
View File
@@ -46,7 +46,8 @@ steps:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- apk add --no-cache curl
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin latest
# Version épinglée : ce conteneur a le socket Docker, il n'exécute pas « main » de syft.
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/v1.55.0/install.sh | sh -s -- -b /usr/local/bin v1.55.0
- mkdir -p .reports
# spec 1.5 épinglée : syft « latest » émet plus récent que ce que le trivy du runner décode
- syft libredecision-app:latest -o cyclonedx-json@1.5 --file .reports/sbom-app.cyclonedx.json
@@ -105,12 +106,19 @@ steps:
# « 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.
# Ceux qui tournaient sont notés dans /opt/libredecision-app/retires : si la suite
# échoue, l'étape « retablir » les relance (et seulement eux).
- |
RETIRES=/opt/libredecision-app/retires
: > "$RETIRES"
for TAG in "urlprefix-$DOMAIN/*" "urlprefix-$DOMAIN/api/*"; 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 "Retrait de $NAME de la racine (revendique $TAG)"
if [ "$(docker inspect --format '{{.State.Running}}' "$C")" = "true" ]; then
echo "$C" >> "$RETIRES"
fi
docker update --restart=no "$C" >/dev/null
docker stop "$C" >/dev/null
done
@@ -136,7 +144,7 @@ steps:
exit 1
fi
# Image construite localement dans la pipeline : pas de docker compose pull
- cd /opt/libredecision-app && docker compose up -d --remove-orphans
- cd /opt/libredecision-app && docker compose up -d --remove-orphans --force-recreate
- cd /opt/libredecision-app && docker compose ps
- name: test-deploy
@@ -153,10 +161,13 @@ steps:
echo "PASS: conteneur running"
# Le produit répond, et c'est bien ce commit qui répond.
# NOTE: volumes + pas de from_secret : compatible
- name: healthcheck
image: alpine:3.20
depends_on:
- test-deploy
volumes:
- /opt/libredecision-app:/opt/libredecision-app
commands:
- apk add --no-cache --quiet curl
- |
@@ -169,7 +180,9 @@ steps:
echo "Tentative $((i+1))/$MAX - $BODY"
case "$BODY" in
*'"ok":true'*"\"build\":\"$BUILD\""*|*"\"build\":\"$BUILD\""*'"ok":true'*)
echo "PASS: $BUILD repond sur $TARGET"; exit 0 ;;
echo "PASS: $BUILD repond sur $TARGET"
: > /opt/libredecision-app/retires
exit 0 ;;
esac
i=$((i+1))
sleep 10
@@ -177,8 +190,39 @@ steps:
echo "FAIL: $BUILD ne repond pas apres 10 minutes"
exit 1
# Échec après le retrait de la racine : le produit s'arrête, ce qu'il avait retiré
# repart (restart: always rendu). Échec avant (construction, tests) : rien à faire,
# la liste est vide depuis le dernier déploiement réussi.
# NOTE: volumes + pas de from_secret : compatible
- name: retablir
image: docker:27-cli
# En graphe (depends_on), une étape sans dépendance démarrerait au début : celle-ci
# attend la fin du healthcheck (fait, échoué ou sauté).
depends_on:
- healthcheck
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /opt/libredecision-app:/opt/libredecision-app
commands:
- |
RETIRES=/opt/libredecision-app/retires
if [ ! -s "$RETIRES" ]; then echo "Rien a retablir"; exit 0; fi
# Hors de « restart: always » aussi, sinon un redémarrage de Docker le ramènerait
# à côté de ce qui revient ; le prochain déploiement le recrée (--force-recreate).
docker update --restart=no lpk-libredecision-app >/dev/null 2>&1 || true
docker stop lpk-libredecision-app >/dev/null 2>&1 || true
for C in $(cat "$RETIRES"); do
echo "Retour de $(docker inspect --format '{{.Name}}' "$C" 2>/dev/null || echo "$C")"
docker update --restart=always "$C" >/dev/null && docker start "$C" >/dev/null || echo " echec pour $C"
done
: > "$RETIRES"
when:
- status: failure
- name: notify-failure
image: alpine:3.20
depends_on:
- retablir
commands:
- 'echo "ECHEC pipeline app #$CI_PIPELINE_NUMBER sur $CI_COMMIT_BRANCH ($CI_COMMIT_SHA)"'
when:
+2 -1
View File
@@ -71,7 +71,8 @@ steps:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- apk add --no-cache curl
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin latest
# Version épinglée : ce conteneur a le socket Docker, il n'exécute pas « main » de syft.
- curl -sSfL https://raw.githubusercontent.com/anchore/syft/v1.55.0/install.sh | sh -s -- -b /usr/local/bin v1.55.0
- mkdir -p .reports
# spec 1.5 épinglée : syft « latest » émet plus récent que ce que le trivy du runner décode
- syft libredecision-backend:latest -o cyclonedx-json@1.5 --file .reports/sbom-backend.cyclonedx.json
+51 -11
View File
@@ -4,8 +4,9 @@
//! tells two kinds of corrections apart:
//!
//! - **typographic**: accents, case, punctuation, spacing, emphasis, or at most
//! two misspelt words fixed (edit distance ≤ 2, no number, no negation
//! touched). The votes stay; the correction is shown with its diff.
//! two misspelt words fixed (edit distance ≤ 2, no negation touched) — and
//! never a figure, a sign, a separator inside a number or a relation
//! (`≥`, `+`, `%`…). The votes stay; the correction is shown with its diff.
//! - **substantive**: anything else. It opens a new revision and every vote
//! cast on the previous one waits for its author to reconfirm it.
//!
@@ -48,9 +49,32 @@ 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()
/// Symbols that carry a value or a relation, wherever they stand.
const RELATIONS: &[char] =
&['+', '±', '×', '÷', '^', '<', '>', '=', '≤', '≥', '≠', '≈', '%', '‰', '€', '$', '£', '∞', '√'];
/// Signs and fraction bars: meaningful next to a figure ("-5", "1/5", "5-10").
const SIGNS: &[char] = &['-', '−', '–', '/'];
/// Separators: meaningful between two figures ("10 000", "10,000", "182.625").
const SEPARATORS: &[char] = &['.', ',', ' ', '\u{a0}', '\u{202f}', '\'', '’'];
/// What the text says in figures and symbols, as written. Two texts with the
/// same letters but a different figure, sign, separator inside a number or
/// relation ("≥" → "≤", "+" → "-", "50" → "5,0", "182.625" → "182 625") do not
/// say the same thing.
fn figures(s: &str) -> String {
let cs: Vec<char> = s.chars().collect();
let is_figure = |i: Option<usize>| i.and_then(|i| cs.get(i)).is_some_and(|c| c.is_numeric());
cs.iter()
.enumerate()
.filter(|&(i, c)| {
let (before, after) = (is_figure(i.checked_sub(1)), is_figure(Some(i + 1)));
c.is_numeric()
|| RELATIONS.contains(c)
|| (SIGNS.contains(c) && (before || after))
|| (SEPARATORS.contains(c) && before && after)
})
.map(|(_, c)| *c)
.collect()
}
/// Folded words (runs of letters/digits).
@@ -62,8 +86,8 @@ 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) {
// A figure, a sign or a relation is never a typo: it changes what the text says.
if figures(old) != figures(new) {
return EditKind::Substantive;
}
if skeleton(old) == skeleton(new) {
@@ -208,11 +232,27 @@ mod tests {
}
#[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);
fn a_figure_a_sign_or_a_relation_is_never_a_typo() {
// Same letters, different meaning (independent reviews, 2026-10-21).
for (old, new) in [
("au moins 50 voix", "au moins 5,0 voix"),
("1 5 jours", "15 jours"),
("10 000 membres", "10,000 membres"),
("un écart de -5", "un écart de 5"),
("1/5 des voix", "1.5 des voix"),
("50 % des voix", "50 ‰ des voix"),
("avoir >= 5 certifications", "avoir <= 5 certifications"),
("DU(n) + c² × M(n)", "DU(n) - c² × M(n)"),
("182.625 jours", "182 625 jours"),
("c²", "c2"),
] {
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
}
// Punctuation around figures, list markers, dashes between words stay typographic.
assert_eq!(classify("50 voix pour", "50 voix pour !"), EditKind::Typographic);
assert_eq!(classify("- une liste", "* une liste"), EditKind::Typographic);
assert_eq!(classify("1. premier point", "1) premier point"), EditKind::Typographic);
assert_eq!(classify("libre - le texte", "libre — le texte"), EditKind::Typographic);
}
#[test]
+20 -7
View File
@@ -28,6 +28,17 @@ use trustin_access::{Identity, IdentityKind};
pub const SESSION_COOKIE: &str = "ld_session";
const OAUTH_COOKIE: &str = "ld_oauth";
/// Over https, the prefixed names: a neighbouring site under the same parent
/// domain cannot plant a `__Host-` cookie (session fixation), nor a
/// `__Secure-` one without https.
fn session_cookie(state: &AppState) -> &'static str {
if state.cfg.secure_cookies() { "__Host-ld_session" } else { SESSION_COOKIE }
}
fn oauth_cookie(state: &AppState) -> &'static str {
if state.cfg.secure_cookies() { "__Secure-ld_oauth" } else { OAUTH_COOKIE }
}
const SESSION_DAYS: i64 = 30;
const INVITATION_DAYS: i64 = 14;
@@ -83,7 +94,7 @@ impl FromRequestParts<AppState> for Viewer {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
let Some(token) = cookie_value(&parts.headers, SESSION_COOKIE) else {
let Some(token) = cookie_value(&parts.headers, session_cookie(state)) else {
return Ok(Self(None));
};
let row = sqlx::query_as::<_, IdentityRow>(&format!(
@@ -124,7 +135,7 @@ async fn open_session(state: &AppState, identity_id: &str) -> AppResult<HeaderVa
.bind(identity_id)
.execute(&state.db)
.await?;
Ok(cookie(state, SESSION_COOKIE, &token, "/", SESSION_DAYS * 86_400))
Ok(cookie(state, session_cookie(state), &token, "/", SESSION_DAYS * 86_400))
}
pub async fn load_identity(state: &AppState, id: &str) -> AppResult<IdentityRow> {
@@ -212,7 +223,7 @@ pub async fn g1_start(State(state): State<AppState>, Query(q): Query<StartQuery>
.append_pair("redirect_uri", &state.cfg.connect_callback())
.append_pair("state", &csrf);
let stash = format!("{csrf}.{}", url::form_urlencoded::byte_serialize(back.as_bytes()).collect::<String>());
Ok(redirect(url.as_str(), vec![cookie(&state, OAUTH_COOKIE, &stash, "/api/auth/g1", 600)]))
Ok(redirect(url.as_str(), vec![cookie(&state, oauth_cookie(&state), &stash, "/api/auth/g1", 600)]))
}
#[derive(Deserialize)]
@@ -243,8 +254,8 @@ pub async fn g1_callback(
headers: HeaderMap,
Query(q): Query<CallbackQuery>,
) -> Response {
let clear = cookie(&state, OAUTH_COOKIE, "", "/api/auth/g1", 0);
let stash = cookie_value(&headers, OAUTH_COOKIE).unwrap_or_default();
let clear = cookie(&state, oauth_cookie(&state), "", "/api/auth/g1", 0);
let stash = cookie_value(&headers, oauth_cookie(&state)).unwrap_or_default();
let (expected, back) = stash.split_once('.').unwrap_or(("", ""));
let back =
safe_return(url::form_urlencoded::parse(format!("p={back}").as_bytes()).next().map(|(_, v)| v).as_deref());
@@ -460,14 +471,14 @@ pub async fn dev_login(State(state): State<AppState>, Json(body): Json<DevBody>)
}
pub async fn logout(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Response> {
if let Some(token) = cookie_value(&headers, SESSION_COOKIE) {
if let Some(token) = cookie_value(&headers, session_cookie(&state)) {
sqlx::query("DELETE FROM sessions WHERE token_hash = ?")
.bind(util::sha256_hex(&token))
.execute(&state.db)
.await?;
}
let mut res = Json(json!({ "me": null })).into_response();
res.headers_mut().append(SET_COOKIE, cookie(&state, SESSION_COOKIE, "", "/", 0));
res.headers_mut().append(SET_COOKIE, cookie(&state, session_cookie(&state), "", "/", 0));
Ok(res)
}
@@ -522,6 +533,8 @@ mod tests {
assert_eq!(safe_return(Some("//evil.example")), "/groupes");
assert_eq!(safe_return(Some("https://evil.example")), "/groupes");
assert_eq!(safe_return(Some("/\\evil")), "/groupes");
assert_eq!(safe_return(Some("/\t/evil.example")), "/groupes");
assert_eq!(safe_return(Some("/\n/evil.example")), "/groupes");
assert_eq!(safe_return(None), "/groupes");
}
+12
View File
@@ -218,6 +218,18 @@ async fn editing_is_not_voting() {
.await;
assert_eq!(s, StatusCode::CONFLICT);
assert_eq!(v["error"], "substantive_in_force");
// Flipping a relation in a seed text is not a typo, even with the same letters.
let k1 = unit(&doc, "K1")["proposals"][0].clone();
let flipped = k1["body"].as_str().expect("body").replace(">= 5 certifications", "<= 5 certifications");
let (s, v) = b
.call(
"PATCH",
&format!("/api/proposals/{}", k1["id"].as_str().expect("id")),
Some(json!({ "title": k1["title"], "body": flipped })),
)
.await;
assert_eq!(s, StatusCode::CONFLICT, "{v}");
assert_eq!(v["error"], "substantive_not_author");
// A member proposal: typo keeps votes, substance asks to reconfirm.
let uid = unit(&doc, "E2")["id"].as_str().expect("u").to_owned();
+2 -1
View File
@@ -4,7 +4,8 @@
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 ») ;
- **jamais coquille** si un chiffre, un signe, un séparateur dans un nombre ou une relation
change (« 50 » → « 5,0 », « 182.625 » → « 182 625 », « ≥ » → « ≤ », « + » → « - ») ;
- **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 ;
+12 -3
View File
@@ -80,9 +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 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`.
Le classement est prudent : un chiffre, un signe, un séparateur dans un nombre ou une relation
changés (« 50 » → « 5,0 », « 182.625 » → « 182 625 », « ≥ » → « ≤ », « + » → « - », « % »)
ou un mot de négation touché (ne, pas, jamais, personne…) rendent la modification « de fond ». Moteur : `crates/ld-engine/src/edit.rs`.
### Deux articles qui se contredisent
@@ -145,6 +145,15 @@ sans toucher au texte de la communauté. Chiara et Yvv en sont membres.
Les raisons longues sont dans `docs/adr/`.
## Limites connues (assumées pour ce premier chantier)
- Le droit de vote se vérifie **au moment du vote** : un membre sorti de la toile, ou dont
l'accès est révoqué, garde les voix déjà exprimées.
- Dans un groupe géré (essai, Axiom…), une même personne admise sous une identité d'invitation
**et** sous sa clé Ğ1 aurait deux voix : c'est à l'administration de n'en admettre qu'une.
- Un administrateur peut créer un nouveau lien pour une identité d'invitation existante
(pour la dépanner) — jamais pour une identité Ğ1.
## Questions ouvertes (à Yvv)
- **Licence** du code — non choisie.
@@ -23,8 +23,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 : avec ton lien d'invitation tu le lis,
il est caché, son plancher est d'une voix, et rien n'en sort. À deux membres (Yvv et toi),
une proposition passe avec vos deux voix « pour » — la formule demande la majorité de
l'électorat quand il est si petit. 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
+1
View File
@@ -310,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.identity_kind': 'A new link can only be for an invited person, never a Ğ1 identity.',
'error.wot_members_only': 'Here, only members of the Ğ1 web of trust vote.',
'error.retry': 'Try again',
+1
View File
@@ -310,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.identity_kind': 'Un nuevo enlace solo puede ser para una persona invitada, nunca para una identidad Ğ1.',
'error.wot_members_only': 'Aquí solo votan los miembros de la red de confianza Ğ1.',
'error.retry': 'Reintentar',
+1
View File
@@ -313,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.identity_kind': "Un nouveau lien ne peut viser qu'une personne invitée, jamais une identité Ğ1.",
'error.wot_members_only': 'Ici, seuls les membres de la toile de confiance Ğ1 votent.',
'error.retry': 'Réessayer',