v3 : neuvième relecture — noms uniques, places justes, quantités

- Noms des liens d'équipe : comparés sans casse, accents ni ponctuation
  (ld_engine::names), lettres latines seulement (pas de sosie cyrillique,
  grec ou pleine chasse), une ligne ; index unique (migration additive
  0003) contre deux ouvertures simultanées sous le même nom.
- Une personne déjà connectée ne prend une place que si le lien lui
  ajoute un groupe ; « Ce n'est pas moi » la déconnecte sur un navigateur
  partagé ; un lien collé dans le même onglet est relu.
- Coller ou couper un mot de quantité n'est pas une coquille
  (« sur tout » → « surtout », « par tout » → « partout »).
- Libellés sans caractère de contrôle ; la soumission revérifie le titre.

Boucle : 68 tests Rust, svelte-check, 9 vitest, e2e 13/13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-21 08:40:04 +02:00
co-authored by Claude Opus 5.5
parent fd19dbf4ec
commit 753b52202b
18 changed files with 231 additions and 62 deletions
+3 -3
View File
@@ -7,11 +7,11 @@
**Chantier 1 — Acte d'engagement Certification** : **en ligne** sur decision.librodrome.org **Chantier 1 — Acte d'engagement Certification** : **en ligne** sur decision.librodrome.org
depuis le 2026-10-21 (version `fa8e8dc`, pipeline Woodpecker n° 30) ; la maquette est sur depuis le 2026-10-21 (version `fa8e8dc`, pipeline Woodpecker n° 30) ; la maquette est sur
maquette.decision.librodrome.org (certificat jusqu'au 19/01/2027). Sur la branche du chantier, maquette.decision.librodrome.org (certificat jusqu'au 19/01/2027). Sur la branche du chantier,
pas encore en ligne : les points des septième et huitième relectures, les **liens d'équipe** pas encore en ligne : les points des septième, huitième et neuvième relectures, les **liens d'équipe**
(demandés par Yvv le 2026-10-21) et la publication vers git.open.us.org. (demandés par Yvv le 2026-10-21) et la publication vers git.open.us.org.
- v3 Rust + Svelte à la racine du dépôt ; la maquette v2 intacte dans `maquette/`. - v3 Rust + Svelte à la racine du dépôt ; la maquette v2 intacte dans `maquette/`.
- Boucle : Rust (fmt, clippy `-D warnings`, 65 tests dont 15 scénarios d'API et la - Boucle : Rust (fmt, clippy `-D warnings`, 68 tests dont 15 scénarios d'API et la
publication vers un dépôt nu) · interface (svelte-check 0 avertissement, 9 vitest) · publication vers un dépôt nu) · interface (svelte-check 0 avertissement, 9 vitest) ·
e2e 13 / 13 (C1–C12, M1). L'image Docker rejoue tout et a été essayée localement : e2e 13 / 13 (C1–C12, 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 santé, interface, volume non-root, clé de publication, lien admin initial (0600, absent
@@ -22,7 +22,7 @@ pas encore en ligne : les points des septième et huitième relectures, les **li
- Relectures indépendantes avant mise en ligne, chacune traitée avant de livrer : `1f9c241 : - Relectures indépendantes avant mise en ligne, chacune traitée avant de livrer : `1f9c241 :
ok`, `94907af : ok`, puis quatre « à reprendre » (`ac9c904`, `9203fd2`, `1eac6f8`, `6c0754a`), ok`, `94907af : ok`, puis quatre « à reprendre » (`ac9c904`, `9203fd2`, `1eac6f8`, `6c0754a`),
toutes sur la même question : une « coquille » (sans vote, publiée) qui change un sens, puis toutes sur la même question : une « coquille » (sans vote, publiée) qui change un sens, puis
`fa8e8dc : ok` (mis en ligne), `c3d6bf8 : ok`. La `fa8e8dc : ok` (mis en ligne), `c3d6bf8 : ok`, `fd19dbf : ok`. La
règle est désormais simple et explicable : **une coquille touche les lettres et les espaces, règle est désormais simple et explicable : **une coquille touche les lettres et les espaces,
rien d'autre** (CHANTIER.md, ADR 0003). Autres points traités : ordre des workflows, anciens rien d'autre** (CHANTIER.md, ADR 0003). Autres points traités : ordre des workflows, anciens
conteneurs en `restart: always`, vote réservé aux membres de la toile dans Monnaie libre (une conteneurs en `restart: always`, vote réservé aux membres de la toile dans Monnaie libre (une
+7 -3
View File
@@ -236,8 +236,9 @@ fn words(s: &str) -> Vec<String> {
/// « latransmission » → « la transmission ». A boundary moved is not a typo /// « latransmission » → « la transmission ». A boundary moved is not a typo
/// (« les avoir » → « le savoir »), nor « Si non » → « Sinon ». /// (« les avoir » → « le savoir »), nor « Si non » → « Sinon ».
fn only_glued_or_split(a: &[String], b: &[String]) -> bool { fn only_glued_or_split(a: &[String], b: &[String]) -> bool {
// Neither the word glued or split nor its pieces may be a negation (« ja mais »). // Neither the word glued or split nor its pieces may be a negation or a
let joined = |w: &str, x: &str, y: &str| ![w, x, y].iter().any(|t| NEGATIONS.contains(t)); // quantity (« ja mais », « sur tout » → « surtout », « par tout » → « partout »).
let joined = |w: &str, x: &str, y: &str| ![w, x, y].iter().any(|t| NEGATIONS.contains(t) || QUANTITIES.contains(t));
let (mut i, mut j, mut moves) = (0, 0, 0); let (mut i, mut j, mut moves) = (0, 0, 0);
while i < a.len() && j < b.len() { while i < a.len() && j < b.len() {
if a[i] == b[j] { if a[i] == b[j] {
@@ -424,7 +425,7 @@ mod tests {
} }
#[test] #[test]
fn a_negation_is_never_cut_nor_formed() { fn a_negation_or_a_quantity_is_never_cut_nor_formed() {
// Eighth independent review, 2026-10-21: the pieces were checked, not the word. // Eighth independent review, 2026-10-21: the pieces were checked, not the word.
for (old, new) in [ for (old, new) in [
("n'avoir jamais qu'un seul compte", "n'avoir ja mais qu'un seul compte"), ("n'avoir jamais qu'un seul compte", "n'avoir ja mais qu'un seul compte"),
@@ -432,6 +433,9 @@ mod tests {
("sans retour", "san s retour"), ("sans retour", "san s retour"),
("rien de plus", "ri en de plus"), ("rien de plus", "ri en de plus"),
("nowhere else", "now here else"), ("nowhere else", "now here else"),
// Ninth review: a quantity glued changes a scope.
("Je vérifie sur tout le territoire", "Je vérifie surtout le territoire"),
("par tout membre", "partout membre"),
] { ] {
assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}"); assert_eq!(classify(old, new), EditKind::Substantive, "{old} → {new}");
assert_eq!(classify(new, old), EditKind::Substantive, "{new} → {old}"); assert_eq!(classify(new, old), EditKind::Substantive, "{new} → {old}");
+1
View File
@@ -9,6 +9,7 @@
pub mod assist; pub mod assist;
pub mod compose; pub mod compose;
pub mod edit; pub mod edit;
pub mod names;
pub mod threshold; pub mod threshold;
pub use threshold::{FormulaParams, InertiaPreset, Tally}; pub use threshold::{FormulaParams, InertiaPreset, Tally};
+84
View File
@@ -0,0 +1,84 @@
//! Names people give themselves (team links): what is accepted, and the key two
//! names collide on.
//!
//! A name is shown as the author of proposals, revisions and signals, so nobody
//! may take a name already there, even dressed differently: « émile » for
//! « Émile », an accent written apart, a Cyrillic « в » or a Greek « Υ » for a
//! Latin letter, full-width « Yvv ». Hence a short alphabet — Latin letters
//! as French, English and Spanish write them, figures, space, hyphen,
//! apostrophe, dot — and a key that forgets case, accents and punctuation.
use unicode_normalization::UnicodeNormalization;
use unicode_normalization::char::is_combining_mark;
const MIN_NAME: usize = 2;
const MAX_NAME: usize = 60;
#[derive(Debug, PartialEq, Eq)]
pub struct Name {
/// As shown: NFC, spaces collapsed.
pub display: String,
/// What two names collide on.
pub key: String,
}
fn allowed(c: char) -> bool {
c.is_ascii_alphanumeric()
|| (c.is_alphabetic() && matches!(c as u32, 0xC0..=0x17F))
|| matches!(c, ' ' | '-' | '\'' | '’' | '.')
}
/// Lowercase, no accents, letters and figures only: « Émile », « EMILE »,
/// « É-mile » share the key « emile ».
pub fn key(s: &str) -> String {
s.nfkd().filter(|c| !is_combining_mark(*c)).flat_map(char::to_lowercase).filter(|c| c.is_alphanumeric()).collect()
}
/// A name a person may take, or `None`.
pub fn check(raw: &str) -> Option<Name> {
let raw = raw.trim();
// One line, ordinary spaces only (no line or paragraph separator).
if raw.chars().any(|c| c.is_whitespace() && c != ' ') {
return None;
}
let display = raw.nfc().collect::<String>().split(' ').filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" ");
let n = display.chars().count();
if !(MIN_NAME..=MAX_NAME).contains(&n) || !display.chars().all(allowed) {
return None;
}
let key = key(&display);
if key.chars().count() < MIN_NAME {
return None;
}
Some(Name { display, key })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ordinary_names() {
assert_eq!(check(" Émile Zola ").map(|n| n.display), Some("Émile Zola".into()));
assert_eq!(check("Jean-Marc O'Neil").map(|n| n.key), Some("jeanmarconeil".into()));
assert_eq!(check("Núria").map(|n| n.key), Some("nuria".into()));
assert!(check("Chiara 2").is_some());
}
#[test]
fn the_same_name_dressed_differently_collides() {
let emile = key("Émile");
for other in ["émile", "EMILE", "E\u{301}mile", "É-mile", "Emile."] {
assert_eq!(check(other).map(|n| n.key), Some(emile.clone()), "{other}");
}
}
#[test]
fn look_alikes_and_invisibles_are_refused() {
// Eighth and ninth independent reviews, 2026-10-21.
for name in ["Yvв", "Υvv", "Yvv", "Yv\u{2028}v", "Yvv\u{fff9}", "Yv\u{200b}v", "Y\nvv", "Y", "--", "Yvv 🙂"]
{
assert_eq!(check(name), None, "{name:?}");
}
}
}
@@ -0,0 +1,5 @@
-- The key a name collides on (ld_engine::names), set for the names people give
-- themselves through a team link: two people never share a name. Additive only.
ALTER TABLE identities ADD COLUMN name_key TEXT;
CREATE UNIQUE INDEX identities_name_key ON identities(name_key) WHERE name_key IS NOT NULL;
+5 -1
View File
@@ -132,7 +132,11 @@ pub async fn invite(
) -> AppResult<Json<serde_json::Value>> { ) -> AppResult<Json<serde_json::Value>> {
admin_only(&user)?; admin_only(&user)?;
let label = b.label.trim(); let label = b.label.trim();
if label.is_empty() || label.chars().count() > 60 || ld_engine::edit::has_hidden_controls(label) { if label.is_empty()
|| label.chars().count() > 60
|| label.chars().any(char::is_control)
|| ld_engine::edit::has_hidden_controls(label)
{
return Err(AppError::Invalid("label")); return Err(AppError::Invalid("label"));
} }
// A new link for an existing person is for invitation identities only: a link // A new link for an existing person is for invitation identities only: a link
+2 -3
View File
@@ -423,9 +423,8 @@ pub async fn submit(
if p.status != "draft" { if p.status != "draft" {
return Err(AppError::Conflict("not_draft")); return Err(AppError::Conflict("not_draft"));
} }
if edit::has_hidden_controls(&p.title) || edit::has_hidden_controls(&p.body) { // Drafts written under older rules are checked again before the vote opens.
return Err(AppError::Invalid("hidden_characters")); check_text(&p.title, &p.body, p.kind == "text")?;
}
let now = util::now(); let now = util::now();
sqlx::query("UPDATE proposals SET status = 'open', submitted_at = ?, updated_at = ? WHERE id = ?") sqlx::query("UPDATE proposals SET status = 'open', submitted_at = ?, updated_at = ? WHERE id = ?")
.bind(&now) .bind(&now)
+51 -27
View File
@@ -2,7 +2,8 @@
//! for a number of openings. Each opening asks the person's name and makes //! for a number of openings. Each opening asks the person's name and makes
//! **their own** invited identity, never an administrator: one person, one //! **their own** invited identity, never an administrator: one person, one
//! voice, and access management stays with trustinWallet's administrators. //! voice, and access management stays with trustinWallet's administrators.
//! Someone already signed in keeps their identity and receives the groups. //! Someone already signed in keeps their identity and receives the groups; it
//! takes a place only if it adds a group.
//! //!
//! Limit, assumed: a person who opens the link twice under two names has two //! Limit, assumed: a person who opens the link twice under two names has two
//! identities. Where invited identities vote (the test group), administrators //! identities. Where invited identities vote (the test group), administrators
@@ -16,13 +17,13 @@ use axum::Json;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::http::header::SET_COOKIE; use axum::http::header::SET_COOKIE;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use ld_engine::edit; use ld_engine::{edit, names};
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
const MAX_DAYS: i64 = 90; const MAX_DAYS: i64 = 90;
const MAX_USES: i64 = 500; const MAX_USES: i64 = 500;
const MAX_NAME: usize = 60; const MAX_LABEL: usize = 60;
pub fn link(state: &AppState, token: &str) -> String { pub fn link(state: &AppState, token: &str) -> String {
format!("{}/equipe#{token}", state.cfg.public_url) format!("{}/equipe#{token}", state.cfg.public_url)
@@ -91,37 +92,44 @@ pub struct JoinBody {
name: Option<String>, name: Option<String>,
} }
fn check_name(raw: &str) -> AppResult<&str> { /// Nobody joins under the name of someone already there, however it is dressed
let name = raw.trim(); /// (case, accents, punctuation): compared on `names::key`. The unique index on
let n = name.chars().count(); /// `name_key` settles two openings at the same instant.
if !(2..=MAX_NAME).contains(&n) || name.chars().any(char::is_control) || edit::has_hidden_controls(name) { async fn free_name(state: &AppState, raw: &str) -> AppResult<names::Name> {
return Err(AppError::Invalid("name")); let name = names::check(raw).ok_or(AppError::Invalid("name"))?;
let taken: Vec<String> = sqlx::query_scalar("SELECT display_name FROM identities").fetch_all(&state.db).await?;
if taken.iter().any(|t| names::key(t) == name.key) {
return Err(AppError::Conflict("name_taken"));
} }
Ok(name) Ok(name)
} }
/// The groups of the link this identity does not have yet.
async fn missing_groups(state: &AppState, identity_id: &str, groups: &[String]) -> AppResult<usize> {
let mine: Vec<String> = sqlx::query_scalar("SELECT group_slug FROM memberships WHERE identity_id = ?")
.bind(identity_id)
.fetch_all(&state.db)
.await?;
Ok(groups.iter().filter(|g| !mine.contains(g)).count())
}
pub async fn join( pub async fn join(
State(state): State<AppState>, State(state): State<AppState>,
Viewer(viewer): Viewer, Viewer(viewer): Viewer,
Json(b): Json<JoinBody>, Json(b): Json<JoinBody>,
) -> AppResult<Response> { ) -> AppResult<Response> {
let l = open_link(&state, &b.token).await?; let l = open_link(&state, &b.token).await?;
let groups = slugs(&l.groups);
// Checked before an opening is counted. // Checked before an opening is counted.
let name = match &viewer { let name = match &viewer {
Some(_) => None, Some(me) => {
None => { // Back with the session already open: nothing to add, no place taken.
let name = check_name(b.name.as_deref().unwrap_or_default())?; if missing_groups(&state, &me.id, &groups).await? == 0 {
// Nobody joins under the name of someone already there. return Ok(Json(json!({ "me": me })).into_response());
let taken: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM identities WHERE display_name = ? COLLATE NOCASE")
.bind(name)
.fetch_one(&state.db)
.await?;
if taken > 0 {
return Err(AppError::Conflict("name_taken"));
} }
Some(name.to_owned()) None
} }
None => Some(free_name(&state, b.name.as_deref().unwrap_or_default()).await?),
}; };
// Count the opening first: of two at the last place, one only goes on. // Count the opening first: of two at the last place, one only goes on.
let counted = sqlx::query( let counted = sqlx::query(
@@ -139,22 +147,34 @@ pub async fn join(
(Some(me), _) => (me.id, None), (Some(me), _) => (me.id, None),
(None, Some(name)) => { (None, Some(name)) => {
let id = format!("inv:{}", util::id()); let id = format!("inv:{}", util::id());
sqlx::query( let inserted = sqlx::query(
"INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at, team_link) "INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at, team_link, name_key)
VALUES (?, 'invite', ?, 0, 0, ?, ?)", VALUES (?, 'invite', ?, 0, 0, ?, ?, ?)",
) )
.bind(&id) .bind(&id)
.bind(&name) .bind(&name.display)
.bind(util::now()) .bind(util::now())
.bind(&l.token_hash) .bind(&l.token_hash)
.bind(&name.key)
.execute(&state.db) .execute(&state.db)
.await?; .await;
if let Err(sqlx::Error::Database(e)) = &inserted
&& e.is_unique_violation()
{
// Same name at the same instant: give the place back.
sqlx::query("UPDATE team_links SET uses = uses - 1 WHERE token_hash = ?")
.bind(&l.token_hash)
.execute(&state.db)
.await?;
return Err(AppError::Conflict("name_taken"));
}
inserted?;
let session = auth::open_session(&state, &id).await?; let session = auth::open_session(&state, &id).await?;
(id, Some(session)) (id, Some(session))
} }
(None, None) => return Err(AppError::Invalid("name")), (None, None) => return Err(AppError::Invalid("name")),
}; };
auth::grant_groups(&state, &identity_id, &slugs(&l.groups), Some(&l.created_by)).await?; auth::grant_groups(&state, &identity_id, &groups, Some(&l.created_by)).await?;
tracing::info!(link = %&l.token_hash[..8], identity = %identity_id, "team link opened"); tracing::info!(link = %&l.token_hash[..8], identity = %identity_id, "team link opened");
let me = auth::load_identity(&state, &identity_id).await?; let me = auth::load_identity(&state, &identity_id).await?;
let mut res = Json(json!({ "me": me })).into_response(); let mut res = Json(json!({ "me": me })).into_response();
@@ -185,7 +205,11 @@ pub async fn create(
) -> AppResult<Json<serde_json::Value>> { ) -> AppResult<Json<serde_json::Value>> {
admin_only(&user)?; admin_only(&user)?;
let label = b.label.trim(); let label = b.label.trim();
if label.is_empty() || label.chars().count() > MAX_NAME || edit::has_hidden_controls(label) { if label.is_empty()
|| label.chars().count() > MAX_LABEL
|| label.chars().any(char::is_control)
|| edit::has_hidden_controls(label)
{
return Err(AppError::Invalid("label")); return Err(AppError::Invalid("label"));
} }
if !(1..=MAX_DAYS).contains(&b.days) || !(1..=MAX_USES).contains(&b.max_uses) { if !(1..=MAX_DAYS).contains(&b.days) || !(1..=MAX_USES).contains(&b.max_uses) {
+13 -5
View File
@@ -507,8 +507,13 @@ async fn a_team_link_makes_one_identity_per_opening() {
// Nobody joins under a name already there; the refusal does not count as an opening. // Nobody joins under a name already there; the refusal does not count as an opening.
let mut second = admin.with(None); let mut second = admin.with(None);
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "luna" }))).await; for taken in ["luna", "LÚNA", "Lu-na", "Yvv"] {
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_taken"))); let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": taken }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_taken")), "{taken}");
}
// Look-alike letters from other alphabets are not a name.
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Lunа" }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::BAD_REQUEST, Some("name")));
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Paul" }))).await; let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "Paul" }))).await;
assert_eq!(s, StatusCode::OK, "{v}"); assert_eq!(s, StatusCode::OK, "{v}");
assert_ne!(v["me"]["id"].as_str(), Some(luna.as_str()), "one identity per opening"); assert_ne!(v["me"]["id"].as_str(), Some(luna.as_str()), "one identity per opening");
@@ -522,9 +527,12 @@ async fn a_team_link_makes_one_identity_per_opening() {
let token2 = v["link"].as_str().expect("link").split('#').nth(1).expect("token").to_owned(); let token2 = v["link"].as_str().expect("link").split('#').nth(1).expect("token").to_owned();
let (_, v) = first.call("POST", "/api/auth/team/peek", Some(json!({ "token": token2 }))).await; let (_, v) = first.call("POST", "/api/auth/team/peek", Some(json!({ "token": token2 }))).await;
assert_eq!(v["signedInAs"], "Luna"); assert_eq!(v["signedInAs"], "Luna");
let (s, v) = first.call("POST", "/api/auth/team", Some(json!({ "token": token2 }))).await; for _ in 0..3 {
assert_eq!(s, StatusCode::OK, "{v}"); // Back again with the session open: no place taken once the groups are there.
assert_eq!(v["me"]["id"].as_str(), Some(luna.as_str())); let (s, v) = first.call("POST", "/api/auth/team", Some(json!({ "token": token2 }))).await;
assert_eq!(s, StatusCode::OK, "{v}");
assert_eq!(v["me"]["id"].as_str(), Some(luna.as_str()));
}
let (s, _) = first.call("GET", "/api/groups/monnaie-libre", None).await; let (s, _) = first.call("GET", "/api/groups/monnaie-libre", None).await;
assert_eq!(s, StatusCode::OK); assert_eq!(s, StatusCode::OK);
+3 -2
View File
@@ -13,7 +13,7 @@ ou de fond), jamais contre la correction précédente :
- les adresses, les identifiants qui contiennent un chiffre et le contenu entre accents graves - les adresses, les identifiants qui contiennent un chiffre et le contenu entre accents graves
restent identiques à la lettre ; restent identiques à la lettre ;
- les espaces (espace, insécable, fine insécable) ne font que coller ou couper des mots, deux - les espaces (espace, insécable, fine insécable) ne font que coller ou couper des mots, deux
au plus, jamais en touchant une négation (ses morceaux ou le mot entier), jamais en déplaçant au plus, jamais en touchant une négation ou une quantité (ses morceaux ou le mot entier), jamais en déplaçant
une frontière ; sauts de ligne et indentation font partie de la forme ; une frontière ; sauts de ligne et indentation font partie de la forme ;
- le titre et le corps sont jugés chacun pour soi, puis ensemble ; - le titre et le corps sont jugés chacun pour soi, puis ensemble ;
- au plus deux mots changent, chacun d'un seul glissement de clavier au cœur du mot (une lettre - au plus deux mots changent, chacun d'un seul glissement de clavier au cœur du mot (une lettre
@@ -40,7 +40,8 @@ chariot ne sont plus des espaces libres, les lettres latines rares font partie d
La huitième (`c3d6bf8 : ok`) a laissé deux points, corrigés ensuite : une négation coupée en La huitième (`c3d6bf8 : ok`) a laissé deux points, corrigés ensuite : une négation coupée en
morceaux qui n'en sont pas (« ja mais »), des invisibles hors de la liste (trait d'union morceaux qui n'en sont pas (« ja mais »), des invisibles hors de la liste (trait d'union
conditionnel, étiquettes) ; et deux remarques : un saut de ligne dans un titre, une liste conditionnel, étiquettes) ; et deux remarques : un saut de ligne dans un titre, une liste
Markdown fusionnée par les espaces. Markdown fusionnée par les espaces. La neuvième (`fd19dbf : ok`) : un mot de quantité collé
(« sur tout » → « surtout », « par tout » → « partout ») — corrigé.
**Coût.** Une ponctuation oubliée, une emphase, un pluriel mal accordé demandent une **Coût.** Une ponctuation oubliée, une emphase, un pluriel mal accordé demandent une
proposition (ou une révision par l'auteur). On préfère ce sens d'erreur : un texte de référence proposition (ou une révision par l'auteur). On préfère ce sens d'erreur : un texte de référence
+6 -2
View File
@@ -13,8 +13,12 @@ Duniter peut se loger et tout faire — sauf la gestion des accès », qui revie
- **Liens d'équipe** (Yvv, 2026-10-21 : « je vais l'utiliser plein de fois, pendant tout le - **Liens d'équipe** (Yvv, 2026-10-21 : « je vais l'utiliser plein de fois, pendant tout le
dev, et la partager avec une équipe ») : un lien réutilisable jusqu'à une date (90 jours au dev, et la partager avec une équipe ») : un lien réutilisable jusqu'à une date (90 jours au
plus) et pour un nombre de personnes (500 au plus), révocable. Chaque ouverture demande un plus) et pour un nombre de personnes (500 au plus), révocable. Chaque ouverture demande un
nom et crée **sa propre** identité d'invitation, jamais administratrice ; un nom déjà pris nom et crée **sa propre** identité d'invitation, jamais administratrice. Un nom déjà pris est
est refusé ; une personne déjà connectée garde son identité et reçoit les groupes. refusé, quelle que soit sa tenue (casse, accents, ponctuation : `ld_engine::names`, index
unique) ; un nom s'écrit en lettres latines, chiffres, espace, trait d'union, apostrophe ou
point (pas de sosie cyrillique, grec ou pleine chasse). Une personne déjà connectée garde son
identité et reçoit les groupes ; elle ne prend une place que si le lien lui en ajoute un ; sur
un navigateur partagé, « Ce n'est pas moi » la déconnecte.
trustinWallet montre par quel lien chaque identité est venue. Limite assumée : une même trustinWallet montre par quel lien chaque identité est venue. Limite assumée : une même
personne qui l'ouvre deux fois, sous deux noms, a deux identités — sans effet dans Monnaie personne qui l'ouvre deux fois, sous deux noms, a deux identités — sans effet dans Monnaie
libre (seule la toile vote), à surveiller dans le groupe d'essai. libre (seule la toile vote), à surveiller dans le groupe d'essai.
@@ -107,9 +107,9 @@ change la portée d'une exception. La règle est donc construite à l'envers d'u
chiffre** (clés publiques, références) sont comparés à l'identique, casse comprise ; le contenu chiffre** (clés publiques, références) sont comparés à l'identique, casse comprise ; le contenu
entre accents graves aussi ; entre accents graves aussi ;
- les espaces ne font que **coller ou couper** des mots, deux au plus, jamais en touchant une - les espaces ne font que **coller ou couper** des mots, deux au plus, jamais en touchant une
négation — ni ses morceaux ni le mot entier (« Si non » → « Sinon », « ne certifier » → négation ou une quantité — ni ses morceaux ni le mot entier (« Si non » → « Sinon », « ne
« necertifier », « jamais » → « ja mais » sont du fond) —, et jamais déplacer une frontière certifier » → « necertifier », « jamais » → « ja mais », « sur tout » → « surtout » sont du
(« les avoir » → « le savoir » est du fond) ; fond) —, et jamais déplacer une frontière (« les avoir » → « le savoir » est du fond) ;
- le **titre** et le **corps** sont jugés chacun pour soi (un titre n'absorbe pas le début du - le **titre** et le **corps** sont jugés chacun pour soi (un titre n'absorbe pas le début du
corps), puis ensemble (deux mots au plus en tout) ; corps), puis ensemble (deux mots au plus en tout) ;
- un **glissement** = une lettre ajoutée, retirée, ou deux voisines inversées, jamais aux deux - un **glissement** = une lettre ajoutée, retirée, ou deux voisines inversées, jamais aux deux
@@ -20,7 +20,7 @@ Où tester : les contrôles qui écrivent (C5, C6, C7) se font dans le **groupe
| C9 | Les aperçus | « Si tout passe », puis « Si les bien cotées passent » | 33 articles avec la mention d'aperçu, puis 3 | | 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) | | 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à | | 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à |
| C12 | Lien d'équipe | administrateur : `/acces` → « Liens d'équipe », cocher le groupe d'essai, créer ; l'ouvrir dans deux navigateurs, sous deux noms | chacun arrive sur ses groupes sous son nom ; dans `/acces`, « 2 / 30 personnes » et deux identités « venue par » le lien ; un nom déjà pris est refusé ; « Révoquer » ferme le lien | | C12 | Lien d'équipe | administrateur : `/acces` → « Liens d'équipe », cocher le groupe d'essai, créer ; l'ouvrir dans deux navigateurs, sous deux noms | chacun arrive sur ses groupes sous son nom ; dans `/acces`, « 2 / 30 personnes » et deux identités « venue par » le lien ; un nom déjà pris est refusé, même autrement écrit (« luna » pour « Luna ») ; sur un navigateur où quelqu'un est connecté, « Ce n'est pas moi » le déconnecte ; « Révoquer » ferme le lien |
| M1 | Mobile | téléphone (ou fenêtre étroite) | menu en tiroir, les trois gestes visibles sans survol, le panneau s'ouvre en feuille | | 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 ## Au-delà de la liste
@@ -45,3 +45,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa
| 2026-10-21 | automate | local, après relecture indépendante | C1–C11, M1 | 12 / 12 verts | | 2026-10-21 | automate | local, après relecture indépendante | C1–C11, M1 | 12 / 12 verts |
| 2026-10-21 | automate | local, après la septième relecture (corrections) | C1–C11, M1 | 12 / 12 verts | | 2026-10-21 | automate | local, après la septième relecture (corrections) | C1–C11, M1 | 12 / 12 verts |
| 2026-10-21 | automate | local, liens d'équipe et huitième relecture | C1–C12, M1 | 13 / 13 verts | | 2026-10-21 | automate | local, liens d'équipe et huitième relecture | C1–C12, M1 | 13 / 13 verts |
| 2026-10-21 | automate | local, neuvième relecture (noms, places, quantités) | C1–C12, M1 | 13 / 13 verts |
+8
View File
@@ -175,6 +175,14 @@ test('C12 — lien d’équipe : chaque personne qui l’ouvre a sa propre ident
await expect(page).toHaveURL(/\/groupes$/); await expect(page).toHaveURL(/\/groupes$/);
await ctx.close(); await ctx.close();
} }
// A shared browser: the person signed in steps aside, the next one gives a name.
const shared = await person(browser, 'Zoé');
await shared.goto(`/equipe#${link.split('#')[1]}`);
await expect(shared.getByText('Ta session est ouverte sous le nom Zoé', { exact: false })).toBeVisible();
await shared.getByRole('button', { name: "Ce n'est pas moi : me déconnecter" }).click();
await shared.getByLabel('Ton nom').fill('luna');
await shared.getByRole('button', { name: 'Rejoindre' }).click();
await expect(shared.getByRole('alert')).toContainText('Ce nom est déjà pris');
await yvv.reload(); await yvv.reload();
await expect(team.getByText('2 / 30 personnes', { exact: false })).toBeVisible(); await expect(team.getByText('2 / 30 personnes', { exact: false })).toBeVisible();
await expect(yvv.getByText('venue par « Équipe de test »')).toHaveCount(2); await expect(yvv.getByText('venue par « Équipe de test »')).toHaveCount(2);
+4 -3
View File
@@ -66,6 +66,7 @@ export const en: Messages = {
'team.join': 'Join', 'team.join': 'Join',
'team.signed': 'Your session is open under the name {name}: these groups are added to your identity.', 'team.signed': 'Your session is open under the name {name}: these groups are added to your identity.',
'team.add': 'Add these groups', 'team.add': 'Add these groups',
'team.notme': "It's not me: sign out",
'groups.title': 'My groups', 'groups.title': 'My groups',
'groups.same.key': 'Groups that use the same key', 'groups.same.key': 'Groups that use the same key',
@@ -287,7 +288,7 @@ export const en: Messages = {
'access.invite.cancel': 'Cancel', 'access.invite.cancel': 'Cancel',
'access.invite.pending': 'Pending', 'access.invite.pending': 'Pending',
'access.team': 'Team links', 'access.team': 'Team links',
'access.team.intro': 'A link to share with a team, until a date. Each person who opens it gives their name and receives their own identity, never the administrator role.', 'access.team.intro': 'A link to share with a team, until a date. Each person who opens it gives their name and receives their own identity, never the administrator role. Revoking closes the link; people who already came keep their access, to be removed one by one.',
'access.team.label': 'For which team', 'access.team.label': 'For which team',
'access.team.days': 'Valid for how many days', 'access.team.days': 'Valid for how many days',
'access.team.uses': 'How many people at most', 'access.team.uses': 'How many people at most',
@@ -334,11 +335,11 @@ export const en: Messages = {
'error.g1_not_configured': "Ğ1 sign-in isn't active yet.", 'error.g1_not_configured': "Ğ1 sign-in isn't active yet.",
'error.invitation_invalid': 'This invitation link is no longer valid.', '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.identity_kind': 'A new link can only be for an invited person, never a Ğ1 identity.',
'error.hidden_characters': 'The text contains invisible characters (writing-direction overrides, zero-width spaces): remove them.', 'error.hidden_characters': 'The text contains invisible characters (writing-direction override, zero-width space, soft hyphen, emoji colour selector…): remove them.',
'error.changed': 'The text has changed in the meantime: reload it, then try again.', 'error.changed': 'The text has changed in the meantime: reload it, then try again.',
'error.wot_members_only': 'Here, only members of the Ğ1 web of trust vote.', 'error.wot_members_only': 'Here, only members of the Ğ1 web of trust vote.',
'error.label': 'The name is empty or too long (60 characters at most).', 'error.label': 'The name is empty or too long (60 characters at most).',
'error.name': 'Your name has 2 to 60 characters, with no invisible character.', 'error.name': 'Your name has 2 to 60 characters, on one line: letters, figures, spaces, hyphen, apostrophe or dot.',
'error.name_taken': 'This name is already taken. Choose another one, adding an initial for instance.', 'error.name_taken': 'This name is already taken. Choose another one, adding an initial for instance.',
'error.team_limits': 'A team link lasts 1 to 90 days, for 1 to 500 people.', 'error.team_limits': 'A team link lasts 1 to 90 days, for 1 to 500 people.',
'error.groups': 'Choose at least one group.', 'error.groups': 'Choose at least one group.',
+4 -3
View File
@@ -66,6 +66,7 @@ export const es: Messages = {
'team.join': 'Unirme', 'team.join': 'Unirme',
'team.signed': 'Tu sesión está abierta con el nombre {name}: estos grupos se añaden a tu identidad.', 'team.signed': 'Tu sesión está abierta con el nombre {name}: estos grupos se añaden a tu identidad.',
'team.add': 'Añadir estos grupos', 'team.add': 'Añadir estos grupos',
'team.notme': 'No soy yo: cerrar sesión',
'groups.title': 'Mis grupos', 'groups.title': 'Mis grupos',
'groups.same.key': 'Grupos que usan la misma clave', 'groups.same.key': 'Grupos que usan la misma clave',
@@ -287,7 +288,7 @@ export const es: Messages = {
'access.invite.cancel': 'Cancelar', 'access.invite.cancel': 'Cancelar',
'access.invite.pending': 'Pendiente', 'access.invite.pending': 'Pendiente',
'access.team': 'Enlaces de equipo', 'access.team': 'Enlaces de equipo',
'access.team.intro': 'Un enlace para compartir con un equipo, hasta una fecha. Cada persona que lo abre da su nombre y recibe su propia identidad, nunca el rol de administrador.', 'access.team.intro': 'Un enlace para compartir con un equipo, hasta una fecha. Cada persona que lo abre da su nombre y recibe su propia identidad, nunca el rol de administrador. Revocar cierra el enlace; las personas que ya llegaron conservan su acceso, que se retira una por una.',
'access.team.label': 'Para qué equipo', 'access.team.label': 'Para qué equipo',
'access.team.days': 'Válido cuántos días', 'access.team.days': 'Válido cuántos días',
'access.team.uses': 'Cuántas personas como máximo', 'access.team.uses': 'Cuántas personas como máximo',
@@ -334,11 +335,11 @@ export const es: Messages = {
'error.g1_not_configured': 'La conexión Ğ1 aún no está activa.', '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.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.identity_kind': 'Un nuevo enlace solo puede ser para una persona invitada, nunca para una identidad Ğ1.',
'error.hidden_characters': 'El texto contiene caracteres invisibles (inversión del sentido de escritura, espacios de ancho cero): quítalos.', 'error.hidden_characters': 'El texto contiene caracteres invisibles (inversión del sentido de escritura, espacio de ancho cero, guion condicional, selector de color de un emoji…): quítalos.',
'error.changed': 'El texto ha cambiado mientras tanto: vuelve a cargarlo y empieza de nuevo.', 'error.changed': 'El texto ha cambiado mientras tanto: vuelve a cargarlo y empieza de nuevo.',
'error.wot_members_only': 'Aquí solo votan los miembros de la red de confianza Ğ1.', 'error.wot_members_only': 'Aquí solo votan los miembros de la red de confianza Ğ1.',
'error.label': 'El nombre está vacío o es demasiado largo (60 caracteres como máximo).', 'error.label': 'El nombre está vacío o es demasiado largo (60 caracteres como máximo).',
'error.name': 'Tu nombre tiene de 2 a 60 caracteres, sin caracteres invisibles.', 'error.name': 'Tu nombre tiene de 2 a 60 caracteres, en una línea: letras, cifras, espacios, guion, apóstrofo o punto.',
'error.name_taken': 'Este nombre ya está ocupado. Elige otro, añadiendo una inicial por ejemplo.', 'error.name_taken': 'Este nombre ya está ocupado. Elige otro, añadiendo una inicial por ejemplo.',
'error.team_limits': 'Un enlace de equipo vale de 1 a 90 días, para 1 a 500 personas.', 'error.team_limits': 'Un enlace de equipo vale de 1 a 90 días, para 1 a 500 personas.',
'error.groups': 'Elige al menos un grupo.', 'error.groups': 'Elige al menos un grupo.',
+4 -3
View File
@@ -69,6 +69,7 @@ export const fr = {
'team.join': 'Rejoindre', 'team.join': 'Rejoindre',
'team.signed': 'Ta session est ouverte sous le nom {name} : ces groupes s’ajoutent à ton identité.', 'team.signed': 'Ta session est ouverte sous le nom {name} : ces groupes s’ajoutent à ton identité.',
'team.add': 'Ajouter ces groupes', 'team.add': 'Ajouter ces groupes',
'team.notme': "Ce n'est pas moi : me déconnecter",
'groups.title': 'Mes groupes', 'groups.title': 'Mes groupes',
'groups.same.key': 'Groupes qui utilisent la même clé', 'groups.same.key': 'Groupes qui utilisent la même clé',
@@ -290,7 +291,7 @@ export const fr = {
'access.invite.cancel': 'Annuler', 'access.invite.cancel': 'Annuler',
'access.invite.pending': 'En attente', 'access.invite.pending': 'En attente',
'access.team': "Liens d'équipe", 'access.team': "Liens d'équipe",
'access.team.intro': "Un lien à partager avec une équipe, jusqu'à une date. Chaque personne qui l'ouvre donne son nom et reçoit sa propre identité, jamais le rôle d'administrateur.", 'access.team.intro': "Un lien à partager avec une équipe, jusqu'à une date. Chaque personne qui l'ouvre donne son nom et reçoit sa propre identité, jamais le rôle d'administrateur. Révoquer ferme le lien ; les personnes déjà venues gardent leurs accès, à retirer une par une.",
'access.team.label': 'Pour quelle équipe', 'access.team.label': 'Pour quelle équipe',
'access.team.days': 'Valable combien de jours', 'access.team.days': 'Valable combien de jours',
'access.team.uses': 'Combien de personnes au plus', 'access.team.uses': 'Combien de personnes au plus',
@@ -337,11 +338,11 @@ export const fr = {
'error.g1_not_configured': "La connexion Ğ1 n'est pas encore active.", 'error.g1_not_configured': "La connexion Ğ1 n'est pas encore active.",
'error.invitation_invalid': "Ce lien d'invitation n'est plus valable.", '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.identity_kind': "Un nouveau lien ne peut viser qu'une personne invitée, jamais une identité Ğ1.",
'error.hidden_characters': 'Le texte contient des caractères invisibles (inversion du sens d\'écriture, espaces de largeur nulle) : retire-les.', 'error.hidden_characters': "Le texte contient des caractères invisibles (inversion du sens d'écriture, espace de largeur nulle, trait d'union conditionnel, sélecteur de couleur d'un emoji…) : retire-les.",
'error.changed': 'Le texte a changé entre-temps : recharge-le, puis recommence.', 'error.changed': 'Le texte a changé entre-temps : recharge-le, puis recommence.',
'error.wot_members_only': 'Ici, seuls les membres de la toile de confiance Ğ1 votent.', 'error.wot_members_only': 'Ici, seuls les membres de la toile de confiance Ğ1 votent.',
'error.label': 'Le nom est vide ou trop long (60 signes au plus).', 'error.label': 'Le nom est vide ou trop long (60 signes au plus).',
'error.name': 'Ton nom fait de 2 à 60 signes, sans caractère invisible.', 'error.name': "Ton nom fait de 2 à 60 signes, sur une ligne : lettres, chiffres, espaces, trait d'union, apostrophe ou point.",
'error.name_taken': 'Ce nom est déjà pris. Choisis-en un autre, en ajoutant une initiale par exemple.', 'error.name_taken': 'Ce nom est déjà pris. Choisis-en un autre, en ajoutant une initiale par exemple.',
'error.team_limits': "Un lien d'équipe vaut de 1 à 90 jours, pour 1 à 500 personnes.", 'error.team_limits': "Un lien d'équipe vaut de 1 à 90 jours, pour 1 à 500 personnes.",
'error.groups': 'Choisis au moins un groupe.', 'error.groups': 'Choisis au moins un groupe.',
+26 -3
View File
@@ -20,9 +20,10 @@
let error = $state(''); let error = $state('');
let busy = $state(false); let busy = $state(false);
onMount(async () => { async function read() {
// The token travels in the fragment: it never reaches a server log. // The token travels in the fragment: it never reaches a server log.
token = location.hash.slice(1).trim(); const fragment = location.hash.slice(1).trim();
if (fragment) token = fragment;
history.replaceState(null, '', '/equipe'); history.replaceState(null, '', '/equipe');
if (!/^[0-9a-f]{64}$/.test(token)) { if (!/^[0-9a-f]{64}$/.test(token)) {
phase = 'missing'; phase = 'missing';
@@ -34,8 +35,22 @@
} catch { } catch {
phase = 'invalid'; phase = 'invalid';
} }
}
onMount(() => {
void read();
// A whole link pasted into this same tab only changes the fragment.
const again = () => void read();
window.addEventListener('hashchange', again);
return () => window.removeEventListener('hashchange', again);
}); });
// A shared browser: whoever is signed in is not the person opening the link.
async function notMe() {
await session.logout();
await read();
}
async function join(e: SubmitEvent) { async function join(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
error = ''; error = '';
@@ -62,7 +77,10 @@
<form class="card block" onsubmit={join}> <form class="card block" onsubmit={join}>
{#if link.signedInAs} {#if link.signedInAs}
<p>{t('team.signed', { name: link.signedInAs })}</p> <p>{t('team.signed', { name: link.signedInAs })}</p>
<button class="btn primary" type="submit" disabled={busy}>{t('team.add')}</button> <div class="row">
<button class="btn primary" type="submit" disabled={busy}>{t('team.add')}</button>
<button class="btn quiet" type="button" onclick={notMe}>{t('team.notme')}</button>
</div>
{:else} {:else}
<label class="field"> <label class="field">
<span>{t('team.name')}</span> <span>{t('team.name')}</span>
@@ -102,6 +120,11 @@
.block > .btn { .block > .btn {
justify-self: start; justify-self: start;
} }
.row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.small { .small {
font-size: 0.84rem; font-size: 0.84rem;
} }