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.
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 :
- Filesystem —
extensions/installed/<id>/contientextension.json, un éventuelpackage.json, et un dossierserver/avecroutes.jset/ouhooks.js. - Base de données — 3 tables SQLite :
extensions(manifest, statut, version, erreur de chargement),extension_settings(clé-valeur de configuration UI) etextension_data(clé-valeur générique). - Runtime — un contexte
extest construit au démarrage, ses routes sont montées sous/api/ext/<id>et ses listeners de hooks sont abonnés au bus.
Le cycle de vie d'une extension est strict :
| Statut | Signification | Routes / hooks actifs ? |
|---|---|---|
installed | Extension extraite dans extensions/installed/<id>, ligne DB créée, pas encore chargée. | Non |
active | Sous-router monté sur /api/ext/<id>, hooks abonnés. | Oui |
inactive | Routes démontées, hooks désabonnés. Le filesystem reste intact. | Non |
failed | Erreur 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
| Champ | Type | Obligatoire | Description |
|---|---|---|---|
id | string (kebab-case, 2-64 chars, regex ^[a-z0-9][a-z0-9-]{1,63}$) | Oui | Identifiant unique. Sert de slug DB, de namespace de routes et de préfixe de capabilities. |
name | string | Oui | Nom affiché dans l'UI /extensions. |
version | string (semver x.y.z) | Oui | Version de l'extension. |
description | string | Non | Description courte affichée dans l'UI. |
author | string | Non | Auteur / éditeur. |
min_cms_version | string (semver) | Non | Version minimale du CMS requise. |
permissions | string[] | Non | Liste des permissions demandées (voir tableau dédié). |
hooks | string[] | Non | Liste indicative des hooks auxquels l'extension s'abonne (à des fins d'audit / UI). |
sidebar | object[] | Non | Entrées à injecter dans la sidebar admin : { label, icon, section, to }. |
pages | object[] | Non | Pages custom servies via iframe : { path, title } — exposées sous /api/ext/<id>/page côté backend. |
settings_schema | object | Non | Sché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
| Permission | Helpers exposés |
|---|---|
cms.pages.read | ext.cms.pages.list / get / count |
cms.pages.write | ext.cms.pages.create / update / delete |
cms.blog.read | ext.cms.blog.listArticles / getArticle / count / listCategories / listTags / getConfig |
cms.blog.write | ext.cms.blog.createArticle / updateArticle / deleteArticle / regenerateFeaturedImage / generateArticle / createCategory / updateCategory / deleteCategory / createTag / deleteTag / updateConfig |
cms.cocon.read | ext.cms.cocon.listGroups / getGroup / listPublished / getPage / countPages / listMedia |
cms.cocon.write | ext.cms.cocon.generate / updatePage / deletePage |
cms.media.read | ext.cms.media.list / count |
cms.media.write | ext.cms.media.upload / delete |
Services & métadonnées
| Permission | Helpers exposés |
|---|---|
cms.ai.use | ext.cms.ai.isConfigured / generate — utilise la clé Anthropic du CMS sans l'exposer. |
cms.images.use | ext.cms.images.isConfigured / search / searchProgressive — Unsplash / Pexels / Pixabay. |
cms.forms.read | ext.cms.forms.detect / listConfigs |
cms.forms.read_submissions | ext.cms.forms.countSubmissions / listSubmissions |
cms.logs.read | ext.cms.logs.list / count |
cms.users.read | ext.cms.users.count / listRoles — jamais d'emails ni de noms. |
cms.stats.read | ext.cms.stats.snapshot — agrégat dashboard. |
cms.tokens.read | ext.cms.tokens.count / listMeta — jamais le token_hash. |
cms.extensions.read | ext.cms.extensions.list / isActive |
Runtime de l'extension
| Permission | Helpers exposés |
|---|---|
storage.read | ext.storage.get(key), ext.storage.keys() |
storage.write | ext.storage.set(key, value), ext.storage.delete(key) — implique storage.read pour cohérence. |
settings.read | ext.settings.get(key), ext.settings.all() |
settings.write | ext.settings.set(key, value), ext.settings.delete(key) |
logs.write | ext.logger.info/warn/error écrit dans la table logs du CMS. Sans cette permission, le logger fallback sur console. |
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 / forme | Description |
|---|---|---|
ext.id | string | Identifiant de l'extension (depuis le manifest). |
ext.manifest | object | Copie en lecture du manifest validé. |
ext.router | Express Router | Router déjà monté sous /api/ext/<id>. Toujours exposé. |
ext.hooks | object | on(name, fn), off(name, fn), emit(name, payload), applyFilters(name, payload). Toujours exposé. |
ext.cms | object | API filtrée par permissions. Voir tableau permissions. |
ext.storage | object | KV isolé (table extension_data). Présent si storage.read ou storage.write. |
ext.settings | object | KV de configuration (table extension_settings). Présent si settings.read ou settings.write. |
ext.logger | object | info/warn/error. Persiste dans la table logs si logs.write, sinon fallback console. |
ext.capabilities | object | register({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
| Hook | Payload | Quand |
|---|---|---|
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)
| Filtre | Payload | Usage typique |
|---|---|---|
media.before_save | { buffer, originalName } | Convertir / optimiser une image avant écriture disque (ex. webp-auto-converter). |
render.html | HTML string | Modifier 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 (table
extension_data) — données runtime, statistiques internes, caches. À la désinstallation, supprimé en cascade. - Settings (table
extension_settings) — configuration utilisateur exposée dans l'UI. Auto-rendue si l'extension déclaremanifest.settings_schema.
// 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.
Sidebar dynamique
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"
}
- label — texte affiché dans la sidebar.
- icon — nom de l'icône (jeu d'icônes du CMS).
- section —
CONTENU,MARKETINGouCONFIGURATION. L'utilisateur peut surcharger via la modale Configurer (extension_settings.sidebar_section_override). - to — route React. Pour une page custom, utiliser
/ext/<id>qui charge une iframe pointant versGET /api/ext/<id>/page.
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 :
- Extraction du zip vers
extensions/installed/<id>/avec garde anti zip slip. - Si
package.jsonprésent : exécutionnpm install --omit=dev --no-audit --no-funddans le dossier de l'extension. Le binairenpmest résolu via le sibling deprocess.execPath(Plesk-safe). - Insertion dans la table
extensionsavec statutinstalled, version, copie du manifest. - Auto-activation : appel de
activateExtension(id)qui monte les routes, abonne les hooks et bascule enactive. - Log
type=extension action=installeddans la tablelogs.
/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éthode | Route | Effet |
|---|---|---|
| GET | /api/extensions | Liste toutes les extensions (statut, version, manifest). |
| GET | /api/extensions/menu | Entrées sidebar des extensions actives. |
| GET | /api/extensions/:id | Détail d'une extension. |
| GET | /api/extensions/:id/settings | Settings stockés pour l'extension. |
| PUT | /api/extensions/:id/activate | Recharge le module, monte routes + hooks → statut active. |
| PUT | /api/extensions/:id/deactivate | Démonte routes + désabonne hooks → statut inactive. |
| PUT | /api/extensions/:id/settings | Met à jour les settings (basé sur settings_schema). |
| DELETE | /api/extensions/:id | Dé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
- Place le dossier dans
extensions/installed/blog-auto-poster/. - Redémarre le CMS : le loader scanne
extensions/installed/*, valide le manifest et active l'extension. - Vérifie le statut dans
/extensions(UI) : ligneactive, aucun champerror. - Test endpoint :
curl http://localhost:3000/api/ext/blog-auto-poster/status(avec un cookie JWT valide). - 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).
.. 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
- hello-world — ping + compteur de builds. Squelette de démarrage.
- webp-auto-converter — s'abonne au filtre
media.before_saveet convertit les images en WebP viasharpavant écriture disque. - surefeedback-connector — s'abonne au filtre
render.htmlet injecte un snippet HTML (SureFeedback, BugHerd, Marker.io, etc.) juste avant</body>.
Bonnes pratiques
- Demander le strict minimum de permissions. L'utilisateur valide chaque permission à l'install — une extension trop gourmande est refusée.
- Toujours préfixer les clés storage par un namespace fonctionnel (ex.
cache.last_run,stats.daily_count) — facilite le débogage et l'export. - Capturer les exceptions dans les listeners de hooks : une erreur est loggée mais ne casse pas la chaîne. Évite de bloquer un build à cause d'une extension défaillante.
- Versionner correctement (
semver) : le CMS exposemin_cms_versionpour signaler une incompatibilité. - Tester la désinstallation : storage + settings sont purgés en cascade, mais si l'extension a créé des fichiers hors de son dossier, ils restent. À éviter — toujours écrire via
ext.storageouext.cms.media.upload.
Références
backend/src/services/extensions-service.js— loader, install/uninstall, validation manifest.backend/src/services/extension-context.js— construction du contexte filtré par permissions.backend/src/services/extension-cms-api.js— surfaceext.cms.*, sanitization des champs sensibles.backend/src/services/hooks-service.js— bus pub/sub + filtres.backend/src/routes/extensions.js— endpoints REST/api/extensions.extensions/installed/hello-world/— exemple de référence.- Base de données — schéma des tables
extensions,extension_settings,extension_data. - Authentification & RBAC — détails capabilities et matrice owner.