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
+7 -3
View File
@@ -236,8 +236,9 @@ fn words(s: &str) -> Vec<String> {
/// « latransmission » → « la transmission ». A boundary moved is not a typo
/// (« les avoir » → « le savoir »), nor « Si non » → « Sinon ».
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 »).
let joined = |w: &str, x: &str, y: &str| ![w, x, y].iter().any(|t| NEGATIONS.contains(t));
// Neither the word glued or split nor its pieces may be a negation or a
// 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);
while i < a.len() && j < b.len() {
if a[i] == b[j] {
@@ -424,7 +425,7 @@ mod tests {
}
#[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.
for (old, new) in [
("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"),
("rien de plus", "ri en de plus"),
("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(new, old), EditKind::Substantive, "{new} → {old}");
+1
View File
@@ -9,6 +9,7 @@
pub mod assist;
pub mod compose;
pub mod edit;
pub mod names;
pub mod threshold;
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>> {
admin_only(&user)?;
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"));
}
// 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" {
return Err(AppError::Conflict("not_draft"));
}
if edit::has_hidden_controls(&p.title) || edit::has_hidden_controls(&p.body) {
return Err(AppError::Invalid("hidden_characters"));
}
// Drafts written under older rules are checked again before the vote opens.
check_text(&p.title, &p.body, p.kind == "text")?;
let now = util::now();
sqlx::query("UPDATE proposals SET status = 'open', submitted_at = ?, updated_at = ? WHERE id = ?")
.bind(&now)
+51 -27
View File
@@ -2,7 +2,8 @@
//! for a number of openings. Each opening asks the person's name and makes
//! **their own** invited identity, never an administrator: one person, one
//! 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
//! identities. Where invited identities vote (the test group), administrators
@@ -16,13 +17,13 @@ use axum::Json;
use axum::extract::{Path, State};
use axum::http::header::SET_COOKIE;
use axum::response::{IntoResponse, Response};
use ld_engine::edit;
use ld_engine::{edit, names};
use serde::Deserialize;
use serde_json::json;
const MAX_DAYS: i64 = 90;
const MAX_USES: i64 = 500;
const MAX_NAME: usize = 60;
const MAX_LABEL: usize = 60;
pub fn link(state: &AppState, token: &str) -> String {
format!("{}/equipe#{token}", state.cfg.public_url)
@@ -91,37 +92,44 @@ pub struct JoinBody {
name: Option<String>,
}
fn check_name(raw: &str) -> AppResult<&str> {
let name = raw.trim();
let n = name.chars().count();
if !(2..=MAX_NAME).contains(&n) || name.chars().any(char::is_control) || edit::has_hidden_controls(name) {
return Err(AppError::Invalid("name"));
/// Nobody joins under the name of someone already there, however it is dressed
/// (case, accents, punctuation): compared on `names::key`. The unique index on
/// `name_key` settles two openings at the same instant.
async fn free_name(state: &AppState, raw: &str) -> AppResult<names::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)
}
/// 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(
State(state): State<AppState>,
Viewer(viewer): Viewer,
Json(b): Json<JoinBody>,
) -> AppResult<Response> {
let l = open_link(&state, &b.token).await?;
let groups = slugs(&l.groups);
// Checked before an opening is counted.
let name = match &viewer {
Some(_) => None,
None => {
let name = check_name(b.name.as_deref().unwrap_or_default())?;
// Nobody joins under the name of someone already there.
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(me) => {
// Back with the session already open: nothing to add, no place taken.
if missing_groups(&state, &me.id, &groups).await? == 0 {
return Ok(Json(json!({ "me": me })).into_response());
}
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.
let counted = sqlx::query(
@@ -139,22 +147,34 @@ pub async fn join(
(Some(me), _) => (me.id, None),
(None, Some(name)) => {
let id = format!("inv:{}", util::id());
sqlx::query(
"INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at, team_link)
VALUES (?, 'invite', ?, 0, 0, ?, ?)",
let inserted = sqlx::query(
"INSERT INTO identities (id, kind, display_name, g1_member, admin, created_at, team_link, name_key)
VALUES (?, 'invite', ?, 0, 0, ?, ?, ?)",
)
.bind(&id)
.bind(&name)
.bind(&name.display)
.bind(util::now())
.bind(&l.token_hash)
.bind(&name.key)
.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?;
(id, Some(session))
}
(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");
let me = auth::load_identity(&state, &identity_id).await?;
let mut res = Json(json!({ "me": me })).into_response();
@@ -185,7 +205,11 @@ pub async fn create(
) -> AppResult<Json<serde_json::Value>> {
admin_only(&user)?;
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"));
}
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.
let mut second = admin.with(None);
let (s, v) = second.call("POST", "/api/auth/team", Some(json!({ "token": token, "name": "luna" }))).await;
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("name_taken")));
for taken in ["luna", "LÚNA", "Lu-na", "Yvv"] {
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;
assert_eq!(s, StatusCode::OK, "{v}");
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 (_, v) = first.call("POST", "/api/auth/team/peek", Some(json!({ "token": token2 }))).await;
assert_eq!(v["signedInAs"], "Luna");
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()));
for _ in 0..3 {
// Back again with the session open: no place taken once the groups are there.
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;
assert_eq!(s, StatusCode::OK);