Connecteurs et Verdaccio
Choisir le fournisseur de licence, installer le plugin Verdaccio livré avec le module, vérifier les clés de projet, et écrire son propre connecteur.
Un connecteur traduit « ce projet doit avoir un accès actif, suspendu ou révoqué » dans un système externe : un compte de registre, une clé de licence, une place dans un logiciel en ligne. Le module en livre un, verdaccio. Le fournisseur des nouveaux projets se choisit par FRAZA_MODULE_LICENSES_CONNECTOR.
Un identifiant reste attaché au connecteur qui l'a créé : changer de fournisseur ne touche pas aux projets existants, seulement aux nouveaux et aux identifiants régénérés.
Le connecteur Verdaccio
Verdaccio n'a pas d'API pour gérer ses comptes. C'est donc le registre qui demande : un plugin, installé sur le serveur Verdaccio, lit la clé que npm présente à chaque requête, appelle le site pour chaque clé qu'il n'a pas en cache, et le site répond si c'est celle d'un projet actif.
- Le site est la seule source de vérité. Suspendre ou révoquer un projet prend effet sur le registre sans toucher au serveur, dès l'expiration du cache du plugin (5 minutes par défaut).
- Les mainteneurs restent dans le
htpasswd. Le plugin ne s'occupe que des clés qui commencent par son préfixe,fzk_par défaut. Les jetons quenpm logindonne aux mainteneurs restent ceux de Verdaccio. - Rien à synchroniser. Le connecteur n'a pas de système à appeler : l'état que le registre lit est l'état voulu lui-même.
Un projet reçoit une clé fzk_ (ou le préfixe de FRAZA_MODULE_LICENSES_REGISTRY_KEY_PREFIX) suivie de 43 caractères base64url, 32 octets aléatoires, et un nom au registre, fz- suivi de dix caractères sans caractère ambigu (ni 0, o, 1, l). Le client voit, une fois, les lignes à coller dans le .npmrc de son projet :
@mon-scope:registry=https://registry.exemple.ca/
//registry.exemple.ca/:_authToken=fzk_…Une ligne de scope par scope de FRAZA_MODULE_LICENSES_REGISTRY_SCOPES, puis la clé, pour l'hôte de FRAZA_MODULE_LICENSES_REGISTRY_URL. Il n'y a ni npm login ni commande à lancer : npm envoie la clé à chaque requête, Authorization: Bearer fzk_…. Le nom fz-… paraît dans l'espace client sous le libellé « Identifiant » : c'est ce que répond npm whoami, ce que le journal de Verdaccio note, et ce que le support demande.
La route que le plugin appelle
POST /za/api/licenses/registry/authenticate, avec l'en-tête Authorization: Bearer <FRAZA_MODULE_LICENSES_REGISTRY_SECRET> et le corps { "key": "fzk_…" }.
| Réponse | Quand |
|---|---|
{ "ok": true, "name": "fz-…", "groups": ["licenses"] } | La clé est celle d'un identifiant en service, et son état voulu est active. name est le nom du projet au registre ; les groupes viennent de FRAZA_MODULE_LICENSES_REGISTRY_GROUPS. |
{ "ok": false } | Tout le reste : inconnue, mal formée, suspendue, révoquée, remplacée par une régénération. |
| 404 | Le secret manque, est faux, ou n'est pas configuré sur le site. |
La route appelle authenticateRegistryKey(key), qui cherche la clé par son SHA-256 : la base ne garde rien d'autre. Le secret est comparé en temps constant. La route n'est pas limitée par adresse : toutes les demandes viennent du serveur du registre, et une limite par adresse bloquerait tous ses clients à la fois. Le secret, long et aléatoire, la protège. La dernière utilisation d'une clé est notée, une fois par heure au plus.
Le plugin Verdaccio
Le plugin est un seul fichier CommonJS sans dépendance, livré dans le paquet : node_modules/@fraza-pro/module-licenses/verdaccio/plugin.cjs. Il demande Node 18 ou plus récent, pour fetch.
Verdaccio ne connaît pas une clé qu'il n'a pas émise : son propre middleware laisse une telle requête anonyme. Le plugin se charge donc deux fois, depuis le même dossier :
- comme middleware, il lit l'en-tête
Authorization: Bearer fzk_…et fait de la requête celle du projet. Le middleware de Verdaccio, qui passe avant, n'est pas touché : les jetons denpm logindes mainteneurs fonctionnent toujours ; - comme plugin d'authentification, il répond pour une clé envoyée comme mot de passe (authentification Basic). Tout autre mot de passe passe au plugin suivant de la section
auth, lehtpasswd.
Une clé refusée laisse la requête anonyme : les paquets publics s'installent toujours, et les paquets réservés répondent 401. Le plugin n'a pas d'adduser : un projet ne se connecte jamais, sa clé va directement dans le .npmrc.
Installer
Verdaccio cherche un plugin fraza-licenses dans un dossier verdaccio-fraza-licenses de son dossier de plugins. Sur le serveur du registre :
mkdir -p /verdaccio/plugins/verdaccio-fraza-licenses
cp plugin.cjs /verdaccio/plugins/verdaccio-fraza-licenses/index.jsPuis, dans config.yaml, le même plugin sous auth: et sous middlewares:, avec les mêmes réglages. Une ancre YAML évite de les recopier :
plugins: /verdaccio/plugins
auth:
# D'abord les clés de projet, puis les mainteneurs.
fraza-licenses: &fraza-licenses
url: https://compte.exemple.ca/za/api/licenses/registry/authenticate # le site qui a le module Licences
site_secret_env: FRAZA_LICENSES_REGISTRY_SECRET # ou site_secret: …
cache_seconds: 300
stale_seconds: 3600
timeout_ms: 5000
key_prefix: fzk_
htpasswd:
file: /verdaccio/storage/htpasswd
max_users: -1 # personne ne crée de compte par npm adduser
middlewares:
fraza-licenses: *fraza-licenses
packages:
'@mon-scope/*': # les paquets que vous vendez
access: $authenticated
publish: $maintainersaccess: $authenticated suffit : une clé active rend la requête authentifiée, une clé suspendue la laisse anonyme. Réserver le scope aux groupes du module (access: licenses) couperait les comptes du htpasswd qui lisent aujourd'hui ces paquets, s'ils n'ont pas ce groupe. publish ne nomme que des comptes de mainteneur : une clé de projet ne peut que lire.
security.api.legacy: true n'est plus nécessaire au plugin : la clé est revérifiée à chaque requête, quel que soit le type des jetons de Verdaccio. Le garder ou non ne dépend que des jetons des mainteneurs.
FRAZA_LICENSES_REGISTRY_SECRET est la même valeur que FRAZA_MODULE_LICENSES_REGISTRY_SECRET sur le site. Redémarrer Verdaccio après le changement.
| Réglage | Défaut | Rôle |
|---|---|---|
url | La route du site. Obligatoire. | |
site_secret, site_secret_env | Le secret partagé avec le site, en clair ou par le nom d'une variable d'environnement du processus de Verdaccio. Obligatoire : sans lui, toute clé est refusée. Pas secret : Verdaccio 6 écrit son propre secret interne sous cette clé, dans la configuration de chaque plugin. | |
cache_seconds | 300 | Combien de temps une réponse est gardée. C'est le délai maximal avant qu'une suspension prenne effet. |
stale_seconds | 3600 | Combien de temps un « oui » sert encore quand le site ne répond pas. |
timeout_ms | 5000 | Le délai d'attente de la réponse du site. |
key_prefix | fzk_ | Le début des clés dont le plugin s'occupe, le même que FRAZA_MODULE_LICENSES_REGISTRY_KEY_PREFIX du site. Tout autre jeton reste à Verdaccio. |
max_entries | 10000 | Le nombre de réponses gardées en cache ; la plus ancienne sort la première. |
Les deux instances du plugin partagent un seul cache, par adresse du site : une clé vérifiée par l'une ne l'est pas une seconde fois par l'autre. Le cache est indexé par le SHA-256 de la clé, jamais par la clé elle-même. Une seule question part au site à la fois pour une même clé, même quand npm demande plusieurs tarballs en parallèle.
Le site en panne. Une clé acceptée depuis moins de stale_seconds continue d'installer : une panne du site n'arrête pas les déploiements des clients. Sans réponse récente, le registre répond 503, et le message dit de réessayer.
Un 503 qui ne passe pas. Le client voit « Le serveur de licences ne répond pas » aussi quand le site refuse le secret : la route répond alors 404. Le journal de Verdaccio le distingue : the site refused the shared secret, or the url is not the route of the Licenses module. Vérifier que site_secret (ou la variable que nomme site_secret_env) vaut exactement FRAZA_MODULE_LICENSES_REGISTRY_SECRET du site, et que url se termine par /za/api/licenses/registry/authenticate.
Vérifier avant la mise en vente
Sur un vrai Verdaccio, avec le plugin déclaré dans les deux sections :
- Créer un projet depuis l'espace client, et coller ses lignes dans le
.npmrcd'un dossier de test.npm view @mon-scope/<paquet>répond. - Remplacer la clé par une clé inventée,
fzk_suivi de n'importe quoi : la même commande reçoit 401. - Avec un compte de mainteneur du
htpasswd,npm login --registry=https://registry.exemple.ca/puisnpm publishréussissent toujours. - Suspendre le projet depuis l'admin, attendre
cache_seconds, et relancernpm viewavec la clé du projet : la requête est refusée. - Rétablir le projet, régénérer sa clé depuis l'espace client : l'ancienne est refusée, la nouvelle répond. L'ancienne peut encore passer pendant
cache_secondssi elle venait d'être vérifiée.
Chez Fraza
Fraza vend Fraza Pro ainsi : le module Licences tourne sur compte.fraza.ca, et le registre est registry.fraza.ca. Ses réglages :
FRAZA_MODULE_LICENSES_REGISTRY_URL=https://registry.fraza.ca/
FRAZA_MODULE_LICENSES_REGISTRY_GROUPS=fraza-pro
FRAZA_MODULE_LICENSES_REGISTRY_SCOPES=@fraza-proLe client reçoit donc les deux lignes @fraza-pro:registry=https://registry.fraza.ca/ et //registry.fraza.ca/:_authToken=fzk_…. Voir Modules Pro.
Écrire son propre connecteur
Un projet enregistre son connecteur depuis son code serveur, puis le nomme dans FRAZA_MODULE_LICENSES_CONNECTOR :
import { registerLicenseConnector } from '@fraza-pro/module-licenses/server';
registerLicenseConnector({
key: 'mon-systeme',
label: 'Mon système de licences',
// Avant la création du projet, hors transaction. Rend l'identité du
// projet chez le fournisseur ; `secret` est facultatif, montré une fois.
async provision({ licenseId, name }) {
const account = await monSysteme.createAccount({ label: name });
return { externalId: account.id, secret: account.key };
},
// Par la tâche de synchronisation. Doit être idempotent : il est rejoué
// jusqu'à ce qu'il réussisse.
async setState(credential, state) {
await monSysteme.setAccountState(credential.external_id, state); // 'active' | 'suspended' | 'revoked'
},
// Facultatif : l'état réel chez le fournisseur.
async check(credential) {
return monSysteme.getAccountState(credential.external_id);
},
// Facultatif : ce que le client lit avec son secret, une seule fois.
instructions(credential, { secret }) {
return { npmrc: `//registry.exemple.ca/:_authToken=${secret}`, note: '' };
},
}, { source: '@mon/projet' });| Champ | Rôle |
|---|---|
key | Minuscules, chiffres et tirets, 63 caractères au plus. Une même source qui enregistre la même clé remplace son connecteur ; une autre source lève une erreur. |
provision | Obligatoire. Rend { externalId, secret? }. externalId est unique par connecteur. |
setState | Obligatoire. Applique l'état chez le fournisseur. Une exception garde son message sur la ligne de l'identifiant, et la tâche réessaie. |
check | Facultatif. |
instructions | Facultatif. Reçoit l'identifiant et { secret }, le secret en clair, à la création et à la régénération seulement. Rend { npmrc: string, note: string }, ou null : npmrc paraît en bloc à copier, note en dessous. |
Un identifiant provisionné pour une création finalement refusée (quota atteint entre-temps) n'est jamais utilisé : le connecteur doit le tolérer. Une API de licence propre à Fraza, avec des clés signées vérifiables hors ligne, viendra plus tard comme second connecteur livré.