Aller au contenu

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 que npm login donne 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éponseQuand
{ "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.
404Le 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 de npm login des 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, le htpasswd.

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.js

Puis, 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: $maintainers

access: $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églageDéfautRôle
urlLa route du site. Obligatoire.
site_secret, site_secret_envLe 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_seconds300Combien de temps une réponse est gardée. C'est le délai maximal avant qu'une suspension prenne effet.
stale_seconds3600Combien de temps un « oui » sert encore quand le site ne répond pas.
timeout_ms5000Le délai d'attente de la réponse du site.
key_prefixfzk_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_entries10000Le 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 :

  1. Créer un projet depuis l'espace client, et coller ses lignes dans le .npmrc d'un dossier de test. npm view @mon-scope/<paquet> répond.
  2. Remplacer la clé par une clé inventée, fzk_ suivi de n'importe quoi : la même commande reçoit 401.
  3. Avec un compte de mainteneur du htpasswd, npm login --registry=https://registry.exemple.ca/ puis npm publish réussissent toujours.
  4. Suspendre le projet depuis l'admin, attendre cache_seconds, et relancer npm view avec la clé du projet : la requête est refusée.
  5. 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_seconds si 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-pro

Le 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' });
ChampRôle
keyMinuscules, 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.
provisionObligatoire. Rend { externalId, secret? }. externalId est unique par connecteur.
setStateObligatoire. Applique l'état chez le fournisseur. Une exception garde son message sur la ligne de l'identifiant, et la tâche réessaie.
checkFacultatif.
instructionsFacultatif. 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é.

Sur cette page