Search by

licenceo / wordpress-sdk

licenceo

SDK d'intégration des licences Licenceo pour plugins et thèmes WordPress (activation, revalidation, mises à jour, remontée de plantage).

v1.1.1 2026-09-16 10:08 UTC

This package is not auto-updated.

Last update: 2026-09-16 17:42:11 UTC


README

SDK d'intégration des licences Licenceo pour plugins et thèmes WordPress. Il gère l'activation, la revalidation périodique, les mises à jour automatiques et la remontée de plantage — vous n'avez rien d'autre à écrire côté client.

Ce dépôt n'est pas un plugin autonome : vous l'intégrez dans votre plugin ou thème, puis vous initialisez le SDK avec la configuration de votre produit.

Installation

Composer

Depuis le dossier de votre plugin ou thème :

composer require licenceo/wordpress-sdk

Le SDK est distribué sur Packagist, depuis le dépôt public GitLab.

Dans le fichier principal du plugin, ou functions.php du thème, après le chargement de WordPress :

require_once __DIR__ . '/vendor/autoload.php';
$licence = \Licenceo\SDK\SDK::init([
    'api_url'     => 'https://api.licenceo.app/api',
    'product_id'  => 12, // votre produit Licenceo
    'slug'        => 'mon-super-plugin',
    'plugin_file' => __FILE__, // fichier principal du plugin
    'version'    => '1.4.2',
]);
// Le SDK enregistre lui-même le hook WordPress de désactivation du plugin.
// Il nettoie le cron, sans libérer la licence. Voir le parcours et les actions ci-dessous.

Conservez composer.json et composer.lock dans votre projet. Embarquez vendor/ dans le ZIP livré aux clients (composer install --no-dev --prefer-dist --optimize-autoloader pendant la préparation du ZIP). Vos clients n’ont pas besoin de Composer.

Le SDK nécessite WordPress pour fonctionner ; charger l’autoloader dans un outil CLI hors WordPress ne doit pas arrêter cet outil. La règle de cohabitation reste inchangée : si plusieurs plugins embarquent le SDK, la première copie chargée fait foi.

Autoload et namespace

Composer déclare le préfixe PSR-4 Licenceo\SDK\ vers src/, en conservant licenceo-sdk.php dans autoload.files pour le chargement WordPress et la protection contre les copies multiples. Le namespace conserve sa casse historique SDK ; utilisez cette même casse dans vos imports.

La façade \Licenceo\SDK\SDK suffit pour le parcours standard ; les classes internes ne sont pas nécessaires.

Dans WordPress, le bootstrap continue de charger ensemble les classes de la première copie : PSR-4 ne remplace pas la règle de cohabitation et ne rend pas le SDK indépendant de WordPress. Spécification PSR-4.

Deux secrets, deux moments

La clé de licence ne sert qu'une fois, à l'activation. Le SDK reçoit alors un jeton propre à ce site, le conserve, et oublie la clé.

Clé de licence
    → activation initiale
    → le serveur émet un jeton propre à cette activation
    → le SDK stocke le jeton, la clé n'est plus conservée
    → toutes les opérations suivantes utilisent le jeton

Ce que le SDK garde dans wp_options, préfixé par le slug de votre produit (donc sans collision entre extensions) :

OptionContenu
<prefix>_activation_tokenLe jeton (lat_…). Jamais affiché, jamais inclus dans une notice.
<prefix>_license_key_hintLes 4 derniers caractères de la clé, pour l'affichage seul.
<prefix>_license_statusDernier verdict connu (validité, raison, paliers). Aucun secret.

Toutes ces options sont écrites avec autoload = no (troisième argument d'update_option()). WordPress charge à chaque requête l'ensemble des options autoloadées et les conserve en cache objet : un secret n'a rien à y faire, il doit être lu explicitement au moment où on s'en sert. C'est vérifié par un test (test_no_option_written_by_the_sdk_is_autoloaded), parce qu'omettre ce troisième argument ne provoque aucune erreur visible.

Le jeton ne sort de wp_options que dans un en-tête HTTP X-Activation-Token. Il n'apparaît jamais dans une URL, une notice d'administration, un message d'erreur, ni dans l'onglet Santé du site — le SDK n'expose aucune route REST et ne s'enregistre dans aucun rapport de débogage WordPress.

Ce que ça change en cas de compromission du site. Un attaquant qui lit la base WordPress obtient un jeton qui ne vaut que pour cette activation : il ne peut pas créer d'autres activations, ni utiliser les emplacements restants de la licence, ni toucher aux autres sites. Sa révocation n'affecte qu'eux.

Ce que ça ne change pas. Une clé de licence volée reste utilisable pour créer une nouvelle activation si un emplacement est libre. Le jeton réduit la portée d'un secret ; ce n'est pas une preuve d'achat ni de possession du domaine.

Cache des mises à jour

La réponse de latest-version reste en cache pendant 12 heures. Sur cache froid, un verrou interne <prefix>_remote_version_lock, non autoloadé, limite la recherche à un seul propriétaire par site et par intégration. Les autres processus quittent le contrôle sans requête HTTP supplémentaire ; ils pourront utiliser le cache lors d'un contrôle ultérieur.

Le verrou utilise directement la table d'options du site, indépendamment du cache objet persistant : INSERT IGNORE s'appuie sur l'unicité de option_name, puis un UPDATE conditionnel permet de reprendre uniquement un bail expiré. Il n'utilise pas le couple lecture/écriture de transients, ni l'upsert d'add_option(). Après acquisition, le cache est relu en évitant un éventuel résultat négatif conservé dans le processus courant.

Le bail expire après 120 secondes, mesurées par MySQL, avec une marge sur le timeout HTTP de 15 secondes. Un processus interrompu laisse donc un verrou récupérable au prochain contrôle après expiration. La libération dans finally vérifie le propriétaire : un ancien processus ne peut pas supprimer le bail de son successeur. Une erreur HTTP, réseau ou de réponse ne remplit pas le cache et libère immédiatement le verrou. Si la base refuse l'acquisition, aucun appel API n'est tenté sans verrou.

Comme tout verrou à bail, l'exclusion vaut pendant sa durée : un processus suspendu plus de 120 secondes peut chevaucher son successeur après sa reprise. Le SDK ne prolonge pas le bail et n'ajoute ni attente active ni appel réseau pour cela. En multisite, le verrou suit la table d'options du site, comme le transient existant.

Perdre ou renouveler un jeton

Aucun endpoint ne permet de relire un jeton existant — c'est précisément ce qui empêche quiconque détient la clé de reprendre la main sur un site en place.

  • Le site a encore son jeton : $licence->rotate_token() en obtient un neuf et invalide l'ancien. Opération explicite ; le SDK ne tourne jamais de lui-même.
  • Le jeton est perdu (base restaurée, site réinstallé) : l'éditeur révoque l'activation depuis son tableau de bord, ce qui libère l'emplacement, puis le site se réactive avec la clé de licence.

deactivate() efface l'état local uniquement après 200 deactivated: true ou 404 confirmant invalid_token/not_activated. Toute panne réseau, réponse 429/5xx, erreur 422 ou réponse incomplète conserve le jeton et retourne success: false. Une licence expirée ou un compte suspendu peut libérer son emplacement si le jeton est encore authentifié.

Côté serveur, trois événements invalident un jeton immédiatement : la désactivation par le site lui-même, la révocation de son activation par l'éditeur, et la révocation de la licence entière, qui coupe d'un coup toutes ses activations, dans une seule transaction.

Les tests de ce dépôt simulent WordPress et les réponses HTTP. Les tests de contrat avec le SaaS sont maintenus séparément dans le dépôt privé de l’application.

Réactiver un domaine déjà actif échoue volontairement (already_activated) et ne réémet aucun jeton.

Utilisation — Plugin

$licence = \Licenceo\SDK\SDK::init([
    'api_url'     => 'https://api.licenceo.app/api',
    'product_id'  => 12,
    'slug'        => 'mon-super-plugin',
    'plugin_file' => plugin_basename(__FILE__), // ex. "mon-super-plugin/mon-super-plugin.php"
    'version'     => '1.4.2',                    // version installée
    // Optionnel :
    // 'option_prefix'   => 'mon_super_plugin',
    // 'homepage'        => 'https://exemple.com/mon-super-plugin',
    // 'report_crashes'  => true, // activé par défaut ; false pour désactiver.
]);

// Activer depuis un écran de réglages, une seule fois :
// $licence->activate($cle_saisie);   // la clé n'est pas conservée
// $licence->deactivate();            // libère ce site
// $licence->rotate_token();          // renouvelle le jeton de ce site

// Pour l'affichage : « clé se terminant par …8HJ6 »
// $licence->license_key_hint();

if ($licence->is_valid()) {
    // ... fonctionnalités premium ...
}

// Paliers (Basic/Pro/Agency) du même produit, gérés depuis le dashboard
// Licenceo — pas besoin d'un produit séparé par palier :
if ($licence->has_feature('rapports-avances')) {
    // ...
}

Écran de saisie de la clé

Le SDK ne fournit pas d'interface : c'est votre plugin qui présente le champ. Quelques règles à respecter, parce que la clé transite par ce formulaire.

add_action('admin_post_mon_plugin_activer', function () use ($licence) {
    // 1. Réservé aux administrateurs du site.
    if (! current_user_can('manage_options')) {
        wp_die(__('Action non autorisée.'));
    }

    // 2. Jeton anti-CSRF obligatoire.
    check_admin_referer('mon_plugin_activer');

    // 3. Nettoyage de la saisie.
    $cle = sanitize_text_field(wp_unslash($_POST['licence'] ?? ''));

    // 4. La clé sert uniquement à l'activation, et n'est pas conservée :
    //    le SDK ne retient que le jeton renvoyé par l'API.
    $resultat = $licence->activate($cle);
    unset($cle);

    // 5. Ne jamais réafficher la clé soumise dans le HTML de retour.
    wp_safe_redirect(add_query_arg('licenceo', $resultat['success'] ? 'ok' : 'ko', wp_get_referer()));
    exit;
});

Pour l'affichage ultérieur, utilisez $licence->license_key_hint() — les quatre derniers caractères — plutôt que de redemander ou de stocker la clé entière.

État public et affichage

L'intégration standard utilise uniquement Licenceo\SDK\SDK. Initialisez une seule instance par produit et par site WordPress, au chargement du fichier principal.

// Dans votre écran de réglages, après contrôle de la capacité de l'utilisateur :
$active = $licence->is_activated(); // un jeton est conservé localement
$valid = $licence->is_valid();     // dernier verdict confirmé par Licenceo
$status = $licence->status();     // aucune requête HTTP
$message = $licence->status_message();
$domain = $licence->domain();

// Échapper les valeurs à l'affichage. La date vient du serveur, jamais d'un calcul local.
echo esc_html($message);
echo esc_html($domain);
if ($status['expires_at'] !== null) {
    echo esc_html(wp_date(get_option('date_format'), strtotime($status['expires_at'])));
}

// Après vérification de capacité + nonce, sur une action explicite de l'utilisateur :
// $result = $licence->activate($cle_saisie);
// $result = $licence->refresh_status(); // résultat de la tentative, pas le statut
// $result = $licence->deactivate();    // libère l'emplacement côté Licenceo

status() retourne toujours ces six champs :

ChampTypeSignification
validboolDernier verdict serveur ; false sans activation.
statusstringactive, expired, revoked (état de la licence), unknown (jeton non reconnu), ou not_activated (aucune activation locale). Une licence peut rester active mais être inutilisable si le compte éditeur est suspendu.
reasonstring ou nullMotif du refus serveur ; null si valide ou avant activation.
checked_atstring ISO 8601 UTC ou nullRéception du dernier verdict confirmé, y compris celui de l'activation. null avant activation et après désactivation.
expires_atstring ISO 8601 ou nullDate fournie par Licenceo. null pour une licence perpétuelle ou lorsqu'aucune licence n'est authentifiée ; vérifier status avant d'afficher « perpétuelle ».
featuresliste de strings ou nullClés du palier. null = aucune restriction de fonctionnalités, uniquement si la licence est valide. Liste vide sans licence authentifiée. Préférer has_feature() pour autoriser une fonctionnalité.
[
    'valid'      => true,
    'status'     => 'active',
    'reason'     => null,
    'checked_at' => '2026-09-16T10:00:00+00:00',
    'expires_at' => '2027-09-16T10:00:00+00:00',
    'features'   => ['export-csv'],
]

L’activation transmet le product_id configuré. L’API refuse la clé d’un autre produit (404 not_found) avant de créer une activation ; aucune compensation locale n’est nécessaire.

Une activation expirée peut toujours avoir un jeton local : is_activated() n'est pas une autorisation d'accès. is_valid() et has_feature() utilisent le dernier verdict ; ils ne calculent pas l'expiration sur l'horloge du client. Après un renouvellement, la revalidation met à jour la date et la validité. Une panne réseau, un HTTP 429/5xx ou un JSON incomplet conserve tout le dernier statut, y compris checked_at. Le cron horaire réessaiera ; refresh_status() permet une demande explicite. N'appelez pas cette méthode à chaque affichage de page.

status_message() traduit le dernier verdict : expired, revoked, invalid_token, account_suspended, not_found, invalid_license, limit_reached, already_activated, subscription_required, production_domain_blocked, not_activated. Un code inconnu retourne « Licence invalide. ». Un échec de activate() ne remplace pas l'état d'une licence déjà installée : affichez alors le message du résultat de l'opération.

Revalidation manuelle

refresh_status() suit la même convention que activate(), deactivate() et rotate_token() : un tableau ['success' => bool, 'message' => string]. success décrit la réussite de la revalidation, pas la validité de la licence. Le statut n'est pas dupliqué dans ce résultat : status() le lit localement.

// Dans une action de réglages protégée par capacité utilisateur et nonce :
$result = $licence->refresh_status();

if ($result['success']) {
    $status = $licence->status(); // aucun appel réseau supplémentaire
    if ($status['valid']) {
        // La licence est valide : expiration et fonctionnalités sont à jour.
    } else {
        // Revalidation réussie, mais licence expirée, révoquée ou autre refus métier.
        echo esc_html($licence->status_message());
    }
} else {
    echo esc_html($result['message']);
    $status = $licence->status(); // dernier verdict confirmé, inchangé
}

Un HTTP 200 portant un verdict métier exploitable donne success=true, y compris si valid=false. Le SDK enregistre alors le statut, l'expiration, les fonctionnalités et la date de réception checked_at. Une erreur réseau, un timeout, un HTTP 429/5xx (ou tout HTTP autre que 200) ou une réponse invalide/incomplète donne success=false et ne modifie aucun champ du dernier statut. Le message de l'opération explique la réussite ou l'impossibilité de revalider ; status_message() décrit la licence.

Sans activation locale, success=false, un message le précise et aucune requête n'est envoyée. Sinon, chaque tentative effectue exactement un appel HTTP, sans relance automatique. Le cron utilise la même logique et continue sa revalidation horaire. Ne comparez pas les checked_at pour détecter la réussite : deux verdicts reçus dans la même seconde peuvent avoir la même date.

Lifecycle WordPress

Pour un plugin, SDK::init() enregistre automatiquement le hook de désactivation WordPress à partir de plugin_file (chemin du fichier principal, absolu ou plugin_basename(__FILE__)). Ce hook appelle on_deactivation($network_wide = false) et supprime tous les événements du cron SDK. Il ne désactive pas la licence, ne fait aucun appel API et conserve les options pour la prochaine activation du plugin. Le chargement suivant du plugin réinstalle le cron.

Pour un thème, le cycle de vie reste explicite dans functions.php :

add_action('switch_theme', function () use ($licence) {
    $licence->on_deactivation();
});

deactivate() concerne exclusivement la licence : succès confirmé (ou activation confirmée absente) → suppression du jeton, de l'aperçu de clé et du statut, puis invalidation du cache des mises à jour. Il ne désactive ni le plugin ni son cron.

Domaine et multisite

domain() renvoie le host de home_url() en minuscules, sans www., port ou chemin, comme la normalisation de l'API. Cette même valeur est envoyée pour l'activation, la validation, la désactivation, la rotation, les mises à jour et les rapports de plantage. Une instance appartient au site où elle a été initialisée. Après switch_to_blog(), initialisez une instance pour le site courant et restaurez le contexte avec restore_current_blog() ; réutiliser l'ancienne instance pour accéder à une licence lève une LogicException plutôt que mélanger domaines et jetons. Les options sont locales à chaque blog. Plusieurs blogs sur un même host restent un même domaine du point de vue de Licenceo (pas de licence réseau implicite). La désactivation réseau du plugin nettoie le cron de chaque site, sans révoquer les licences.

Noms des options et transients

Le préfixe est licenceo_<libellé>_<empreinte> : libellé normalisé et limité à 24 caractères, empreinte SHA-256 tronquée à 32 caractères du tableau JSON [api_url sans slash final, product_id en string, type, slug, plugin_file normalisé ou stylesheet]. La version installée n'entre pas dans l'identité. Le libellé est le slug par défaut ; option_prefix permet de le remplacer mais ne désactive jamais l'isolation. Deux slugs qui se normalisent pareil restent distincts. Ne changez pas ces paramètres une fois distribué sans prévoir une réactivation.

Suffixes : _activation_token (string), _license_key_hint (string, 4 caractères), _license_status (tableau ci-dessus), _remote_version (transient de mise à jour), _crash_reported_at (transient anti-spam). Le hook cron utilise _license_check. Les trois options sont écrites sans autoload. Il n'y a pas de migration des anciens préfixes de développement : libérez les anciennes activations depuis Licenceo puis réactivez avec le nouveau SDK. La clé complète n'est jamais persistée.

Utilisation — Thème

Mêmes clés, sauf plugin_file remplacé par type => 'theme'. Le stylesheet est détecté automatiquement via get_template() (le thème parent — correct même si l'utilisateur final active un thème enfant par-dessus) :

$licence = \Licenceo\SDK\SDK::init([
    'api_url'    => 'https://api.licenceo.app/api',
    'product_id' => 12,
    'slug'       => 'mon-super-theme',
    'type'       => 'theme',
    'version'    => '1.4.2',
    // 'stylesheet' => get_template(), // optionnel, auto-détecté
]);
add_action('switch_theme', function () use ($licence) {
    $licence->on_deactivation();
});

Compatibilité

  • PHP >= 7.4
  • WordPress >= 5.0 (fonctions wp_remote_*, transients, hooks d'update standards)

Plusieurs produits basés sur ce SDK sur le même site

Chaque produit embarque sa propre copie du SDK. Le comportement est le suivant, et c'est une limite connue, pas une résolution complète de versions :

  • Aucune erreur fatale : la redéclaration de classe est détectée et évitée, quel que soit le nombre de copies présentes.
  • La première copie chargée fait foi, quelle que soit sa version, pour tous les produits du site. L'ordre de chargement dépend de WordPress, pas du SDK.
  • Un conflit de versions est signalé : si une copie ignorée porte une version différente de la copie active, une notice d'administration l'indique (visible uniquement par un administrateur, jamais par un visiteur).
  • Limite, non résolue : si deux copies de versions différentes n'exposent pas les mêmes méthodes, le produit qui attend la version la plus récente peut appeler une méthode absente de la version réellement chargée — et cela échouera. Le SDK détecte et signale le conflit, il ne le résout pas : il n'y a ni chargement parallèle, ni pont de compatibilité entre versions. Gardez le SDK à jour dans tous vos produits.

La version de la copie chargée est lisible dans la constante LICENCEO_SDK_VERSION et dans \Licenceo\SDK\SDK::VERSION.

Ces comportements sont vérifiés en processus PHP isolé (tests/IsolatedLoadTest.php), pas seulement par lecture du code.

Note d'implémentation. La classe SDK vit dans src/SDK.php et non dans licenceo-sdk.php, et ce n'est pas un détail de rangement : PHP lie une déclaration de classe sans parent dès la compilation du fichier qui la contient. Une classe déclarée dans le fichier portant le garde-fou class_exists() existerait donc déjà quand ce garde-fou s'exécute — il se déclencherait au tout premier chargement, aucun fichier de src/ ne serait chargé, et SDK::init() échouerait sur « Class Licenceo\SDK\Client not found ». C'était le cas jusqu'à la version 1.1.0. Ne remettez pas de déclaration de classe dans le point d'entrée.

Tests

La suite est autonome : PHPUnit est une dépendance de développement de ce dépôt. Elle nécessite PHP 8.3 ou supérieur et Composer ; le SDK distribué conserve PHP 7.4 comme minimum d’exécution. Les fonctions WordPress utilisées (wp_remote_*, options, transients, hooks) sont simulées, sans dépendance à l’application Licenceo.

composer install
vendor/bin/phpunit

Ce qui est couvert : construction des requêtes (méthode HTTP, en-tête X-License-Key, corps JSON, absence de clé dans les URL), activation/désactivation, validation, recherche de version, cache, conservation du dernier statut connu en cas de panne réseau ou d'erreur serveur, paliers de fonctionnalités.

Verrou de mise à jour : les tests unitaires couvrent les caches chaud/froid, le verrou détenu, sa libération sur succès/erreur/exception, la reprise après expiration, la relecture après acquisition, le multisite et les deux modes de cache objet. Le scénario réentrant simule un entrelacement ; ce n'est pas un test de concurrence multiprocessus.

UpdaterLockDatabaseTest exécute les requêtes SQL de production sur MySQL via plusieurs connexions indépendantes : unicité de la clé, absence d'écrasement d'un bail actif, reprise conditionnelle à expiration, horloge SQL et suppression réservée au propriétaire. Ces tests de sémantique SQL sont optionnels et nécessitent pdo_mysql ainsi qu'un compte de test autorisé à créer/supprimer une base. Ils créent une base isolée licenceo_sdk_test_<aléatoire>, supprimée à la fin ; ils ne doivent pas utiliser un compte de production.

LICENCEO_TEST_MYSQL_DSN='mysql:host=127.0.0.1;port=3306' \
LICENCEO_TEST_MYSQL_USER='sdk_test' \
LICENCEO_TEST_MYSQL_PASSWORD='mot-de-passe-de-test' \
vendor/bin/phpunit

Sans ces variables, les trois tests MySQL sont signalés comme ignorés. Ils vérifient les primitives réelles, sans prétendre lancer des requêtes PHP simultanées ; l'exclusion entre processus repose sur la contrainte unique et l'évaluation atomique du prédicat d'UPDATE par MySQL.

Chargement réel du SDK (tests/IsolatedLoadTest.php) : ces tests lancent un vrai processus php neuf, sans aucune classe préchargée, et chargent le SDK comme le ferait un plugin. Ils couvrent les deux modes d'installation — copie manuelle et autoload Composer réellement généré — ainsi que l'initialisation avec une configuration valide puis incomplète, et la coexistence de deux copies physiques distinctes : même version, versions différentes, ordre de chargement, notice d'administration et absence de notice pour un visiteur.

Ces tests existent parce qu'un test unitaire à classes préchargées n'aurait rien prouvé : c'est exactement par là qu'un défaut réel était passé (le SDK ne se chargeait sur aucune installation, cf. section Compatibilité).

Limites connues de cette suite :

  • les fonctions WordPress sont simulées, pas réelles : elle ne prouve pas l'intégration avec un vrai WordPress (transients persistés, planification WP-Cron réelle, rendu des écrans d'administration) ;
  • aucun test de bout en bout contre l'API Licenceo réelle : les réponses de l'API sont jouées depuis des fixtures ;
  • Checker (revalidation par WP-Cron) et CrashReporter (détection d'erreur fatale via register_shutdown_function) ne sont pas couverts : leur déclenchement dépend du cycle de vie de WordPress et de PHP ;
  • la coexistence est vérifiée sur des copies dont seule la version diffère. Deux copies dont les API divergent réellement (une méthode présente d'un côté, absente de l'autre) ne sont pas simulées : c'est précisément la limite documentée ci-dessous, et elle n'est pas résolue.

Documentation complète

La référence de l'API REST sous-jacente (endpoints, authentification, webhooks) est disponible sur licenceo.app/docs/api.

Licence

Ce SDK est distribué sous licence GPL-2.0-or-later, cohérente avec l'écosystème WordPress. Voir LICENSE et la notice juridique du SDK pour le détail de ce que cette licence couvre (le code du SDK) et ne couvre pas (le SaaS Licenceo lui-même, son API, sa marque).

Support

Questions, bugs : contact@licenceo.fr.

Releases

Les deux versions internes, $licenceo_sdk_copy_version dans licenceo-sdk.php et SDK::VERSION dans src/SDK.php, doivent toujours rester identiques.

Avant de publier une version depuis ce dépôt :

composer validate --strict
composer install
vendor/bin/phpunit

La publication est réalisée via les tags du dépôt public GitLab et Packagist. Le fichier composer.json ne contient pas de champ version : Packagist utilise les tags Git. Ne modifiez pas un tag déjà publié pour y remplacer le code.