Système d'extensions

Le Labo du Yeti embarque un système d'extensions complet, inspiré de l'architecture plugin de WordPress mais conçu pour Node 22 / ESM. Une extension est un dossier autonome installé sous extensions/installed/<id>/, déclaré par un manifeste extension.json, chargé au boot et capable d'ajouter des routes API, de s'abonner à des événements (hooks), d'écrire dans un stockage clé-valeur isolé, d'injecter des entrées dans la sidebar admin et d'enregistrer ses propres capabilities dans la matrice de droits.

Tout passe par un contexte injecté filtré selon les permissions déclarées : une extension n'a jamais accès au filesystem du CMS, ni aux modules privés (services, DB, etc.), ni aux secrets (clé Anthropic, SMTP, JWT_SECRET, hash de tokens). Elle voit uniquement les helpers que son manifest autorise explicitement.

Source de vérité. Le loader, le contexte et l'API CMS sont implémentés dans backend/src/services/extensions-service.js, extension-context.js et extension-cms-api.js. Le bus d'événements vit dans hooks-service.js.

Architecture générale

Chaque extension est représentée à trois niveaux :

Le cycle de vie d'une extension est strict :

StatutSignificationRoutes / hooks actifs ?
installedExtension extraite dans extensions/installed/<id>, ligne DB créée, pas encore chargée.Non
activeSous-router monté sur /api/ext/<id>, hooks abonnés.Oui
inactiveRoutes démontées, hooks désabonnés. Le filesystem reste intact.Non
failedErreur au chargement (manifest invalide, exception au register). Champ error rempli.Non

Le manifest extension.json

Le manifest est le point d'entrée déclaratif de toute extension. Il est validé strictement à l'installation (id en kebab-case, version semver, types corrects).

{
  "id": "hello-world",
  "name": "Hello World",
  "version": "1.0.0",
  "description": "Extension de démonstration : expose un endpoint /api/ext/hello-world/ping et logue chaque build.",
  "author": "Le Labo du Yeti",
  "min_cms_version": "0.1.0",
  "permissions": ["storage.read", "storage.write", "logs.write"],
  "hooks": ["build.after"],
  "sidebar": [
    { "label": "Hello World", "icon": "sparkles", "section": "CONFIGURATION", "to": "/ext/hello-world" }
  ],
  "pages": [
    { "path": "/page", "title": "Hello World" }
  ]
}

Champs du manifest

ChampTypeObligatoireDescription
idstring (kebab-case, 2-64 chars, regex ^[a-z0-9][a-z0-9-]{1,63}$)OuiIdentifiant unique. Sert de slug DB, de namespace de routes et de préfixe de capabilities.
namestringOuiNom affiché dans l'UI /extensions.
versionstring (semver x.y.z)OuiVersion de l'extension.
descriptionstringNonDescription courte affichée dans l'UI.
authorstringNonAuteur / éditeur.
min_cms_versionstring (semver)NonVersion minimale du CMS requise.
permissionsstring[]NonListe des permissions demandées (voir tableau dédié).
hooksstring[]NonListe indicative des hooks auxquels l'extension s'abonne (à des fins d'audit / UI).
sidebarobject[]NonEntrées à injecter dans la sidebar admin : { label, icon, section, to }.
pagesobject[]NonPages custom servies via iframe : { path, title } — exposées sous /api/ext/<id>/page côté backend.
settings_schemaobjectNonSchéma de configuration qui auto-génère la modale Configurer dans /extensions.

Permissions disponibles

Le contexte ext.* est filtré par les permissions déclarées dans manifest.permissions. Si une permission n'est pas listée, le helper correspondant n'existe tout simplement pas sur l'objet ext (accès = undefined). À l'installation, l'utilisateur valide explicitement la liste via un écran de confirmation humanisé.

Lecture du contenu CMS

PermissionHelpers exposés
cms.pages.readext.cms.pages.list / get / count
cms.pages.writeext.cms.pages.create / update / delete
cms.blog.readext.cms.blog.listArticles / getArticle / count / listCategories / listTags / getConfig
cms.blog.writeext.cms.blog.createArticle / updateArticle / deleteArticle / regenerateFeaturedImage / generateArticle / createCategory / updateCategory / deleteCategory / createTag / deleteTag / updateConfig
cms.cocon.readext.cms.cocon.listGroups / getGroup / listPublished / getPage / countPages / listMedia
cms.cocon.writeext.cms.cocon.generate / updatePage / deletePage
cms.media.readext.cms.media.list / count
cms.media.writeext.cms.media.upload / delete

Services & métadonnées

PermissionHelpers exposés
cms.ai.useext.cms.ai.isConfigured / generate — utilise la clé Anthropic du CMS sans l'exposer.
cms.images.useext.cms.images.isConfigured / search / searchProgressive — Unsplash / Pexels / Pixabay.
cms.forms.readext.cms.forms.detect / listConfigs
cms.forms.read_submissionsext.cms.forms.countSubmissions / listSubmissions
cms.logs.readext.cms.logs.list / count
cms.users.readext.cms.users.count / listRoles — jamais d'emails ni de noms.
cms.stats.readext.cms.stats.snapshot — agrégat dashboard.
cms.tokens.readext.cms.tokens.count / listMeta — jamais le token_hash.
cms.extensions.readext.cms.extensions.list / isActive

Runtime de l'extension

PermissionHelpers exposés
storage.readext.storage.get(key), ext.storage.keys()
storage.writeext.storage.set(key, value), ext.storage.delete(key) — implique storage.read pour cohérence.
settings.readext.settings.get(key), ext.settings.all()
settings.writeext.settings.set(key, value), ext.settings.delete(key)
logs.writeext.logger.info/warn/error écrit dans la table logs du CMS. Sans cette permission, le logger fallback sur console.
Champs jamais exposés. Quelles que soient les permissions accordées, aucune extension ne reçoit : emails utilisateurs, password_hash, token_hash, clés IA, identifiants SMTP, clés des banques d'images, ni JWT_SECRET. Ces champs sont retirés par les fonctions sanitize* de extension-cms-api.js avant retour.

Contexte injecté ext

Chaque extension exporte une fonction register(ext) appelée au chargement. L'objet ext regroupe tout ce que l'extension peut utiliser :

PropriétéType / formeDescription
ext.idstringIdentifiant de l'extension (depuis le manifest).
ext.manifestobjectCopie en lecture du manifest validé.
ext.routerExpress RouterRouter déjà monté sous /api/ext/<id>. Toujours exposé.
ext.hooksobjecton(name, fn), off(name, fn), emit(name, payload), applyFilters(name, payload). Toujours exposé.
ext.cmsobjectAPI filtrée par permissions. Voir tableau permissions.
ext.storageobjectKV isolé (table extension_data). Présent si storage.read ou storage.write.
ext.settingsobjectKV de configuration (table extension_settings). Présent si settings.read ou settings.write.
ext.loggerobjectinfo/warn/error. Persiste dans la table logs si logs.write, sinon fallback console.
ext.capabilitiesobjectregister({id, label, group, defaults}) et unregister(id). Toujours exposé.

Hooks du core

Le bus d'événements (backend/src/services/hooks-service.js) supporte deux patterns : les événements (emit, fire-and-forget en série) et les filtres (applyFilters, transformation chaînée du payload). Les listeners sont awaités en série ; une exception est loggée mais ne casse pas la chaîne.

Hooks événements

HookPayloadQuand
page.before_save{ page, input }Avant écriture d'une page classique.
page.after_save{ page }Après sauvegarde réussie d'une page.
page.after_delete{ id }Après suppression d'une page.
build.before{ mode }Au début d'un build (full ou partiel).
build.after{ mode, summary }À la fin d'un build avec le récap (pages, articles, cocon).
cocon.before_generate{ groupId, combos }Avant génération de pages cocon.
cocon.after_generate{ groupId, result }Après génération de pages cocon.
user.login{ user }Connexion réussie (post-2FA).
user.logout{ user }Déconnexion.
form.submission{ form_id, data }Soumission publique d'un formulaire.
extension.activated{ id }Extension activée.
extension.deactivated{ id }Extension désactivée.

Hooks filtres (transformation)

FiltrePayloadUsage typique
media.before_save{ buffer, originalName }Convertir / optimiser une image avant écriture disque (ex. webp-auto-converter).
render.htmlHTML stringModifier le HTML final d'une page rendue (ex. injection snippet SureFeedback / BugHerd).

Pattern filtre : chaque listener reçoit le payload et peut retourner une version modifiée. Si rien n'est retourné, le payload précédent est conservé. Le résultat final est la sortie du dernier listener.

Storage & settings

Chaque extension dispose de deux espaces KV isolés (clés composites (extension_id, key)) :

// Storage : compteur de builds
const n = (ext.storage.get('build_count') || 0) + 1;
ext.storage.set('build_count', n);
ext.logger.info(`Build #${n} terminé`);

// Settings : lire une config utilisateur
const endpoint = ext.settings.get('webhook_url');
if (endpoint) await fetch(endpoint, { method: 'POST', body: '...' });

Les valeurs sont sérialisées en JSON automatiquement (string, number, boolean, object, array). Les FOREIGN KEY ... ON DELETE CASCADE garantissent un nettoyage propre à la désinstallation.

Une extension peut injecter des entrées dans la sidebar admin via manifest.sidebar. Format d'une entrée :

{
  "label": "Hello World",
  "icon": "sparkles",
  "section": "CONFIGURATION",
  "to": "/ext/hello-world"
}

Capabilities (matrice de droits)

Une extension peut enregistrer ses propres capabilities dans la matrice owner pour bénéficier du même contrôle d'accès que les features du core. L'id est automatiquement préfixé par ext.<extension-id>. pour éviter les conflits.

ext.capabilities.register({
  id: 'webp_convert',                                // → ext.webp-auto-converter.webp_convert
  label: 'Conversion WebP auto',
  group: 'Extension : WebP Auto Converter',         // défaut : "Extension : <name>"
  defaults: { admin: true, webmaster: true, editor: false }
});

La feature apparaît immédiatement dans la matrice owner sous /admin/permissions et peut être activée/désactivée par rôle. Côté backend, l'extension peut vérifier l'accès via les helpers exposés par capabilities-service.js.

Workflow d'installation

L'installation d'une extension passe par deux endpoints exposés sous /api/extensions (auth + role owner|admin|webmaster + capability extensions) :

1. POST /api/extensions/inspect

Upload du zip (max 10 Mo, mimetype strict .zip). Le backend extrait dans un dossier temporaire, valide le manifest et retourne un preview :

{
  "manifest": { "id": "hello-world", "name": "Hello World", "version": "1.0.0", ... },
  "permissions_humanized": [
    "Lire et écrire le stockage interne de l'extension",
    "Écrire dans les logs du CMS",
    "S'abonner à l'événement build.after"
  ],
  "already_installed": false,
  "files_summary": { "routes": true, "hooks": true, "package_json": true }
}

L'UI affiche une modale de confirmation listant chaque permission en clair (jamais d'identifiants techniques bruts à l'utilisateur final).

2. POST /api/extensions

Confirmation côté utilisateur → install réelle :

  1. Extraction du zip vers extensions/installed/<id>/ avec garde anti zip slip.
  2. Si package.json présent : exécution npm install --omit=dev --no-audit --no-fund dans le dossier de l'extension. Le binaire npm est résolu via le sibling de process.execPath (Plesk-safe).
  3. Insertion dans la table extensions avec statut installed, version, copie du manifest.
  4. Auto-activation : appel de activateExtension(id) qui monte les routes, abonne les hooks et bascule en active.
  5. Log type=extension action=installed dans la table logs.
Zéro redémarrage. Une extension est utilisable immédiatement après son installation : les routes /api/ext/<id>/... répondent, les hooks sont actifs et la sidebar est rafraîchie côté React via un événement.

Autres endpoints

MéthodeRouteEffet
GET/api/extensionsListe toutes les extensions (statut, version, manifest).
GET/api/extensions/menuEntrées sidebar des extensions actives.
GET/api/extensions/:idDétail d'une extension.
GET/api/extensions/:id/settingsSettings stockés pour l'extension.
PUT/api/extensions/:id/activateRecharge le module, monte routes + hooks → statut active.
PUT/api/extensions/:id/deactivateDémonte routes + désabonne hooks → statut inactive.
PUT/api/extensions/:id/settingsMet à jour les settings (basé sur settings_schema).
DELETE/api/extensions/:idDésactive + supprime le dossier + supprime les lignes DB en cascade.

Pour la liste détaillée des middlewares (auth, capability, multer), voir API REST → /api/extensions.

Guide : créer sa première extension

Cet exemple reprend l'extension de référence extensions/installed/hello-world/ livrée avec le CMS et l'enrichit pour démontrer toutes les capacités du système.

Étape 1 — Structure du dossier

my-extension/
├── extension.json          ← manifest (obligatoire)
├── package.json            ← optionnel (deps npm)
└── server/
    ├── routes.js           ← export default register(ext) → ext.router.*
    └── hooks.js            ← export default register(ext) → ext.hooks.on(...)

Étape 2 — Le manifest

{
  "id": "blog-auto-poster",
  "name": "Blog Auto Poster",
  "version": "1.0.0",
  "description": "Publie automatiquement un article de blog après chaque build.",
  "author": "Acme",
  "min_cms_version": "0.1.0",
  "permissions": [
    "storage.read",
    "storage.write",
    "logs.write",
    "cms.blog.read",
    "cms.blog.write"
  ],
  "hooks": ["build.after"],
  "sidebar": [
    { "label": "Auto Poster", "icon": "rocket", "section": "MARKETING", "to": "/ext/blog-auto-poster" }
  ]
}

Étape 3 — Routes API

Le fichier server/routes.js exporte une fonction register(ext). Les routes définies sur ext.router sont automatiquement préfixées par /api/ext/blog-auto-poster.

// server/routes.js
export default function register(ext) {
  ext.router.get('/status', (req, res) => {
    const lastPost = ext.storage.get('last_post_at') || null;
    const totalPosts = ext.storage.get('total_posts') || 0;
    res.json({ ok: true, lastPost, totalPosts });
  });

  ext.router.post('/trigger', async (req, res) => {
    try {
      const article = await ext.cms.blog.createArticle({
        title: 'Article auto — ' + new Date().toISOString(),
        status: 'draft',
        categories: ['actualites'],
      });
      ext.storage.set('total_posts', (ext.storage.get('total_posts') || 0) + 1);
      ext.storage.set('last_post_at', new Date().toISOString());
      ext.logger.info('Article auto créé', { id: article.id });
      res.json({ ok: true, article });
    } catch (err) {
      ext.logger.error('Création article échouée', { error: err.message });
      res.status(500).json({ ok: false, error: err.message });
    }
  });

  ext.logger.info('blog-auto-poster routes loaded');
}

Étape 4 — Hooks

Le fichier server/hooks.js abonne l'extension aux événements du core :

// server/hooks.js
export default function register(ext) {
  ext.hooks.on('build.after', async ({ mode, summary } = {}) => {
    // Publier un article récap après chaque build full
    if (mode !== 'full') return;

    const last = ext.storage.get('last_build_post_at');
    const now = Date.now();
    if (last && now - last < 24 * 3600 * 1000) return; // 1/jour max

    const article = await ext.cms.blog.createArticle({
      title: `Build du ${new Date().toLocaleDateString('fr-FR')}`,
      status: 'published',
    });
    ext.storage.set('last_build_post_at', now);
    ext.logger.info(`Build #${mode} → article publié`, { article_id: article.id, summary });
  });
}

Étape 5 — Déclarer une capability

Pour que les rôles non-owner puissent contrôler l'accès à l'auto-poster, on enregistre une capability au boot :

// server/hooks.js (ajouter dans register)
ext.capabilities.register({
  id: 'auto_post',
  label: 'Publication auto de l\'article de build',
  defaults: { admin: true, webmaster: true, editor: false }
});

Étape 6 — Tester en local

  1. Place le dossier dans extensions/installed/blog-auto-poster/.
  2. Redémarre le CMS : le loader scanne extensions/installed/*, valide le manifest et active l'extension.
  3. Vérifie le statut dans /extensions (UI) : ligne active, aucun champ error.
  4. Test endpoint : curl http://localhost:3000/api/ext/blog-auto-poster/status (avec un cookie JWT valide).
  5. Déclenche un build pour vérifier que le hook fire correctement et qu'un article est publié.

Étape 7 — Packager en zip

# Depuis le dossier parent de l'extension
Compress-Archive -Path .\blog-auto-poster\* -DestinationPath .\blog-auto-poster-v1.0.0.zip

Le zip est ensuite installable via l'UI /extensions → Installer ou via l'API. La structure attendue dans l'archive est exactement le contenu du dossier (pas de dossier racine englobant).

Anti-zip-slip. Le backend rejette toute archive contenant des chemins absolus ou des .. qui sortiraient du dossier cible. Pour les détails, voir Sécurité → ZIP slip guard.

Exemple de référence : hello-world

Livré avec le CMS sous extensions/installed/hello-world/, sert de squelette minimal pour démarrer.

extension.json

{
  "id": "hello-world",
  "name": "Hello World",
  "version": "1.0.0",
  "description": "Extension de démonstration : expose un endpoint /api/ext/hello-world/ping et logue chaque build.",
  "author": "Le Labo du Yeti",
  "min_cms_version": "0.1.0",
  "permissions": ["storage.read", "storage.write", "logs.write"],
  "hooks": ["build.after"]
}

server/routes.js

export default function register(ext) {
  ext.router.get('/ping', (req, res) => {
    res.json({
      ok: true,
      extension: ext.id,
      version: ext.manifest.version,
      message: 'Hello from extension !',
      timestamp: new Date().toISOString(),
    });
  });

  ext.router.get('/count', (req, res) => {
    const n = (ext.storage.get('ping_count') || 0) + 1;
    ext.storage.set('ping_count', n);
    res.json({ ping_count: n });
  });

  ext.logger.info('Hello World extension loaded');
}

server/hooks.js

export default function register(ext) {
  ext.hooks.on('build.after', async ({ mode, summary } = {}) => {
    const n = (ext.storage.get('build_count') || 0) + 1;
    ext.storage.set('build_count', n);
    ext.logger.info(`Build #${n} terminé (mode=${mode || 'n/a'})`);
  });
}

Extensions livrées avec le CMS

Bonnes pratiques

Références