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>
|
||
|---|---|---|
| .forgejo/workflows | ||
| .idea | ||
| .vscode | ||
| k8s | ||
| server | ||
| src | ||
| static | ||
| .dockerignore | ||
| .envrc | ||
| .gitignore | ||
| .npmrc | ||
| Dockerfile | ||
| flake.lock | ||
| flake.nix | ||
| justfile | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| vite.config.ts | ||
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
- 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.
- Les joueurs rejoignent la room.
- Activation d'une story (
▶dans la liste). - 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.
- 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.
- Reveal : les valeurs s'affichent, avec la distribution, le consensus éventuel et un score suggéré. Le plateau indique qui a révélé.
- 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 »).
- 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.
- « 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 code4413(définitif poury-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/datalui appartient. Avec un volume nommé, Docker reprend cette appartenance ; avec un bind-mount d'un dossier de l'hôte, pense àchown 1000:1000dessus (ou--user "$(id -u):$(id -g)") ; - l'image embarque un
HEALTHCHECKsur/healthz, doncdocker psdit 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égieRecreate, 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
(
auto→clair→sombre), 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 dansstatic/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.