Commit Graph
11 Commits
Author SHA1 Message Date
GGThed 70db8a7491 feat(obs): nommer chaque requete, et rendre les pages d erreur audibles
OBS-005. Un 500 dans errors.log et les six lignes de app.log qui y menent
n etaient relies que par leur horodatage — ce qui n est pas une relation
des que le serveur traite plus d une requete a la fois. Et un utilisateur
qui dit « ca a plante quand j ai clique sur enregistrer » ne donnait a
personne de quoi chercher.

Chaque requete recoit un identifiant, porte par toutes les lignes de
journal qu elle produit, renvoye en X-Request-Id, et affiche sur la page
500 comme reference a citer.

Il est **genere**, jamais lu depuis un en-tete entrant. Accepter celui du
client serait pratique pour tracer a travers nginx, et permettrait aussi a
n importe qui d ecrire du texte arbitraire — retours a la ligne compris —
dans le fichier de journal. C est ainsi qu un journal cesse d etre une
preuve. Il n y a de toute facon aucun proxy de confiance tant qu OPS-002
est ouvert.

Le test correspondant assure sur l alphabet plutot qu en envoyant un
retour a la ligne : le client de test de Werkzeug refuse d emettre un tel
en-tete, donc l attaque ne peut meme pas etre construite par la, ce qui ne
prouverait rien sur l application.

**Defaut trouve en chemin, et repare.** Les cinq gabarits d erreur
remplissent le bloc `content`, qui n existait que dans la branche
authentifiee de la mise en page. Un visiteur deconnecte tombant sur une
erreur — donc typiquement sur la page de connexion — recevait le logo, le
selecteur de langue, et **aucun message**. Le code de statut etait bon, les
journaux etaient bons, la page etait vide. Le <title> disait quand meme
« 404 », ce qui explique en grande partie que personne ne l ait vu.

Le bloc est desormais rendu dans les deux branches via self.content(),
Jinja refusant deux blocs de meme nom. Un seul cote du if s execute, donc
jamais de double rendu — et c est assure, pas suppose.

550 tests.
2026-08-11 15:30:05 -04:00
GGThed c5e5cfa014 refactor(validation): un schema aux frontieres tryout et evaluation
ARCH-005, seconde moitie. Meme forme que pour les matchs : des champs lus
a la main sur request.form, deux verifies et le reste cru sur parole.

Cote tryout :
- game pilote la liste des postes et les champs de gamertag montres au
  joueur qui s inscrit. Il etait accepte tel quel : une faute de frappe
  produisait une selection pour laquelle personne ne pouvait etre evalue ;
- max_players etait int(x) if x else None — un 500 sur « twelve », et un
  -3 accepte sans broncher ;
- coach_ids etait charge par User.id.in_(...) sans filtre de role. Une
  soumission fabriquee a la main pouvait donc nommer un joueur coach d une
  selection, ce qui est une attribution de droits : gerer la selection et
  evaluer ses joueurs. Ce n est pas un formulaire que l interface propose,
  et ca marchait.

Cote evaluation, validate_score transformait tout ce qui sortait de 1..10
— 11, 0, « bien » — en None. Le critere disparaissait de la moyenne et la
page annoncait l evaluation enregistree. Rien ne distinguait « non
evalue » de « evalue, refuse, et oublie ».

Le calcul de la moyenne remonte sur le modele, en Evaluation.overall_from
et apply_scores. Il vivait dans la route, additionnant neuf variables
locales, et ne pouvait pas etre exerce sans requete HTTP, session
authentifiee et base — c est TEST-002, et c est pourquoi le calcul des
scores n avait aucun test. Il en a maintenant six, sans rien monter.

Une precision qui compte : aucun critere rempli donne None, pas 0. La
grille commence a 1, donc un zero serait une note qu aucun joueur ne peut
recevoir, et qui le classerait sous tout le monde dans la liste.

Les neuf criteres sont ecrits en toutes lettres dans le schema plutot que
generes depuis le modele — un schema se lit — et un test verifie que les
deux listes coincident. C est la garde qui empeche la derive, pas
l astuce.

Douze chaines traduites, dont trois que pybabel avait devinees en fuzzy :
une entree fuzzy est ignoree a l execution, le piege est consigne dans
docs/translations.md.

30 tests neufs. 477 au total.
2026-08-11 13:35:05 -04:00
GGThed d8541678a6 fix(auth): retirer un CAPTCHA qui ne protegeait rien, filtrer autrement
SEC-AUTH-008. L addition a deux operandes entre 1 et 10 avait dix-neuf
reponses possibles et se resolvait en lisant la question comme une chaine.
Elle n arretait aucune inscription automatisee. Elle ajoutait en revanche
une etape a chaque personne, lecteur d ecran compris, contre une apparence
de protection — ce qui est pire que rien, puisque ca se compte comme une
protection.

L autre branche proposee par l audit etait un vrai service de CAPTCHA :
un tiers, une cle d API, une requete a chaque affichage, et un script
etranger remis dans script-src, defaisant le travail qui a ferme
SEC-WEB-001. Disproportionne pour le site d un club.

A la place, deux verifications invisibles pour un visiteur : un champ
piege, cache par la feuille de style et hors du parcours clavier, qu un
robot remplisseur complete et qu une personne ne voit jamais ; et un delai
minimal entre la remise du formulaire et son retour, l horodatage etant
dans la session signee et non dans un champ.

Le plafond est dit dans le code plutot que sous-entendu : ceci arrete le
pourriel de masse, pas quelqu un qui lit la page. Ce qui filtrerait
vraiment les inscriptions serait l activation des comptes par le staff —
is_active_account vaut True par defaut. C est une decision de produit.

L angle « session forgeable » du constat tombe : avec SECRET_KEY
compromise (SEC-001), on forge une session connectee sur n importe quel
compte et on n a aucune raison de s inscrire.

Au passage, ARCH-005 en partie : le bloc « regenerer, purger les mots de
passe, re-rendre » etait recopie quatre fois. Un seul helper, et la purge
des mots de passe ne peut plus etre oubliee dans la cinquieme copie.

Les refus sont journalises avec leur motif — c est le seul endroit ou un
abus du formulaire devient visible — mais restent indistinguables pour
l expediteur : nommer la regle indique comment la contourner.

429 tests.
2026-08-11 12:14:50 -04:00
GGThedandClaude Opus 5 8e3865f557 chore(lint): elargir les regles ruff et rendre le format bloquant en CI
QUA-002, seconde moitie. Le depot etant formate, l elargissement porte sur
des defauts et non sur du brassage.

Ajoute a la selection : B (bugbear), C4, RET, SIM, UP. Le lot entier n a
produit que 24 signalements sur 76 fichiers -- le code etait plus propre
que l audit ne le craignait. Neuf corriges automatiquement, quinze a la
main.

SIM108 est ignore : forcer un ternaire se lit moins bien que le if/else
qu il remplace, au seul endroit ou il se declenche.

isort (I) n est PAS active. Il reordonnerait les imports de 48 fichiers,
soit une seconde passe de pur brassage juste apres le commit de formatage.
A faire, mais seul.

Deux vrais defauts trouves par les nouvelles regles
  - team_matches.edit_match faisait `except ValueError: pass` sur l heure de
    debut et l heure de fin, trois lignes sous un champ date qui, lui,
    signale et redirige. Une heure mal saisie etait donc acceptee par le
    formulaire, jetee, l ancienne valeur conservee -- et la page annoncait
    la reussite. Meme traitement que la date desormais.
  - backup.py levait BackupError depuis deux blocs `except` sans `from`,
    ce qui perdait la cause d origine dans la trace.

Ainsi que : un `return` explicite dans force_https, `%`-formatage remplace
dans log_auth_event (operations de chaine avant journalisation, pas des
gabarits de logger -- la redaction n est pas affectee), une compréhension
inutile, un `set(...)` en compréhension d ensemble, `open(..., 'r')`, une
variable de boucle inutilisee, et `contextlib.suppress` dans conftest.

CI : `ruff format --check` remplace le commentaire qui expliquait pourquoi
il etait absent.

263 tests passent. Les deux nouveaux messages sont traduits ; attention,
pybabel les avait apparies en `fuzzy` avec des entrees « date » existantes,
et une entree fuzzy est ignoree a l execution.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 15:59:40 -04:00
GGThedandClaude Opus 5 20158a9e7a fix(security): verifier les fichiers televerses et retirer un intent Discord
SEC-021 -- rien ne validait le contrat signe
upload_signed_contract se contentait d un nom de fichier non vide.
ALLOWED_SIGNED_EXTENSIONS etait declaree juste a cote et jamais lue. Le
fichier atterrissait sur le disque sous un nom que download_signed_contract
sert ensuite : ce qu un joueur televerse est ce qu un gerant ouvre.

Le nom seul ne suffisait pas non plus cote upload_contract, qui verifiait
`.pdf` en fin de chaine -- payload.pdf ne dit rien des octets.

pdf_upload_error() couvre les deux routes : extension dans la liste, puis
signature %PDF- en tete de flux. Le flux est rembobine, l appelant
enregistre toujours le fichier entier.

OPS-014 -- intent Discord privilegie inutile
Le bot demandait GUILD_MEMBERS et ne s en servait pas : rien n enumere ni
ne recherche de membre de serveur, les personnes sont jointes par le
discord_user_id enregistre sur leur compte. Retire.

message_content reste : on_raw_reaction_add lit le texte de la reponse d un
coach pour consigner un motif de refus.

CI-003 et CI-005 sont deja appliques (permissions: contents: read,
checkout@v4, exclusions de deploiement). L epinglage par SHA des actions
n est pas fait : ce sont des actions GitHub de premiere partie, et
l epingler sans Dependabot echange une exposition contre une autre.

11 tests, dont deux verifient que signer un contrat marche toujours et
qu un autre joueur ne peut pas le faire.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 15:09:13 -04:00
GGThedandClaude Opus 5 835a394d3f fix(auth): enumeration, verrou, deconnexion en GET, redirection ouverte
Quatre constats de la liste des gains rapides, tous sur auth.py.

SEC-017 -- enumeration de comptes
Le formulaire repondait "il vous reste 3 tentative(s)" a un compte connu et
"verifiez le nom d utilisateur et le mot de passe" a un inconnu. Le decompte
lui-meme etait la fuite : la meme information, etalee sur cinq requetes.
S y ajoutait un ecart de temps de reponse, check_password n etant appele que
si la ligne existait -- scrypt est cher, l ecart est mesurable.

Un seul message pour tous les echecs, et la verification s execute
desormais sur les deux branches : contre un hachage aleatoire tire une fois
par processus quand l identifiant n existe pas.

SEC-018 -- verrou de compte declenchable par un tiers
Cinq mauvaises reponses mettaient un compte connu hors service pendant
quinze minutes, indefiniment renouvelables. Sur un compte president, c est
toute l administration, et aucun ecran ne permettait de defaire.

Le compteur et la fenetre restent -- ce sont la trace qu un administrateur
lit quand un compte est pilonne, et la fenetre double jusqu a un plafond.
Ce qui change : de bons identifiants passent, fenetre ouverte ou non, et
remettent le compteur a zero. Le proprietaire du compte ne peut plus etre
bloque par un tiers.

Ce que cela coute, dit franchement : un verrou dur n arretait de toute
facon pas un attaquant ayant trouve le mot de passe -- il lui suffisait
d attendre. Le debit de tentatives reste borne par la limite de 10/minute
par IP. Une limite par couple (compte, IP) demanderait un stockage dedie ;
elle attend Alembic.

L evenement account.locked devient account.throttled : "locked" affirmait
plus que ce qui se passe.

SEC-019 -- deconnexion en GET
/auth/logout n avait pas de methods, donc GET, donc hors protection CSRF :
n importe quelle page pouvait deconnecter un visiteur avec une balise img.
La route passe en POST et l entree de navigation devient un formulaire avec
jeton. Le style suit -- les regles .nav-links visaient les liens seuls.

SEC-020 -- validation de redirection
is_safe_url interrogeait urlparse().netloc. urlparse lit /\evil.com comme
un chemin, sans netloc ; plusieurs navigateurs normalisent l antislash en
barre oblique avant de resoudre, ce qui en fait //evil.com. La fonction
refuse maintenant antislash et caracteres de controle, exige un chemin
enracine, et compare l origine explicitement.

Le xfail(strict) qui documentait SEC-017 est leve. 40 tests dans
test_auth_session.py, dont la table des cibles refusees.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-08 15:05:13 -04:00
GGThedandClaude Opus 5 37f70c89e3 feat(i18n): traduire les messages flash et de validation
198 appels flash dans les sept modules de routes, plus les 23 messages de
validation de app/validators.py. Le catalogue compte desormais 631 chaines,
aucune non traduite.

validators.py utilise lazy_gettext : les champs de schema sont construits a
l'import, donc avant qu'une requete existe. Un gettext ordinaire s'y
resoudrait une seule fois, dans la langue active au demarrage.

Un bug introduit par la conversion, puis corrige
  Le convertisseur automatique ne voyait que le premier litteral d'un appel
  flash, ce qui a casse deux chaines concatenees sur plusieurs lignes dans
  users.py -- le resultat n'etait meme pas du Python valide. Ma premiere
  verification ne l'a pas vu : elle enchainait py_compile sur head, or head
  reussit toujours, donc le "OK" s'affichait quoi qu'il arrive. Les deux
  appels sont reecrits et la verification refaite correctement.

Un bug plus interessant, revele par le test de fumee
  La langue choisie ne survivait pas a la connexion. login() et logout()
  appellent tous deux session.clear() -- l'un contre la fixation de session,
  l'autre pour terminer la session -- et le choix de langue partait avec le
  reste. Concretement : quelqu'un qui lisait la page de connexion en anglais
  se retrouvait en francais des qu'il se connectait.

  La langue est une preference d'affichage, pas un etat appartenant au
  compte. Les deux endroits la reportent maintenant explicitement, a cote du
  jeton CSRF. Quatre tests couvrent le cas, dont un qui verifie que corriger
  une cle preservee n'a pas fait tomber l'autre.

Detail de nommage : le convertisseur avait genere %(value)s pour une
expression conditionnelle, ce qui n'aide pas un traducteur. Renomme en
%(player)s.

Les 14 traductions ecrites avec une apostrophe droite sont normalisees en
apostrophe typographique. Sans consequence en HTML, ou &#39; s'affiche
correctement -- mais les blocs <script> ne decodent pas les entites, et
autant que le catalogue soit homogene.

200 tests.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 22:12:27 -04:00
GGThedandClaude Opus 5 80bc1413f1 feat(i18n): traduire l'ensemble des gabarits
Le site s'affiche desormais integralement en francais. 483 chaines, aucune
non traduite, dans les deux catalogues.

Couvert : navigation et mise en page partagee, connexion, les cinq pages
d'erreur, et les 29 gabarits de pages.

Methode
  Marquage semi-automatique, conservateur par construction : seuls des
  motifs sans ambiguite sont balises -- contenu de balises de texte,
  attributs placeholder/title/aria-label, texte suivant une icone, blocs
  title et page_title -- et tout contenu comportant du Jinja ou du balisage
  imbrique est laisse de cote. Deux passes, la seconde pour td, li, h6,
  strong, em et caption.

  97 entrees etaient marquees fuzzy par pybabel update, c'est-a-dire
  devinees par similarite. Une entree fuzzy est **ignoree a l'execution** :
  elles ont donc ete traitees comme non traduites, et le drapeau retire une
  fois la traduction ecrite.

Deux pieges rencontres, tous deux documentes dans docs/translations.md

  Une entite HTML n'est pas du texte. &times;, utilise comme libelle de
  bouton de fermeture, a ete balise par la passe automatique. Jinja
  l'echappait alors en &amp;times; et le bouton aurait affiche le texte
  litteral &times; au lieu de la croix. Corrige dans cinq gabarits.

  Huit chaines subsistent dans des blocs <script>. Elles fonctionnent mais
  restent fragiles : Jinja echappe & < > " ' dans un bloc script, et ces
  entites n'y sont pas decodees -- une traduction contenant une apostrophe
  droite arriverait dans la chaine JavaScript sous la forme &#39;. Le
  francais retenu utilise des apostrophes typographiques, non echappees,
  donc l'existant est sur. Toute nouvelle chaine a cet endroit devra passer
  par un attribut data- ou un bloc <script type="application/json">.

Vocabulaire retenu -- a valider avec le club
  tryout -> selection · manager -> gerant · coach -> coach (conserve, terme
  d'usage en e-sport) · scout -> recruteur · email -> courriel · scrim ->
  scrim. Les noms de jeux et les postes (Support, Duelist, AWPer) restent
  en anglais : ce sont les termes employes par les joueurs.
  Ces choix vivent dans un seul catalogue, donc chacun se change en un
  endroit.

Reste a faire : les messages flash hors routes/auth.py, et les messages de
validation de app/validators.py, qui necessitent lazy_gettext puisque les
champs de schema sont construits a l'import.

Verifie : 13 pages parcourues dans les deux langues, aucune ne laisse de
marqueur Jinja non evalue ni d'entite doublement echappee. 193 tests.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 21:43:29 -04:00
GGThedandClaude Opus 5 09453199b8 refactor(csp): migrer dix gabarits vers les comportements declaratifs
OPS-010, suite. 76 gestionnaires en ligne -> 52, dans 5 gabarits au lieu de
15. Le cliquet de tests/test_csp.py est abaisse en consequence.

Six motifs recurrents, generalises dans main.js plutot que traites un a un
  data-action        clic, resolu par un ecouteur delegue
  data-change        changement -- attribut distinct du clic, sans quoi un
                     <select> declencherait son gestionnaire des le clic
                     qui l'ouvre
  data-confirm       confirmation avant un envoi destructeur, en
                     remplacement de onsubmit="return confirm(...)". Le
                     texte reste dans le markup, donc traduisible.
  data-navigate      navigation sur selection, {value} etant encode
  remove-element     suppression d'un ancetre designe par data-remove
  history-back       retour arriere

registerActions()
  Les fonctions propres a une page vivent dans son bloc de script et ne
  peuvent donc pas figurer dans la table globale. Chaque page declare les
  siennes, l'ecouteur delegue reste unique.

Cas particulier, coach_availability
  Le gestionnaire y etait construit dans une chaine JavaScript, au moment
  de generer la grille de creneaux. Le markup portait deja data-day et
  data-time : toggleSlot lit desormais ses arguments depuis l'element, ce
  qui supprime a la fois l'attribut en ligne et la concatenation.

Gabarits migres : my_teams, register, users, view_user, one_on_one,
coach_availability, profile, team_matches, notes, contracts.

Restent, par ordre decroissant : match_form 13, calendar 11, teams 11,
evaluate_player 9, view_tryout 8.

Syntaxe JavaScript de chaque bloc modifie verifiee par node --check.

A noter : la traduction de ces dix gabarits reste a faire. Seules les deux
chaines devenues visibles dans le markup au cours de cette migration -- les
messages de confirmation de suppression -- sont balisees et traduites.

192 tests.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 20:51:07 -04:00
GGThedandClaude Opus 5 15bfebf4fc feat(csp): infrastructure de sortie de unsafe-inline, et couche partagee migree
SEC-WEB-001 / OPS-010. script-src porte toujours 'unsafe-inline' : c'est
pour cela que le XSS stocke de SEC-XSS-001 s'executait au lieu d'etre
bloque. Le retirer n'est pas un changement d'une ligne.

Ce qui bloque reellement
  Un nonce autorise des elements <script> ; il ne peut rien pour un
  attribut onclick="...". Mesure faite : 76 gestionnaires en ligne repartis
  dans 15 gabarits. Tant qu'il en reste un, la politique ne peut pas etre
  durcie.

  Piege supplementaire, documente dans build_csp() : en CSP niveau 3, un
  navigateur ignore 'unsafe-inline' des qu'un nonce est present. Emettre
  les deux ne serait donc pas une transition douce -- ce serait couper
  d'un coup tous les scripts en ligne et tous les onclick, et uniquement
  sur les navigateurs recents. La bascule doit etre atomique, d'ou un
  drapeau unique : CSP_ALLOW_INLINE_SCRIPT.

Infrastructure posee
  build_csp() assemble l'en-tete selon le drapeau. Un nonce est genere par
  requete et n'est emis que lorsque l'inline est interdit. Les 15 blocs
  <script> portent deja nonce="{{ csp_nonce }}", inerte aujourd'hui : la
  bascule finale sera un changement de configuration, pas de gabarits.

Couche partagee migree en premier
  base.html et macros.html sont rendus sur absolument toutes les pages. Six
  gestionnaires retires, remplaces par des attributs data-action et un
  ecouteur delegue unique dans main.js. La delegation plutot qu'un
  ecouteur par widget : le contenu injecte dynamiquement herite du
  comportement sans re-attachement.

Un cliquet plutot qu'une promesse
  tests/test_csp.py fixe un budget par gabarit qui ne peut que baisser.
  Ajouter un gestionnaire en ligne fait echouer la suite ; en retirer sans
  mettre le budget a jour aussi, ce qui force a enregistrer la progression
  dans le diff. A zero, il ne reste qu'a basculer le drapeau.

  Le cliquet a d'ailleurs corrige mon propre relevé : mon grep initial
  comptait 83 gestionnaires, la mesure exacte en donne 76 -- le motif ne
  verifiait pas l'espace avant l'attribut.

style-src conserve 'unsafe-inline' : les attributs style="" sont partout et
ne constituent pas un vecteur XSS a eux seuls. Migration distincte.

192 tests.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 20:44:16 -04:00
GGThedandClaude Opus 5 a92600c305 feat(i18n): francais comme langue principale, anglais accessible
Le site s'affiche desormais en francais par defaut, avec un selecteur de
langue permettant de basculer vers l'anglais.

Choix de conception : les chaines sources restent en anglais
  Elles servent d'identifiants gettext, et le francais est fourni par
  catalogue avec BABEL_DEFAULT_LOCALE = 'fr'. Le code reste ainsi dans une
  seule langue -- la meme que ses commentaires et docstrings -- tandis que
  ce qu'un membre voit est du francais.

  Consequence qui rend la migration praticable : une chaine non encore
  traduite retombe en anglais, pas sur un identifiant brut. Les gabarits
  peuvent donc etre migres un par un sans jamais laisser le site a moitie
  casse.

Selection de la langue (app/i18n.py)
  1. choix explicite via le selecteur, garde en session
  2. sinon en-tete Accept-Language du navigateur, restreint a fr et en
  3. sinon francais
  Un choix explicite prime toujours, y compris sur un navigateur anglophone.

Selecteur
  Extrait en partiel et inclus dans les deux branches de la mise en page :
  barre laterale une fois connecte, ET page d'authentification. Quelqu'un
  qui ne lit pas la langue courante doit pouvoir en changer AVANT de se
  connecter -- le laisser derriere l'authentification aurait ete un defaut
  d'accessibilite. Chaque langue est ecrite dans sa propre langue.

  La route /lang/<locale> valide le Referer avant de rediriger : sans ce
  controle, elle constituait une redirection ouverte.

Migre dans cette passe
  navigation complete, page de connexion, les cinq pages d'erreur, et
  l'integralite des messages flash de routes/auth.py. 64 chaines, dont
  aucune non traduite.

Verification
  25 tests, dont deux garde-fous d'integrite : un catalogue .mo manquant
  ou une entree non traduite font echouer la suite. Sans cela, une
  compilation oubliee servirait de l'anglais partout, en silence et sans
  rien dans les journaux.

Un test existant a du etre corrige, et c'est instructif
  test_login_failure_message_does_not_reveal_account_existence cherchait la
  sous-chaine anglaise 'attempt(s) remaining'. La page etant desormais en
  francais, elle etait absente des deux cotes, l'assertion passait, et le
  mode strict a signale le faux succes. Le test comparait donc l'anglais,
  pas le comportement. Il compare desormais les messages flash rendus,
  quelle que soit la langue. La faille SEC-AUTH-006 reste ouverte, et le
  test la documente toujours.

Les catalogues .po ET .mo sont versionnes : le deploiement est un simple
miroir de fichiers, sans etape de compilation. messages.pot, regenerable,
ne l'est pas.

docs/translations.md documente le processus, les deux pieges (concatenation
de phrases, traduction a l'import), et l'etat de la migration. A noter pour
la suite : les chaines dans les blocs <script> ne peuvent pas etre balisees
telles quelles, il faudra les passer par des attributs data- -- ce qui
rejoint le chantier de sortie de unsafe-inline (OPS-010).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 20:31:25 -04:00