No description
Find a file
romain.chauveau d1a625d667
All checks were successful
Build & deploy yapoker / build (push) Successful in 2m51s
Build & deploy yapoker / deploy (push) Successful in 2s
Retours de la première séance : 0.5, réconciliation, chronos, descriptions
Cinq remarques rapportées par l'équipe après un premier usage.

La carte 0.5 rejoint le deck, entre le 0 et le 1. Son libellé reste « 0.5 »
et non « ½ » : le sélecteur de score final compare les suggestions aux
libellés des cartes, un test verrouille cet invariant depuis le 0.

La réconciliation. Le reveal ne fige plus les cartes : sauf unanimité, il
rouvre les votes à découvert, chacun ajustant le sien pendant la discussion
et le score suggéré suivant en direct. C'est ce qui se passait déjà à l'oral,
et la seule issue offerte jusqu'ici était d'effacer pour revoter à l'aveugle.
Unanimité ≠ consensus chiffré : trois 5 et un NOPE  font un consensus mais
pas une unanimité, et c'est justement le refus qu'il faut discuter, d'où
`unanimous` dans le décompte. Le compteur de passes ne monte que sur un
revote à l'aveugle — réconcilier n'efface rien, c'est la même passe.

Valider un score enchaîne sur la story suivante, dans la même transaction
que la clôture : c'est le même geste, il n'a pas à se faire en deux clics.
Quand c'était la dernière, le plateau se libère et annonce que tout est
estimé, plutôt que de garder à l'écran une story qui n'a plus rien à dire.

Deux chronomètres : la séance (à partir de la première story lancée, pas de
l'ouverture de la room, qui a pu se faire la veille) et la story en cours,
revotes compris — `startedAt` ne repart donc plus sur un « revoter ».

Le lien donne le code, le reste de la ligne est la description. Plutôt que
d'extraire un « vrai titre » d'un texte qu'on ne contrôle pas, une ligne avec
lien devient `ABC-123` + tout le reste, affiché en entier sur le plateau
pendant l'estimation et tronqué ailleurs. Une ligne de backlog qui ramène le
statut, l'assigné et les points ne casse plus rien. La description suit dans
l'historique, dans les exports et dans les morceaux d'un `story.split()`.
Récupérer les résumés côté serveur reste hors périmètre, et le README dit
pourquoi : sur un site ouvert à tous, ce serait un oracle de lecture non
authentifié sur les titres de tous les tickets du compte de service.

Rooms existantes : les nouveaux champs sont absents de leurs documents et se
relisent en valeurs neutres (première passe, pas de réconciliation, pas de
description), donc aucune migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 21:05:00 +02:00
.forgejo/workflows Pied de page vers le dépôt, et retrait des détails d'infra privée 2026-09-02 15:47:27 +02:00
.idea Première version : planning poker CRDT, sans backend métier 2026-09-02 14:50:56 +02:00
.vscode Première version : planning poker CRDT, sans backend métier 2026-09-02 14:50:56 +02:00
k8s Garde-fous : plafond par room, rooms vierges non persistées, rate-limit 2026-09-02 17:19:29 +02:00
server Garde-fous : plafond par room, rooms vierges non persistées, rate-limit 2026-09-02 17:19:29 +02:00
src Retours de la première séance : 0.5, réconciliation, chronos, descriptions 2026-09-07 21:05:00 +02:00
static Première version : planning poker CRDT, sans backend métier 2026-09-02 14:50:56 +02:00
.dockerignore Première version : planning poker CRDT, sans backend métier 2026-09-02 14:50:56 +02:00
.envrc Première version : planning poker CRDT, sans backend métier 2026-09-02 14:50:56 +02:00
.gitignore Première version : planning poker CRDT, sans backend métier 2026-09-02 14:50:56 +02:00
.npmrc Première version : planning poker CRDT, sans backend métier 2026-09-02 14:50:56 +02:00
Dockerfile Première version : planning poker CRDT, sans backend métier 2026-09-02 14:50:56 +02:00
flake.lock Première version : planning poker CRDT, sans backend métier 2026-09-02 14:50:56 +02:00
flake.nix Première version : planning poker CRDT, sans backend métier 2026-09-02 14:50:56 +02:00
justfile Garde-fous : plafond par room, rooms vierges non persistées, rate-limit 2026-09-02 17:19:29 +02:00
package-lock.json Première version : planning poker CRDT, sans backend métier 2026-09-02 14:50:56 +02:00
package.json Première version : planning poker CRDT, sans backend métier 2026-09-02 14:50:56 +02:00
README.md Retours de la première séance : 0.5, réconciliation, chronos, descriptions 2026-09-07 21:05:00 +02:00
tsconfig.json Première version : planning poker CRDT, sans backend métier 2026-09-02 14:50:56 +02:00
vite.config.ts Première version : planning poker CRDT, sans backend métier 2026-09-02 14:50:56 +02:00

yapoker

Planning poker libre et auto-hébergeable, servi sur poker.yagni.fr. Le nom est un mot-valise entre yagni (You Aren't Gonna Need It) et « poker » — et le principe est fidèle à la marque : aucun backend applicatif.

Le principe

Une seule image, un seul port, un seul conteneur — comme le blog :

docker run -p 8080:8080 -v yapoker-data:/data forgejo.yagni.fr/yagni/yapoker

Dedans, un processus Node qui fait deux choses, et rien de plus :

Ce qu'il sert Rôle Ce qu'il sait du planning poker
GET * le front SvelteKit compilé — c'est lui qui porte toute la logique de jeu : rôles, stories, votes, reveal, score final, historique, export tout, mais dans le navigateur
WS /sync/<room> relaie les updates CRDT entre les clients d'une même room et les persiste sur disque rien

Le hub parle le protocole y-websocket : il relaie des updates Yjs opaques et les écrit dans une base SQLite. Il ne connaît ni les votes, ni les rôles, ni les stories. Toute règle du jeu se change donc dans le navigateur, sans toucher au serveur.

Servir le front et la WebSocket sur la même origine est ce qui rend le déploiement trivial : le client déduit son URL de synchronisation de l'adresse de la page (wss://<host>/sync). Rien à injecter au build, pas de contenu mixte, pas de CORS, et la même image tourne à l'identique sur localhost:8080 et sur poker.yagni.fr.

Conséquence assumée : les votes sont masqués à l'affichage avant le reveal, pas cryptographiquement. Quiconque ouvre les devtools voit les cartes des autres. C'est un outil interne entre collègues de confiance.

Démarrage local

Prérequis : Node 24 et just. Le flake les fournit (nix develop, ou automatiquement avec direnv après direnv allow).

just install     # dépendances de l'app et du serveur
just server      # terminal 1 : serveur sur :8080
just dev         # terminal 2 : Vite (live-reload) sur :5173

Puis http://localhost:5173. Vite proxifie /sync vers le serveur, donc le client parle toujours à sa propre origine — exactement la situation de prod. Aucune URL à configurer.

Trois autres façons de le lancer, de la plus légère à la plus fidèle :

Commande Ce que ça fait
just serve build + serveur, sans Docker -> http://localhost:8080
just image construit l'image unique (yapoker:dev)
just run construit puis lance l'image comme en prod -> http://localhost:8080

Ouvre la même room dans deux fenêtres (dont une en navigation privée) pour jouer à deux : chaque fenêtre a sa propre identité locale.

Autres recettes : just check (typecheck), just test (logique de jeu), just build, just rooms, just drop-room <room>. just --list pour la liste complète.

Rooms persistantes

Une room n'est pas une session jetable : poker.yagni.fr/room/<id> est l'adresse durable d'une équipe. Les stories y sont préparées à l'avance, les joueurs arrivent quand ils veulent, et l'historique des séances passées reste consultable dans la room. « Nouvelle séance » archive la séance courante puis vide la liste des stories — le roster de participants, lui, reste en place.

Un seul lien pour tout le monde. En arrivant, chacun saisit un pseudo et choisit son rôle dans un menu déroulant :

  • Joueur — vote et pilote ;
  • PO — pilote mais ne vote pas ;
  • Spectateur — observe, sans voter ni piloter.

Le rôle ne dit qu'une chose : est-ce que je vote. Un PO n'a aucun pouvoir propre — c'est un joueur qui ne vote pas, rien de plus. Ajouter des stories, en activer une, révéler, valider un score, découper, clore la séance : joueurs et PO le font à égalité.

C'est délibéré, et pas seulement par simplicité : une séance peut se tenir sans PO du tout — des cartes techniques estimées entre développeurs, par exemple — et elle ne doit pas rester bloquée faute de titulaire. Une équipe qui s'estime elle-même n'a pas besoin de permissions : la confiance suffit, et le document CRDT n'a de toute façon jamais connu les rôles (voir plus bas). Plusieurs personnes qui pilotent en même temps, ce n'est donc pas un incident ; la fusion CRDT est déterministe, le dernier clic gagne et tout le monde voit la même chose, jamais un état chacun.

Pas de compte, pas d'auth : le rôle est déclaratif, modifiable à tout moment depuis l'en-tête. Un joueur qui passe PO en cours de manche voit sa carte retirée pour ne pas bloquer le décompte.

Présence, départs et exclusions

La liste des participants est persistante (la même équipe revient la semaine suivante) mais la pastille verte vient de l'awareness du provider, qui est éphémère : elle ne porte que le participantId. Trois conséquences :

  • fermer l'onglet grise le participant sans le retirer de la room ;
  • « Quitter » le retire du roster et oublie le siège local ;
  • si quelqu'un est retiré du roster pendant la séance, le client concerné le détecte (il n'est plus dans le roster alors que la synchro est faite), libère son siège et revient à l'écran de connexion avec un message. Sans ça il restait un fantôme : connecté, absent de la liste, et ses cartes tombaient dans le document sans propriétaire.

Le flow d'une séance

  1. Quelqu'un — le PO s'il y en a un, n'importe quel joueur sinon — ouvre la room et ajoute les stories, une par ligne : un titre libre, un lien Jira, ou les deux sur la même ligne (collage multi-lignes supporté, puces et numérotations nettoyées). Dès qu'il y a un lien, le ticket donne le code et le reste de la ligne devient la description — voir ci-dessous.
  2. Les joueurs rejoignent la room.
  3. Activation d'une story ( dans la liste).
  4. Les joueurs posent une carte. Tout le monde — PO et spectateurs compris — ne voit qu'un dos de carte et le libellé A VOTÉ. Chaque joueur voit sa propre carte et peut encore la changer.
  5. Dès que tous les joueurs connectés ont voté, le bouton « Révéler » passe en accentué et pulse. N'importe qui peut le déclencher, à tout moment, même avant que tout le monde ait voté. Un joueur qui trouve que ça traîne peut aussi engager le 49.3 🗳️ : fin des débats, tout le monde pose sa carte — mais ça ne révèle rien.
  6. Reveal : les valeurs s'affichent, avec la distribution, le consensus éventuel et un score suggéré. Le plateau indique qui a révélé.
  7. Réconciliation. Sauf unanimité, elle s'ouvre d'elle-même au reveal : les cartes restent à découvert et chacun peut encore ajuster la sienne pendant la discussion — « ah, je n'avais pas vu la migration, je passe de 3 à 8 » — les autres voyant le changement en direct, score suggéré compris. Il n'y a pas de bouton à cliquer pour ça : c'est ce qui se passe toujours après un reveal qui n'est pas net. Si tout le monde a posé la même carte, il n'y a rien à discuter : les cartes se figent et on valide. Un bouton 🤝 Réconcilier reste là pour le cas qui reste — l'unanimité qu'on veut quand même rediscuter. Revoter, lui, efface les cartes et repart à l'aveugle : c'est une nouvelle passe, et le plateau l'affiche (« revote n°2 »).
  8. Validation d'un score final — facultative, et libre de différer des votes. Elle enchaîne aussitôt sur la story suivante à estimer : c'est le même geste, il n'a pas à se faire en deux clics. Idem pour « Clore : refusée » et « Renvoyer en affinage ». Quand c'était la dernière, le plateau se vide et annonce que tout est estimé — une story tranchée qui reste à l'écran n'a plus rien à dire. Seul le découpage reste sur place : ses morceaux sont à renommer avant d'être estimés.
  9. « Story suivante → » reste là pour naviguer à la main : après un découpage, ou pour sauter une story et y revenir (elle sera reproposée, la recherche remonte la liste avant de rendre la main).

Les étapes 1, 3, 5, 7, 8 et 9 sont ouvertes à tous les votants ; seuls les spectateurs ne les voient pas.

Deux chronomètres suivent la séance : celui de l'en-tête part à la première story lancée — et pas à l'ouverture de la room, qui a pu se faire la veille — celui du plateau compte le temps passé sur la story en cours, discussions et revotes compris. Ils informent, rien de plus : aucune limite, aucune alerte, et « Nouvelle séance » les remet à zéro.

Le lien donne le code, le reste est la description

Une story porte deux textes, et la ligne collée les remplit toute seule :

Ce que c'est Où ça s'affiche
titre la clé du ticket (ABC-123) quand un lien en donne une, la ligne entière sinon partout : liste, plateau, historique, exports — c'est l'identité courte, celle qu'on cite à l'oral
description tout le reste de la ligne en entier sur le plateau, pendant l'estimation ; tronqué ailleurs
corriger la TVA sur les avoirs https://jira.example.com/browse/ABC-124
→ titre « ABC-124 », description « corriger la TVA sur les avoirs »

https://jira.example.com/browse/ABC-123
→ titre « ABC-123 », pas de description

Refonte du panier
→ titre « Refonte du panier » (pas de lien, rien à déduire)

L'intérêt est de ne plus chercher à extraire un « vrai titre » d'un texte qu'on ne contrôle pas. Une ligne de backlog qui ramène le statut, l'assigné et les points avec elle ne casse plus rien : le code reste propre dans la liste, et la bouillie part en description, là où elle est justement utile — sous les yeux de l'équipe au moment d'estimer. La clé n'est pas répétée en tête de description quand le texte collé la portait déjà.

Les deux champs se corrigent avec le de la liste des stories, et les morceaux d'un story.split() ✂️ héritent de la description du parent : un morceau qui ne dirait que « ABC-123 — 1/2 » ne rappellerait rien à personne.

Coller depuis Jira, accessoirement : quand la vue s'y prête (la vue liste d'un filtre, pas le backlog, qui est une liste drag-and-drop dont on ne sélectionne rien), le navigateur met les <a> de la sélection dans le presse-papier en text/html — résumés compris. Le composeur les relit et en fait des lignes au format ci-dessus. Ça ne coûte rien quand ça marche et ça ne casse rien quand ça ne marche pas : sans HTML exploitable, le collage reste celui du navigateur. L'analyse est laissée à DOMParser ; ce qui est propre à yapoker vit dans draftsFromLinks, testé.

Un bookmarklet, si on veut vraiment les résumés d'une page qui ne se copie pas (un Confluence, un fil de discussion, une liste de clés). Exécuté depuis l'onglet Jira il est same-origin, donc il interroge l'API avec la session de la personne — sans token, sans CORS, sans rien à déployer :

javascript:(async()=>{const k=[...new Set(document.body.innerText.match(/\b[A-Z][A-Z0-9_]+-\d+\b/g)??[])],b=location.origin,l=[];for(const i of k){const r=await fetch(`${b}/rest/api/2/issue/${i}?fields=summary`,{headers:{Accept:'application/json'}});if(r.ok)l.push(`${(await r.json()).fields.summary} ${b}/browse/${i}`)}const t=document.createElement('textarea');t.value=l.join('\n');t.style.cssText='position:fixed;top:2rem;left:2rem;right:2rem;bottom:2rem;z-index:9999;font:14px monospace';t.onkeydown=e=>{if(e.key==='Escape')t.remove()};document.body.append(t);t.select()})()

Il ramasse les clés visibles sur la page, demande leur résumé et ouvre une zone de texte prête à copier (Ctrl+C, puis Échap) — au format résumé <lien>, que le composeur relit. Les fausses clés (UTF-8 et compagnie) répondent 404 et se jettent d'elles-mêmes. api/2 marche sur Cloud comme sur Data Center. Ces listes étant virtualisées, seules les lignes affichées sont dans la page : il faut faire défiler, ou passer par une vue paginée.

Le deck

Cartes de valeur : 0, 0.5, 1, 2, 3, 5, 8, 13, 20, 40, 100, . Le 0 signifie « rien à faire, c'est déjà là » (son infobulle garde le clin d'œil no-op), le 0.5 la broutille qui n'est quand même pas rien, et compte comme « a joué » mais sans chiffre.

Le demi-point s'écrit 0.5 et non ½ : le sélecteur de score final compare la suggestion aux libellés des cartes, et l'arrondi au deck produit un nombre. Un libellé qui ne correspond pas n'allumerait aucun bouton — c'était le cas du 0 tant qu'il s'appelait NOOP, un test le verrouille désormais.

La carte s'appelait NOOP : trop proche de NOPE ✋ sur le tapis, et opaque pour qui n'est pas développeur. Son identifiant interne reste noop, parce qu'il est stocké dans les documents CRDT — le renommer orphelinerait les votes des rooms existantes.

Cartes actions — réservées aux joueurs, une seule carte par manche : jouer une action, c'est renoncer à son chiffre. Seule exception, le 49.3, qui est une motion de procédure et non un vote : elle n'occupe pas le tapis, et son auteur pose quand même sa carte. Comportements implémentés (à valider, tout est dans src/lib/cards.ts et src/lib/components/RoundResult.svelte) :

Carte Effet implémenté
NOPE Refus d'estimer. Au reveal, un bouton « Clore : refusée » apparaît (statut refused, sans score).
Legacy 🦖 Le joueur choisit son facteur (×2, ×3, ×5) au moment de la poser. Le facteur le plus élevé de la manche multiplie le score suggéré, arrondi au palier supérieur du deck (8 ×3 → 40).
l'Excuse 🃏 Comme au tarot : elle ne prend pas le pli mais reste au joueur. Le vote est neutralisé (hors décompte) sans bloquer la manche.
split() ✂️ Au reveal, on peut clore en split et créer les morceaux en un clic (« Découper », n morceaux, insérés juste après la story). Nom court sur la face de carte — story.split() ne tient pas dans 64 px ; le nom complet est dans l'infobulle.
49.3 🗳️ Clôt les débats : bandeau « 49.3 engagé par X — chacun pose sa carte immédiatement », et la liste des retardataires passe en évidence. Ne révèle rien et ne consomme pas le vote de son auteur. Un « revoter » fait tomber la motion.
NOT READY 🚧 Au reveal, bouton « Renvoyer en affinage » (statut not_ready).

Le score suggéré = consensus s'il y en a un, sinon la médiane basse des chiffres posés, multipliée par le facteur Legacy et arrondie au deck. Ce n'est qu'une suggestion : on clique une carte, on tape ce qu'on veut, ou on ne valide rien.

Consensus et unanimité ne sont pas la même chose, et c'est la seconde qui décide de l'ouverture automatique de la réconciliation. Trois 5 et un NOPE font un consensus chiffré (le score suggéré est 5) mais pas une unanimité — et c'est justement le refus qu'il faut discuter. Unanimité = toutes les cartes posées sont la même, actions comprises.

Historique et export

Chaque reveal laisse les votes en clair dans la room (qui a voté quoi), consulté plus tard dans le panneau Historique grâce à la persistance du hub. Chaque séance — la courante comme les archivées — s'exporte en CSV (séparateur ;, pour Excel FR) ou en Markdown, généré dans le navigateur. Le CSV donne une colonne description ; le Markdown la met dans la cellule du lien ([ABC-1](…) — Refonte du panier), une colonne de plus rendant le tableau illisible.

Les cartes d'une story non révélée n'y apparaissent jamais. Le flag revealed vit sur la story elle-même, pas seulement sur la manche : l'historique affiche « 🂠 votes non révélés », l'export Markdown « non révélés », le CSV n'émet aucune ligne de vote, et l'archivage de la séance jette ces votes au lieu de les figer. Sans ça, le panneau Historique et le bouton d'export seraient deux portes de sortie pour les cartes en cours — y compris pour une story votée puis abandonnée sans reveal.

Structure du dépôt

src/
  lib/
    cards.ts              deck + agrégation des votes (pur, testé)
    types.ts              types du domaine
    export.ts             CSV / Markdown / téléchargement
    duration.ts           format des deux chronomètres (m:ss, h:mm:ss)
    yjs/
      doc.ts              schéma du document CRDT + toutes les mutations
      provider.ts         URL du hub + connexion y-websocket
    room/
      store.svelte.ts     projection réactive du Y.Doc + actions de jeu
      identity.ts         pseudo/rôle mémorisés par room (localStorage)
      id.ts               validation et génération des noms de room
      stories.ts          parsing des lignes (code, lien, description)
    components/           PlayingCard, Hand, Table, RoundResult, StoryList…
  routes/
    +layout.ts            ssr = false, prerender = false (SPA pure)
    +page.svelte          accueil : ouvrir / rejoindre une room
    room/[id]/+page.svelte
server/                   le serveur (aucune dépendance native)
  src/index.js            routage : /healthz, /sync/<room>, puis les fichiers
  src/static.js           service du build SvelteKit (SPA, cache, .br/.gz)
  src/hub.js              protocole y-websocket + présence
  src/persistence.js      log d'updates + snapshots SQLite
  src/admin.js            outillage : lister / supprimer une room
k8s/                      manifests Kubernetes (kustomize)
Dockerfile                l'image unique (build du front + runtime Node)
.forgejo/workflows/       CI : build kaniko + bump du tag côté GitOps

Traductions

Deux dictionnaires plats, src/lib/i18n/fr.ts et en.ts, et un t('clé', { paramètre }) — pas de bibliothèque : quelques centaines de chaînes ne justifient pas une dépendance et une étape de build.

Le français est la référence : en est typé Record<keyof typeof fr, string>, donc oublier une traduction ne compile pas. Trois tests complètent ce que le typage ne voit pas : aucune chaîne vide, les mêmes clés de part et d'autre, et surtout les mêmes paramètres {nom} dans les deux langues — un {name} perdu en traduisant s'afficherait tel quel, ou emporterait l'information avec lui.

Les noms de cartes ne sont pas traduits (NOPE, Legacy, l'Excuse, split(), 49.3, NOT READY) : c'est l'identité du deck, comme les cartes d'un jeu de société. Seules leurs explications le sont — et celle du 49.3 gagne au passage le contexte constitutionnel que les non-Français n'ont pas. Les exports CSV et Markdown suivent la langue de l'interface, en-têtes de colonnes compris.

Le document CRDT

Un Y.Doc par room, dont le nom de document est l'id de room :

meta          Y.Map     createdAt, sessionStartedAt, firstRoundAt
stories       Y.Array   liste ordonnée : { id, title, url, description, status, finalScore, revealed }
participants  Y.Map     participantId -> { name, role, joinedAt }
votes         Y.Map     "storyId:participantId" -> { card, factor?, at }
round         Y.Map     manche en cours : storyId, revealed, startedAt, demandedBy, reconciling, pass
sessions      Y.Array   séances archivées (JSON figé, jamais réécrit)

Les votes sont à plat et non en votes[storyId][participantId] : deux joueurs qui votent en même temps sur une story vierge créeraient chacun leur Y.Map imbriquée, et la fusion CRDT n'en garderait qu'une — un vote perdu. Une clé composée par vote, et chacun écrit dans son coin. Le test « synchronisation entre deux clients » de src/lib/yjs/doc.test.ts verrouille ce comportement.

reconciling et pass portent la phase de réconciliation : la manche reste revealed — les cartes ne se remasquent pas — tout en redevenant modifiable. reveal pose reconciling lui-même, à false seulement si les cartes sont unanimes. Le compteur de passes, lui, ne monte que sur un revote à l'aveugle : réconcilier n'efface rien et ne recommence rien, ce sont les mêmes cartes qu'on ajuste. Les documents créés avant ces champs se relisent en « première passe, pas de réconciliation » : une room ouverte avant la mise à jour continue de tourner sans migration. Même principe pour firstRoundAt, absent tant qu'aucune story n'a été lancée — c'est ce qui fait que le chrono de séance n'affiche rien plutôt que zéro.

startedAt ne repart pas sur un revote : c'est le temps passé sur la story, et revoter en fait partie.

La présence (« qui est en ligne ») passe par l'awareness du provider, qui est éphémère : elle ne porte que le participantId, tout le reste vit dans le document.

Le serveur

cd server && npm ci && npm start
Variable Défaut Rôle
HOST / PORT 0.0.0.0 / 8080 écoute
YAPOKER_DB ./data/yapoker.sqlite fichier SQLite
YAPOKER_STATIC ./build dossier du build à servir (absent = relais seul)
YAPOKER_SYNC_PATH /sync chemin réservé à la WebSocket
YAPOKER_COMPACT_AFTER 200 nombre d'updates avant compaction en snapshot
YAPOKER_JOURNAL_MODE DELETE journal SQLite (WAL sur disque local uniquement)
YAPOKER_MAX_PAYLOAD 1048576 taille max d'un message WebSocket
YAPOKER_MAX_ROOM_BYTES 4194304 plafond de taille par room (voir ci-dessous)

Aucune de ces variables n'est nécessaire pour un déploiement standard : l'image les pose déjà, et le front n'a rien à configurer au build. Il existe une seule variable côté front, VITE_SYNC_URL, à passer au moment du npm run build pour viser un hub sur une autre origine que la page — utile seulement si tu sépares un jour le front du serveur.

GET /healthz renvoie {"status":"ok","docs":n,"connections":n} — utilisé par les probes Kubernetes et par le HEALTHCHECK de l'image. Le chemin /sync est exigé pour une WebSocket : une connexion égarée sur / est refusée avec le code 4400 (définitif pour y-websocket, le client cesse de retenter) plutôt que de créer une room fantôme. Le front, lui, est servi avec un cache-control immuable sur /_app/immutable/ (assets hachés) et no-cache ailleurs, les .br/.gz générés au build étant servis tels quels.

Persistance : SQLite, pas LevelDB. Le volume de prod est un PVC NFS, et LevelDB y est fragile (verrous de fichiers, compactions concurrentes). SQLite en journal DELETE s'appuie sur des verrous POSIX que NFSv4 tient, et c'est déjà le compromis retenu pour les autres apps du cluster. node:sqlite étant dans le runtime Node, il n'y a aucune dépendance native à compiler dans l'image.

Le modèle est un log d'updates append-only par document, compacté périodiquement en snapshot (même stratégie que y-leveldb). Chaque update est écrit immédiatement : un pod tué en pleine séance n'emporte pas les votes avec lui — contrairement au serveur de référence, qui n'écrit qu'au départ du dernier client. Sur SIGTERM, le hub compacte tous les documents ouverts avant de rendre la main.

Garde-fous contre l'abus

Le hub accepte n'importe quel update Yjs — c'est ce qui le rend générique, et c'est aussi ce qui le rend gavable : un client peut y déverser ce qu'il veut, et un volume réseau n'a pas forcément de quota. Trois limites, donc :

  • plafond par room (YAPOKER_MAX_ROOM_BYTES, 4 Mio) : au-delà, la room n'accepte plus d'écriture et ses connexions sont fermées avec le code 4413 (définitif pour y-websocket, le client ne retente pas). Une room de planning poker pèse quelques kilo-octets : la marge est de trois ordres de grandeur. Le plafond vaut aussi pour la compaction, sinon le flush final réécrit l'état complet et réintroduit tout le surplus ;
  • rooms vierges non persistées : ouvrir une URL de room sans rien y écrire ne laisse aucune ligne en base. Un scan d'URL ne sème donc rien ;
  • taille de message (YAPOKER_MAX_PAYLOAD, 1 Mio) au niveau de la WebSocket.

Ça borne ce qu'un client peut écrire, pas ce qu'il peut ouvrir : le débit de connexions se limite au niveau de l'Ingress (voir k8s/ingress.yaml). Et sur un volume sans quota, la vraie ceinture reste côté stockage — un quota sur le dataset garantit qu'aucune application ne peut remplir le pool des autres.

Outillage d'exploitation (hub arrêté de préférence) :

node src/admin.js list          # rooms en base
node src/admin.js drop <room>   # suppression définitive

Le hub est réécrit ici plutôt que repris de @y/websocket-server pour trois raisons : la persistance branchée sur chaque update, le préfixe de chemin /sync partagé avec le site statique, et l'arrêt propre sur SIGTERM. Le protocole reste celui de y-websocket, donc n'importe quel client y-websocket sait lui parler.

Déploiement

Docker, n'importe où

docker build -t yapoker .
docker run -d --name yapoker -p 8080:8080 -v yapoker-data:/data yapoker

C'est tout : http://localhost:8080. Le volume porte la base SQLite, donc les rooms survivent à un docker restart comme à une mise à jour d'image. Rien d'autre à configurer — ni URL, ni variable, ni reverse-proxy.

Deux détails d'exploitation :

  • le conteneur tourne en non-root (node, uid 1000) et /data lui appartient. Avec un volume nommé, Docker reprend cette appartenance ; avec un bind-mount d'un dossier de l'hôte, pense à chown 1000:1000 dessus (ou --user "$(id -u):$(id -g)") ;
  • l'image embarque un HEALTHCHECK sur /healthz, donc docker ps dit franchement si le serveur répond.

Kubernetes

k8s/ contient des manifests kustomize prêts à déposer sous apps/yapoker/ d'un dépôt GitOps, plus un exemple d'Application Argo CD à adapter. Quatre objets :

  • yapoker (Deployment) — 1 replica, stratégie Recreate, rootfs en lecture seule, toutes les capabilities larguées ;
  • yapoker (Service) — 80 → 8080 ;
  • yapoker (Ingress) — poker.yagni.fr, un seul chemin, avec des timeouts relevés à 3600 s : sinon ingress-nginx coupe les WebSockets inactives au bout d'une minute et les clients reconnectent en boucle ;
  • yapoker-data — PVC annoté Prune=false : c'est la seule donnée durable du projet, Argo ne doit jamais le supprimer.
kubectl kustomize k8s   # rendu local des 4 objets

Le Deployment tourne en runAsUser: 0 alors que l'image, elle, tourne en uid 1000 : beaucoup de provisioners de volumes créent le dossier du PVC en root:root, et l'uid 1000 ne peut alors pas y écrire. Si ta StorageClass rend le volume accessible à l'uid 1000, retire les deux lignes et reste non-root.

CI : build kaniko + bump GitOps

.forgejo/workflows/deploy.yml : kaniko construit l'image (le runner n'a pas de daemon Docker) et la pousse sur le registre, puis un second job bumpe le tag dans apps/yapoker/deployment.yaml du dépôt GitOps, qu'Argo CD réconcilie. Une seule image, donc un seul build et un seul tag à bumper.

Trois secrets Actions à poser une fois :

Secret Valeur
DEPLOY_USER le compte de la forge qui pousse
DEPLOY_TOKEN son jeton : package: Write (pousser l'image) + repository: Write (bump du manifeste)
GITOPS_REPO le dépôt GitOps à mettre à jour, <hôte>/<org>/<dépôt>.git

GITOPS_REPO est un secret et non une valeur en dur : il décrit une infra privée qui n'a pas à être publiée avec le code. Le registre doit par ailleurs autoriser le pull anonyme, sinon il faut un imagePullSecret côté cluster.

Choix assumés

  • Pas de compte, pas d'auth. Qui a le lien entre dans la room.
  • Votes masqués à l'affichage seulement, cf. plus haut.
  • Bilingue FR/EN et thème clair/sombre, tous deux dans la barre en haut à droite de chaque page. Le thème suit la préférence système par défaut (autoclairsombre), la langue celle du navigateur ; les deux choix sont mémorisés dans le navigateur. La police est la pile monospace du système (déposer une woff2 dans static/fonts/ si on veut figer JetBrains Mono).
  • Pas de scale-out. Une instance sert toutes les rooms, et le front voyage dans le même conteneur : impossible d'ajouter des replicas sans router par room (et changer de persistance). C'est le prix de l'image unique, payé volontiers pour un outil d'équipe — et Node sert quelques dizaines de kilo- octets d'assets par visiteur, pas de quoi regretter nginx.

Hors périmètre du MVP

Comptes utilisateurs, intégration active avec l'API Jira (seul le lien brut est stocké), vote asynchrone/différé, thèmes visuels alternatifs.

Récupérer les résumés automatiquement, côté serveur en fait partie, et pas par paresse. Jira Cloud ne répond pas aux appels cross-origin d'une page tierce (CORS) et son API exige une authentification : il faudrait un proxy authentifié dans le hub, donc un secret à déployer et à faire tourner, et le hub cesserait d'être ignorant du planning poker. Surtout, poker.yagni.fr est ouvert à tout le monde : un tel endpoint serait un oracle de lecture non authentifié sur les titres de tous les tickets que voit le compte de service — énumérables de ABC-1 à ABC-9999 par quiconque trouve le domaine — et une facture de bande passante offerte au premier script qui passe. Un allowlist de projets et un rate-limit réduiraient la casse sans supprimer le problème.

D'où le choix de ne rien récupérer du tout : le lien porte le code, le texte collé porte le contexte (voir Le lien donne le code, le reste est la description). Tout arrive par la personne qui prépare le backlog, avec ses droits sur Jira, et le serveur n'a toujours rien à savoir.

Tests

just test

85 tests côté front, 6 côté serveur (just test) couvrent ce qui casse silencieusement : l'agrégation du deck (consensus, médiane, facteur Legacy, , demi-point), les dictionnaires de traduction, le parsing des lignes (code depuis le lien, reste en description, bouillie de backlog comprise) et du presse-papier, le choix de la story suivante, les durées affichées, les exports, la présence (annonce, départ, retour — cf. le piège ci-dessous), et les règles du jeu au niveau du document CRDT (49.3 qui clôt les débats sans révéler, vote refusé après un reveal unanime, réconciliation ouverte d'office au reveal dès que les cartes diffèrent (et un consensus chiffré n'est pas une unanimité), enchaînement automatique sur la story suivante (et plateau libéré quand il n'en reste aucune), chrono de séance qui part à la première story, changement de rôle, découpage, archivage de séance, vote orphelin d'un participant retiré, convergence de deux clients concurrents). La confidentialité des votes avant reveal est verrouillée à ses trois sorties : séance en cours, archive, et exports CSV/Markdown. Les composants Svelte, eux, ne sont pas testés : c'est de l'affichage, vérifié à la main.

Les tests du serveur (runner intégré de Node, aucune dépendance de plus) portent sur la persistance : aller-retour d'un document, compaction sans perte, et les deux garde-fous ci-dessus — une room vierge qui laisserait une trace ou un plafond contourné ne se voient pas à l'usage, seulement le jour où le volume est plein. Le second cas s'est justement produit : le plafond refusait bien les updates un par un, mais la compaction finale réécrivait tout l'état.

Un piège mérite sa ligne : awareness.setLocalStateField() est un no-op silencieux quand l'état local est null, ce qui est justement le cas après un départ. Publier la présence avec setLocalStateField laissait donc un participant qui quittait puis revenait invisible pour les autres — grisé dans la liste et hors du décompte des votants (« 0/0 ont voté »). D'où publishPresence() dans src/lib/yjs/provider.ts, qui passe toujours par setLocalState, et son test de régression.