@clicjo/contrat-exploitation
v2.4.0
Published
Le contrat de l'API d'exploitation de clicjo (doc 12 § 6, D33) : schemas Zod ET table des routes des canaux d'integration — deploiements, interrupteurs, quota, liste nominative, lien de lancement, lecture, canal sortant, widget, purge. Sous-chemin /admin
Readme
@clicjo/contrat-exploitation
Le contrat de l'API d'exploitation de clicjo : schémas Zod et table des routes des canaux d'intégration. Schémas et types dérivés d'une même source — une divergence d'interprétation devient une erreur de compilation, jamais un incident en production.
Publié pour que la CI d'un intégrateur casse au bump de version, plutôt que de découvrir la dérive en production.
Installation
npm install @clicjo/contrat-exploitationDépendance unique : zod.
Ce que ce paquet couvre
| Canal | Ce qu'il porte |
| ------------------ | ---------------------------------------------------------------------------- |
| Déploiements | création, suspension, réactivation, fermeture, quota, interrupteurs d'achat |
| Accès apprenant | liens de lancement, jetons d'embarquement, widget |
| Entrée dans le jeu | les noms de paramètres d'URL et urlEntree() |
| Lecture | progression, recueils, consentements — paginés, filtrables par apprenant |
| Canal sortant | déclaration et révocation de connecteurs, signature |
| RGPD | purge de données personnelles |
| Catalogue | les jeux servis, leurs variables de configuration et leurs salles |
Le back-office de la plateforme (créer un client, émettre une clé, poser un mandat) vit au
sous-chemin @clicjo/contrat-exploitation/admin — l'import dit ainsi qu'on franchit une
frontière de privilège.
Exemple
import { ROUTES, construireChemin, urlEntree } from '@clicjo/contrat-exploitation';
// La table des routes est la source : ne composez pas les chemins à la main.
const chemin = construireChemin(ROUTES.lireProgression, { jetonDeploiement });
// L'URL à ouvrir dans le navigateur avec un jeton de lancement.
const url = urlEntree('https://jeu.clicjo.fr', 'lien', jetonLancement);Lire une cohorte entière — depuis la 2.0.0, en suivant le curseur jusqu'à ce qu'il manque :
const progressions = [];
let curseur: string | undefined;
do {
const query = new URLSearchParams({ limite: '200', ...(curseur ? { curseur } : {}) });
const reponse = await fetch(`${base}${chemin}?${query}`, { headers: { 'x-cle-api': cle } });
// Le schéma publié est le validateur : ne relisez pas la réponse à la main.
const page = ROUTES.lireProgression.reponse.parse(await reponse.json());
progressions.push(...page.progressions);
curseur = page.curseurSuivant; // absent = dernière page
} while (curseur);Lire un seul apprenant — un appel, plutôt que soixante lignes à jeter :
const query = new URLSearchParams({ refApprenant: 'apprenant-42' });
const reponse = await fetch(`${base}${chemin}?${query}`, { headers: { 'x-cle-api': cle } });Deux pièges que ce paquet existe pour fermer
Les jetons sont OPAQUES. Un jeton de déploiement s'écrit <partition>_<uuid> — ne le
décomposez pas, ne le rangez pas dans une colonne de type uuid, ne le validez pas contre une
expression régulière d'UUID. Le contrat le déclare string, et c'est tout ce qu'il en promet.
?lien= et ?jeton= ne font pas la même chose. Le premier rouvre une partie pour une
personne, le second en ouvre une par le déploiement. Les confondre donne une URL qui
fonctionne à l'essai et perd l'apprenant à la reprise. Utilisez urlEntree() plutôt que de
composer l'adresse.
Ce que la 2.0.0 rompt
Une seule chose, et elle ne concerne que les trois lectures du canal PULL (lireProgression,
lireRecueils, lireConsentements) : elles rendaient tout, elles rendent une page.
Jusqu'ici une lecture rendait l'intégralité du déploiement à chaque appel. Le coût grandissait
avec la cohorte, sans plafond, et suivre une personne obligeait à télécharger les soixante
autres pour en jeter cinquante-neuf. Ces trois réponses portent désormais un curseurSuivant,
et leur requête accepte limite (défaut 50, plafond 200), curseur et refApprenant.
Le seul geste obligatoire à la migration : suivre curseurSuivant. Un appel qui l'ignore
compile, répond 200, et rend les 50 premières lignes en ayant l'air d'avoir tout rendu — c'est
la raison pour laquelle ceci est une majeure et non une mineure additive.
| Vous faisiez | Vous faites |
| --------------------------------------------------- | --------------------------------------------------------- |
| un appel, tout le déploiement | une boucle sur curseurSuivant (cf. l'exemple ci-dessus) |
| tout lire puis filtrer chez vous sur refApprenant | ?refApprenant=… — un appel, une personne |
Il n'y a ni total ni nombre de pages, et ce n'est pas un oubli : les deux exigeraient un
COUNT sur toute la table à chaque appel, et ils mentiraient dès qu'une partie serait jouée
entre deux pages. Le curseur est opaque : renvoyez-le tel quel, ne le décomposez pas, ne le
supposez pas ordonnable. Un curseur périmé vaut absence de curseur — la première page, jamais une
erreur.
Il n'y a pas de paramètre env sur ces trois routes, contrairement à listerDeploiements :
le déploiement est dans l'URL et porte son propre environnement. L'étanchéité recette/prod reste
assurée par la clé d'API que vous présentez.
Une exception sur le grain, et il faut la connaître avant de dimensionner vos appels : sur
lireRecueils, limite compte des apprenants, pas des recueils — une page rend tous les
recueils des apprenants qu’elle porte. Le motif est structurel de notre côté : le puits
d’événements ne porte pas de colonne de déploiement, et un ordre global sur les recueils ne
serait servi par aucun index, donc un tri complet à chaque page. Le volume reste borné parce que
le nombre de briques de recueil d’une partie l’est, fixé par le jeu et non par l’apprenant.
Le reste de la majeure est additif — rien à changer pour en profiter :
completionsne bouge pas.completionsDateess'ajoute à côté, avec la date de première complétion de chaque brique. Ne supposez pas que les deux portent les mêmes briques : une complétion dont l’événement a été purgé (droit à l’effacement) figure danscompletionset manque àcompletionsDatees. C’est délibéré — nous ne comblons pas un trou avec une date plausible, ce qui ferait passer une inconnue pour un fait.completionsfait foi sur ce qui est résolu,completionsDateessur ce qui est datable ;derniereActiviteLe(optionnel) date la dernière action jugée — jamais la dernière requête. Un onglet ouvert, un rechargement, une reprise ne comptent pas : sans quoi un apprenant qui a décroché depuis trois semaines paraîtrait actif. Absent = cette personne n'a encore rien fait ;- sur un consentement,
phraserend le texte exactement consenti, bénéficiaire interpolé : un numéro de version seul ne justifie pas un envoi. Optionnel — une version introuvable au référentiel ne fait pas échouer la page entière ; - sur un recueil,
idest un identifiant stable et unique, opaque. Il sert à dédupliquer : une pagination reprise, un rejeu ou deux lectures qui se chevauchent vous feront recevoir deux fois la même réponse, et comparer les recueils champ à champ échoue justement dans le cas où cela compte — deux réponses identiques du même apprenant à deux moments différents. De notre côté il départage : deux recueils d’un même lot partagent leur horodatage, et une pagination sur un ordre partiel saute ou répète des lignes ; retireLedate un retrait, et?inclureRetires=truerend les consentements retirés. Ils disparaissaient purement, ce qui rendait un retrait indiscernable d'un accord jamais donné — et sans savoir quelle ligne effacer, on ne peut pas donner effet à un retrait. Un retiré est lisible, jamais exploitable : la valeur par défaut restefalse.
Versionnage
Semver. Une version mineure est additive : rien de ce que vous épinglez ne change de forme. Une version majeure signale une rupture, et elle est annoncée avant publication.
La 2.0.0 est la première majeure du paquet, et sa rupture est décrite ci-dessus. Épinglez
^2.0.0 : vous recevrez les ajouts, jamais une rupture non annoncée.
La 2.1.0 (2026-08-19) passe les schémas de réponse en mode strip : un champ que le schéma ne décrit pas est accepté et retiré de l'objet validé — vos schémas épinglés cessent ainsi de casser pendant la fenêtre de bascule d'une évolution additive, tandis que les champs déclarés restent exigés à leur nom et dans leur type. Les schémas de requête, eux, restent stricts : sur ce que vous envoyez, la sévérité protège d'un paramètre mal nommé.
La 2.2.0 (2026-08-19) apporte le multi-jeux : jeu optionnel à la création d'un
déploiement (absent avec un seul jeu au catalogue : il est choisi ; plusieurs : 400 jeu-requis
avec la liste ; inconnu : 400 jeu-inconnu), jeu et versionMaster sur le déploiement
publié — le couple est figé à la création, une partie en cours ne change jamais de version —
et un catalogue qui liste les jeux réellement servis. Un jeu au contenu invalide répond 503
jeu-indisponible sur ses seuls déploiements. Note de registre : la 2.1.0 n'a jamais été
publiée seule (incident de jeton de publication) — la 2.2.0 est la première à porter le strip.
La 2.2.1 (2026-08-19) est ce paragraphe : la 2.2.0 était partie sans documenter ses propres ajouts — le défaut exact qui avait forcé la 2.0.1, une version plus tôt. Aucun changement de code.
La 2.3.0 (2026-08-19) ferme deux résidus. La création d'un déploiement rend désormais
le couple jeu / versionMaster figé — optionnels au schéma parce qu'un rejeu d'idempotence
antérieur à la 2.3.0 rend l'ancienne forme verbatim (30 jours au plus, la rétention des clés) ;
la lecture unitaire reste là où le couple est exigé. Et la politique de rétention se lit
(GET /retentions) : durées et points de départ des compteurs, publiés en données — vous
n'avez plus à la connaître par annonce. Un serveur dont la composition ne déclare aucune
politique répond 501 retentions-non-declarees : « je ne dis pas » n'est pas « rien
n'expire ».
La 2.4.0 (2026-08-19) livre le flux des recueils (GET
/lecture/deploiements/:jetonDeploiement/recueils/flux) : la lecture au grain du recueil,
dans l'ordre où le serveur les a écrits — les nouveaux arrivent toujours en fin de flux, donc
conservez votre dernier curseurSuivant et revenez plus tard : vous ne recevrez que le
nouveau. Dédupliquez par id : après un effacement concurrent, la reprise préfère vous
redonner un recueil déjà vu plutôt que d'en sauter un. La lecture au grain de l'apprenant est
inchangée.
La même 2.4.0 apporte aussi le lien de reprise (D68) — la troisième nature de lien,
qui ne se confond avec aucune des deux autres : là où le lien de lancement est signé, à usage
unique et expire seul, le lien de reprise est opaque (le serveur n'en garde que l'empreinte
SHA-256), multi-usage, sans échéance propre — une session qui existe est reprenable,
sauf révocation — et révocable : par vous
(POST /deploiements/:jetonDeploiement/liens-reprise/revoquer), par nous, et en cascade au
retrait de l'apprenant. Vous frappez le lien sur la session d'un apprenant rattaché
(POST /deploiements/:jetonDeploiement/liens-reprise, refApprenant recopié de l'API de
lecture), le secret vous est rendu une seule fois (re-frapper révoque le précédent — au
plus un secret vivant par session), et votre canal le remet à l'apprenant : c'est vous qui
le connaissez. L'apprenant le présente à POST /liens-reprise/consommer (publique) et
retrouve sa session — jamais une autre, jamais une création. Périmètre : parcours
rattachés seulement ; les visiteurs anonymes gardent leur reprise courte par cookie.
Licence
UNLICENSED — tous droits réservés. Le paquet est publié pour être installé par les intégrateurs de clicjo, non pour être réutilisé ou redistribué.
