diff --git a/docs/DEV.md b/docs/DEV.md index 0e0a1f7..099820d 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -12,6 +12,8 @@ Pour la procédure de publication du package : [PUBLICATION.md](./dev/PUBLICATIO Pour les conventions de commit : [COMMITS.md](./dev/COMMITS.md). +Pour la création de modules externes `@zen/module-*` : [MODULES.md](./MODULES.md). + > **Contexte projet** : les utilisateurs finaux créent leur projet via `npx @zen/start`, qui génère automatiquement une structure Next.js avec la plateforme déjà intégré. Lors d'une assistance ou d'une revue, partez du principe que le projet cible est déjà structuré de cette façon. --- @@ -71,6 +73,8 @@ Ces suffixes ne sont pas cosmétiques : **le build les utilise comme source de v L'admin utilise un **registre runtime** pour permettre aux projets consommateurs d'ajouter des widgets, des entrées de navigation, et des pages sans modifier le core. +> Pour distribuer ces extensions sous forme de package npm réutilisable plutôt que de les écrire en local, voir [MODULES.md](./MODULES.md) — chaque module `@zen/module-*` installé est auto-découvert et activé. + ```js // app/zen.extensions.js — projet consommateur import { diff --git a/docs/MODULES.md b/docs/MODULES.md new file mode 100644 index 0000000..1fa73aa --- /dev/null +++ b/docs/MODULES.md @@ -0,0 +1,248 @@ +# Modules externes `@zen/module-*` + +Un **module** est un package npm distinct qui ajoute des fonctionnalités à un projet construit avec `@zen/core` — sans aucune modification de code dans le projet consommateur. + +```bash +npm install @zen/module-billing +# ajouter les variables d'env documentées dans le README du module +npx zen-db init # crée les tables du module et seed ses permissions +npm run dev # tout est câblé : pages admin, sidebar, widgets, API, /zen//... +``` + +Aucun fichier de configuration manuelle. La plateforme découvre les modules par scan des dépendances `package.json`. + +--- + +## Découverte + +Au boot et au lancement de `zen-db init`, le core scanne `dependencies` + `devDependencies` du `package.json` du projet consommateur et charge tout package matchant : + +- **Préfixe officiel** : `@zen/module-*` +- **Préfixe non-scopé** : `zen-module-*` +- **Tiers** : tout package dont le `package.json` contient `"keywords": ["zen-module"]` + +Pour chaque module trouvé, le core vérifie qu'il exporte les bons symboles, puis l'enregistre. + +--- + +## Forme d'un module + +Le point d'entrée du package (`main` ou `exports["."]`) doit exporter : + +```js +// @zen/module-blog/index.js + +export const manifest = { + name: '@zen/module-blog', + version: '1.0.0', + permissions: [ + { key: 'blog.view', name: 'Voir les billets', description: 'Consultation', group_name: 'Blog' }, + { key: 'blog.manage', name: 'Gérer les billets', description: 'CRUD', group_name: 'Blog' }, + ], + envVars: [ + { key: 'BLOG_UPLOAD_DIR', required: false, description: 'Répertoire des médias' }, + ], +}; + +export async function register() { + // Tous les enregistrements runtime se font ici (voir API ci-dessous). + await import('./register-server.js'); +} + +export { createTables, dropTables } from './db.js'; +``` + +| Export | Type | Obligatoire | +|--------|------|-------------| +| `manifest` | objet (voir ci-dessous) | oui | +| `register` | `() => void \| Promise` | oui | +| `createTables` | `async () => { created?: string[], skipped?: string[] }` | si le module a des tables | +| `dropTables` | `async () => void` | si le module a des tables | + +### Manifest + +| Champ | Type | Description | +|-------|------|-------------| +| `name` | `string` | Nom du package (utilisé comme identifiant unique). | +| `version` | `string` | Version du module (logguée au boot). | +| `permissions` | `Array` | Permissions ajoutées au catalogue. Auto-attribuées au rôle `admin` au prochain `zen-db init`. | +| `envVars` | `Array` | Variables d'env du module ; les `required: true` absentes émettent un warning au boot. | + +--- + +## API d'enregistrement + +Toutes ces fonctions s'utilisent depuis le hook `register()` du module. + +### Permissions + +Déclarées dans `manifest.permissions`. Le core les enregistre automatiquement avant le seed BD et les attribue au rôle `admin`. À la connexion, l'admin peut les distribuer à d'autres rôles via `/admin/roles`. + +### Sidebar admin + +```js +import { registerNavSection, registerNavItem } from '@zen/core/features/admin'; + +registerNavSection({ id: 'blog', title: 'Blog', icon: 'Notebook01Icon', order: 40 }); +registerNavItem({ + id: 'blog-posts', + label: 'Billets', + icon: 'Notebook01Icon', + href: '/admin/blog', + sectionId: 'blog', + permission: 'blog.view', +}); +``` + +### Pages admin + +```js +import { registerPage } from '@zen/core/features/admin'; +import BlogAdminPage from './admin/BlogAdminPage.client.js'; + +registerPage({ slug: 'blog', Component: BlogAdminPage, title: 'Blog' }); +``` + +Rendue sous `/admin/blog`. + +### Widgets dashboard + +```js +// côté serveur +import { registerWidgetFetcher } from '@zen/core/features/admin'; +registerWidgetFetcher('blog-posts', async () => ({ count: await countPosts() })); + +// côté client +'use client'; +import { registerWidget } from '@zen/core/features/admin'; +registerWidget({ id: 'blog-posts', Component: BlogWidget, order: 40, permission: 'blog.view' }); +``` + +### Routes API + +```js +import { registerApiRoutes } from '@zen/core/api'; +import { defineApiRoutes, apiSuccess } from '@zen/core/api'; + +const routes = defineApiRoutes([ + { path: '/blog/posts', method: 'GET', handler: handleListPosts, auth: 'admin', permission: 'blog.view' }, + { path: '/blog/posts', method: 'POST', handler: handleCreatePost, auth: 'admin', permission: 'blog.manage' }, + { path: '/blog/posts/:id', method: 'GET', handler: handleGetPost, auth: 'public' }, +]); + +registerApiRoutes(routes); +``` + +Le router applique automatiquement la session, le rate-limit et la vérification de permission. Les routes sont accessibles sous `/zen/api/*`. + +| Champ `auth` | Comportement | +|--------------|--------------| +| `'public'` | Aucune session requise. | +| `'user'` | Session valide. | +| `'admin'` | Session avec permission `admin.access`. La permission granulaire `permission` est aussi vérifiée si fournie. | + +### Pages publiques `/zen//...` + +```js +import { registerPublicModulePage } from '@zen/core/public-pages'; +import BlogPublicPage from './public/BlogPublicPage.js'; + +registerPublicModulePage({ moduleName: 'blog', Component: BlogPublicPage }); +``` + +URL : `/zen/blog/<...>`. Le composant reçoit `{ params, segments }` : + +```js +function BlogPublicPage({ params, segments }) { + // /zen/blog/post/abc-123 → segments = ['post', 'abc-123'] + if (segments[0] === 'post') return ; + return ; +} +``` + +Le namespace `api` est réservé aux routes API et ne peut être utilisé comme `moduleName`. + +### Migrations BD + +```js +// db.js +import { query, tableExists } from '@zen/core/database'; + +const TABLES = [ + { name: 'zen_blog_posts', sql: `CREATE TABLE zen_blog_posts (...)` }, +]; + +export async function createTables() { + const created = []; + const skipped = []; + for (const t of TABLES) { + if (await tableExists(t.name)) { skipped.push(t.name); continue; } + await query(t.sql); + created.push(t.name); + } + return { created, skipped }; +} + +export async function dropTables() { + for (const t of [...TABLES].reverse()) { + await query(`DROP TABLE IF EXISTS "${t.name}" CASCADE`); + } +} +``` + +Convention : préfixer toutes les tables par `zen__` pour éviter les collisions. + +--- + +## Frontières serveur/client + +Comme pour le core (voir [DEV.md](DEV.md)), les fichiers du module portent les suffixes `.server.js` / `.client.js`. Le hook `register()` côté serveur est appelé par le core ; les enregistrements client (widgets, par ex.) doivent être triggés par un import dans le bundle client — typiquement via le composant client lui-même qui appelle `registerWidget()` à l'import. + +```js +// Pattern recommandé pour un module avec partie client : + +// src/register-server.js (importé par register()) +import './admin/BlogAdminPage.client.js'; // chaîne d'imports vers client +import './widgets/BlogWidget.server.js'; +// ... registerNavItem, registerPage, registerApiRoutes, etc. +``` + +Le bundle Next.js du projet consommateur traverse le graphe d'import et inclut les composants client dans le bundle client. Côté serveur, seules les fonctions et fetchers serveur sont chargés. + +--- + +## Variables d'environnement + +Toute variable requise par le module doit être déclarée dans `manifest.envVars` et documentée dans le `README.md` du module. Les variables `required: true` absentes génèrent un warning au boot — elles ne crashent pas le serveur, le module gère son propre fallback. + +--- + +## Squelette minimal d'un module + +``` +@zen/module-blog/ +├── package.json # name: "@zen/module-blog", main: "./index.js" +├── README.md # documente les env vars et la configuration +├── index.js # exporte manifest, register, createTables, dropTables +├── db.js # createTables/dropTables +├── register-server.js # imports déclencheurs (chargé par register()) +├── api.js # routes API (registerApiRoutes) +├── admin/ +│ ├── BlogAdminPage.client.js # registerPage + composant +│ └── widgets/... # registerWidgetFetcher + registerWidget +└── public/ + └── BlogPublicPage.js # registerPublicModulePage +``` + +--- + +## Cycle de vie complet + +| Étape | Côté core | Côté module | +|-------|-----------|-------------| +| Install | — | `npm install @zen/module-X` | +| Configuration | — | Ajout des `envVars` au `.env` | +| Migration BD | `zen-db init` scanne, charge le module, registerPermissions(), seed, createTables() | `createTables()` exécuté | +| Boot serveur | `instrumentation.js` → `initializeZen()` scanne, charge, registerPermissions(), `register()` | `register()` exécuté côté serveur | +| Premier render client | bundle client traverse les imports → composants client enregistrés | `registerWidget()` exécuté côté client | +| Runtime | router dispatch les requêtes, admin résout les pages/widgets via le registre | aucun travail supplémentaire | diff --git a/package.json b/package.json index 5457b21..ba57adc 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,15 @@ "./users/constants": { "import": "./dist/core/users/constants.js" }, + "./modules": { + "import": "./dist/core/modules/index.js" + }, + "./public-pages": { + "import": "./dist/core/public-pages/index.js" + }, + "./public-pages/server": { + "import": "./dist/core/public-pages/PublicModulePage.server.js" + }, "./api": { "import": "./dist/core/api/index.js" }, diff --git a/src/core/api/index.js b/src/core/api/index.js index d5c20df..b0837b7 100644 --- a/src/core/api/index.js +++ b/src/core/api/index.js @@ -9,7 +9,7 @@ export { routeRequest, requireAuth, requireAdmin } from './router.js'; // Runtime state — session resolver + feature routes registry -export { configureRouter, getSessionResolver, clearRouterConfig, registerFeatureRoutes, getFeatureRoutes, clearFeatureRoutes } from './runtime.js'; +export { configureRouter, getSessionResolver, clearRouterConfig, registerApiRoutes, registerFeatureRoutes, getFeatureRoutes, clearFeatureRoutes } from './runtime.js'; // Response utilities — use in all handlers (core and modules) export { apiSuccess, apiError, getStatusCode } from './respond.js'; diff --git a/src/core/api/runtime.js b/src/core/api/runtime.js index e6d647d..be995b0 100644 --- a/src/core/api/runtime.js +++ b/src/core/api/runtime.js @@ -68,18 +68,25 @@ if (!globalThis[REGISTRY_KEY]) globalThis[REGISTRY_KEY] = []; const _featureRoutes = globalThis[REGISTRY_KEY]; /** - * Enregistre les routes d'une feature core. - * Appelé une fois par feature pendant initializeZen(). + * Enregistre des routes API. + * Appelé une fois par feature core ou module externe pendant initializeZen() + * ou depuis le hook register() d'un module. * * @param {ReadonlyArray} routes - Définitions produites par defineApiRoutes() */ -export function registerFeatureRoutes(routes) { +export function registerApiRoutes(routes) { if (!Array.isArray(routes)) { - throw new TypeError('registerFeatureRoutes: routes must be an array'); + throw new TypeError('registerApiRoutes: routes must be an array'); } _featureRoutes.push(...routes); } +/** + * Alias rétro-compatible de registerApiRoutes. + * @deprecated Utiliser registerApiRoutes. + */ +export const registerFeatureRoutes = registerApiRoutes; + /** * Retourne toutes les routes de features enregistrées. * Appelé à chaque requête par le router pour construire la liste complète. diff --git a/src/core/modules/README.md b/src/core/modules/README.md new file mode 100644 index 0000000..808d9d0 --- /dev/null +++ b/src/core/modules/README.md @@ -0,0 +1,23 @@ +# Modules + +Registre des modules `@zen/module-*` chargés dans le projet consommateur. Voir [docs/MODULES.md](../../../docs/MODULES.md) pour le guide complet de création d'un module. + +## API + +```js +import { registerModule, getRegisteredModules } from '@zen/core/modules'; + +// Le core utilise discoverModules() pour peupler ce registre automatiquement. +// La plupart des consommateurs n'appellent jamais registerModule() directement. +``` + +## Forme attendue d'un module + +Le point d'entrée d'un package `@zen/module-X` doit exporter : + +| Export | Type | Obligatoire | +|--------|------|-------------| +| `manifest` | `{ name, version, permissions?, envVars? }` | oui | +| `register` | `() => void \| Promise` | oui | +| `createTables` | `async () => { created?, skipped? }` | si le module a des tables | +| `dropTables` | `async () => void` | si le module a des tables | diff --git a/src/core/modules/discover.server.js b/src/core/modules/discover.server.js new file mode 100644 index 0000000..ea11b84 --- /dev/null +++ b/src/core/modules/discover.server.js @@ -0,0 +1,129 @@ +import { readFile } from 'node:fs/promises'; +import { resolve, join } from 'node:path'; +import { createRequire } from 'node:module'; +import { info, warn, fail } from '@zen/core/shared/logger'; +import { registerModule, getRegisteredModule } from './registry.js'; + +/** + * Découverte automatique des modules `@zen/module-*` installés dans le projet consommateur. + * + * Stratégie : + * 1. Lire `package.json` du process.cwd() (le projet consommateur, pas @zen/core). + * 2. Pour chaque dépendance dont le nom matche `^@zen/module-` ou `^zen-module-`, + * résoudre son point d'entrée et l'importer. + * 3. Pour les noms qui ne matchent pas le préfixe, fallback : lire + * `keywords` du package.json du package — si "zen-module" est présent, charger. + * 4. Valider la forme du module (manifest, register, createTables/dropTables) et + * l'enregistrer via registerModule(). + * + * Cette fonction ne lance PAS les hooks register() — elle se contente de découvrir + * et d'enregistrer les modules dans le registre. Le boot (initializeZen) et le CLI + * (zen-db) consomment ensuite getRegisteredModules() selon leurs besoins. + * + * Idempotente : appeler plusieurs fois ne charge chaque module qu'une seule fois. + */ +const NAME_PREFIX = /^(@zen\/module-|zen-module-)/; + +function isCandidate(name) { + return NAME_PREFIX.test(name); +} + +async function readJson(path) { + try { + return JSON.parse(await readFile(path, 'utf-8')); + } catch { + return null; + } +} + +async function isThirdPartyModule(name, projectCwd) { + // Fallback pour les modules tiers : on regarde le keywords du package. + const require = createRequire(join(projectCwd, 'package.json')); + let pkgJsonPath; + try { + pkgJsonPath = require.resolve(`${name}/package.json`); + } catch { + return false; + } + const pkg = await readJson(pkgJsonPath); + return Array.isArray(pkg?.keywords) && pkg.keywords.includes('zen-module'); +} + +async function loadModule(name) { + if (getRegisteredModule(name)) return; // déjà chargé + + let mod; + try { + // Node résout via node_modules à partir du module appelant ; en pratique + // depuis dist/core/modules/ dans @zen/core (lui-même installé chez le + // consommateur), Node remonte jusqu'aux node_modules du consommateur. + mod = await import(name); + } catch (error) { + fail(`zen-modules: failed to import "${name}" — ${error.message}`); + return; + } + + if (!mod.manifest || typeof mod.register !== 'function') { + warn(`zen-modules: "${name}" missing required exports (manifest, register) — skipping`); + return; + } + + registerModule({ + manifest: mod.manifest, + register: mod.register, + createTables: mod.createTables, + dropTables: mod.dropTables, + }); + info(`zen-modules: discovered ${mod.manifest.name}@${mod.manifest.version ?? '?'}`); +} + +/** + * Découvre et enregistre tous les modules installés dans le projet consommateur. + * + * @param {object} [options] + * @param {string} [options.cwd] - Répertoire racine du projet consommateur. + * @returns {Promise<{ loaded: string[] }>} + */ +export async function discoverModules({ cwd = process.cwd() } = {}) { + const pkgPath = resolve(cwd, 'package.json'); + const pkg = await readJson(pkgPath); + if (!pkg) { + warn(`zen-modules: no package.json at ${pkgPath} — skipping discovery`); + return { loaded: [] }; + } + + const allDeps = { + ...(pkg.dependencies ?? {}), + ...(pkg.devDependencies ?? {}), + }; + + const candidates = []; + for (const name of Object.keys(allDeps)) { + if (isCandidate(name)) { + candidates.push(name); + } else if (await isThirdPartyModule(name, cwd)) { + candidates.push(name); + } + } + + for (const name of candidates) { + await loadModule(name); + } + + return { loaded: candidates }; +} + +/** + * Valide les variables d'environnement requises par chaque module. + * Ne lance pas — log un warning pour chaque variable absente. + */ +export function validateModuleEnvVars(modules) { + for (const mod of modules) { + const envVars = mod.manifest?.envVars ?? []; + for (const v of envVars) { + if (v.required && !process.env[v.key]) { + warn(`zen-modules: ${mod.manifest.name} requires env var "${v.key}" — ${v.description ?? ''}`); + } + } + } +} diff --git a/src/core/modules/index.js b/src/core/modules/index.js new file mode 100644 index 0000000..1db46b9 --- /dev/null +++ b/src/core/modules/index.js @@ -0,0 +1 @@ +export { registerModule, getRegisteredModules, getRegisteredModule, clearRegisteredModules } from './registry.js'; diff --git a/src/core/modules/registry.js b/src/core/modules/registry.js new file mode 100644 index 0000000..5f8a294 --- /dev/null +++ b/src/core/modules/registry.js @@ -0,0 +1,45 @@ +/** + * Registre des modules `@zen/module-*` chargés. + * + * Un module est un package npm exportant : + * - manifest : { name, version, permissions?, envVars? } + * - register : () => void | Promise + * - createTables : async () => { created?: string[], skipped?: string[] } + * - dropTables : async () => void + * + * La découverte (`discover.server.js`) lit le package.json du projet + * consommateur et appelle registerModule() pour chaque dépendance détectée. + * + * Persisté via Symbol.for sur globalThis pour survivre aux hot-reloads. + */ + +const REGISTRY_KEY = Symbol.for('__ZEN_MODULES_REGISTRY__'); +if (!globalThis[REGISTRY_KEY]) globalThis[REGISTRY_KEY] = new Map(); +/** @type {Map} */ +const registry = globalThis[REGISTRY_KEY]; + +export function registerModule(mod) { + if (!mod || typeof mod !== 'object') { + throw new TypeError('registerModule: argument must be an object'); + } + const { manifest } = mod; + if (!manifest || typeof manifest.name !== 'string' || !manifest.name) { + throw new TypeError('registerModule: module.manifest.name must be a non-empty string'); + } + if (typeof mod.register !== 'function') { + throw new TypeError(`registerModule(${manifest.name}): module.register must be a function`); + } + registry.set(manifest.name, mod); +} + +export function getRegisteredModules() { + return [...registry.values()]; +} + +export function getRegisteredModule(name) { + return registry.get(name); +} + +export function clearRegisteredModules() { + registry.clear(); +} diff --git a/src/core/public-pages/PublicModulePage.server.js b/src/core/public-pages/PublicModulePage.server.js new file mode 100644 index 0000000..9cc32dd --- /dev/null +++ b/src/core/public-pages/PublicModulePage.server.js @@ -0,0 +1,28 @@ +import { notFound } from 'next/navigation'; +import { getPublicModulePage } from './registry.js'; + +/** + * Composant serveur RSC catch-all pour `/zen//<...>`. + * + * Next.js route ce composant via un segment `[...path]`. Le premier segment + * identifie le module ; le reste est passé au composant enregistré qui fait + * son propre routage interne. + * + * `/zen/api/...` est intercepté en amont par la route API (`route.js`) qui + * est plus spécifique pour Next.js — ce composant ne le verra jamais en + * pratique, mais on garde le filtre par sûreté. + */ +export default async function PublicModulePage({ params }) { + const resolved = await params; + const path = Array.isArray(resolved?.path) ? resolved.path : []; + + if (path.length === 0) notFound(); + const [moduleName, ...rest] = path; + if (moduleName === 'api') notFound(); + + const entry = getPublicModulePage(moduleName); + if (!entry) notFound(); + + const { Component } = entry; + return ; +} diff --git a/src/core/public-pages/README.md b/src/core/public-pages/README.md new file mode 100644 index 0000000..eb08622 --- /dev/null +++ b/src/core/public-pages/README.md @@ -0,0 +1,37 @@ +# Public Module Pages + +Registre runtime pour les pages publiques `/zen//<...>` ajoutées par les modules externes. + +## Concept + +Tout chemin `/zen//...` (sauf `/zen/api/...` réservé aux routes API) est résolu vers le composant enregistré sous ``. Le module gère son routage interne. + +## API + +```js +import { registerPublicModulePage } from '@zen/core/public-pages'; + +registerPublicModulePage({ + moduleName: 'billing', + Component: BillingRouter, + title: 'Facturation', +}); +``` + +Le composant reçoit `{ params, segments }` : + +| Prop | Type | Description | +|------|------|-------------| +| `params` | `object` | Paramètres Next.js résolus (incluant `path`). | +| `segments` | `string[]` | Segments d'URL après `/zen//`. Le module fait son propre routage. | + +Exemple : `/zen/billing/invoice/abc-123` → `segments = ['invoice', 'abc-123']`. + +## Câblage côté projet consommateur + +Le scaffolder `@zen/start` génère automatiquement `app/zen/[...path]/page.js` qui ré-exporte le composant serveur. Aucune action manuelle requise. + +## Restrictions + +- Le moduleName `api` est réservé et lève une exception à l'enregistrement. +- Un seul composant par moduleName ; un appel ultérieur écrase le précédent. diff --git a/src/core/public-pages/index.js b/src/core/public-pages/index.js new file mode 100644 index 0000000..2dabb83 --- /dev/null +++ b/src/core/public-pages/index.js @@ -0,0 +1 @@ +export { registerPublicModulePage, getPublicModulePage, getPublicModulePages } from './registry.js'; diff --git a/src/core/public-pages/registry.js b/src/core/public-pages/registry.js new file mode 100644 index 0000000..2cbb6f9 --- /dev/null +++ b/src/core/public-pages/registry.js @@ -0,0 +1,36 @@ +/** + * Registre runtime des pages publiques `/zen//<...>`. + * + * Chaque module externe enregistre un composant racine pour son namespace. + * Le composant reçoit `{ params, segments }` où `segments` est le tableau + * de chemins après `/zen//` ; le module fait son propre routage interne. + * + * Le préfixe `api` est réservé : tout enregistrement sous moduleName === 'api' + * est rejeté pour éviter les collisions avec les routes API. + */ + +const REGISTRY_KEY = Symbol.for('__ZEN_PUBLIC_MODULE_PAGES__'); +if (!globalThis[REGISTRY_KEY]) globalThis[REGISTRY_KEY] = new Map(); +/** @type {Map} */ +const registry = globalThis[REGISTRY_KEY]; + +export function registerPublicModulePage({ moduleName, Component, title }) { + if (typeof moduleName !== 'string' || !moduleName) { + throw new TypeError('registerPublicModulePage: "moduleName" must be a non-empty string'); + } + if (moduleName === 'api') { + throw new Error('registerPublicModulePage: "api" is a reserved namespace under /zen/'); + } + if (typeof Component !== 'function' && typeof Component !== 'object') { + throw new TypeError(`registerPublicModulePage(${moduleName}): "Component" must be a React component`); + } + registry.set(moduleName, { moduleName, Component, title }); +} + +export function getPublicModulePage(moduleName) { + return registry.get(moduleName); +} + +export function getPublicModulePages() { + return [...registry.values()]; +} diff --git a/src/core/users/db.js b/src/core/users/db.js index 4b05f33..b15fa6d 100644 --- a/src/core/users/db.js +++ b/src/core/users/db.js @@ -2,6 +2,7 @@ import { query, tableExists } from '@zen/core/database'; import { generateId } from './password.js'; import { done, warn } from '@zen/core/shared/logger'; import { PERMISSION_DEFINITIONS } from './constants.js'; +import { registerPermissions, getRegisteredPermissions } from './permissions-registry.js'; const USER_ROLE_PERMISSIONS = []; @@ -81,11 +82,17 @@ async function migratePermissions() { } async function seedDefaultRolesAndPermissions() { - // Permissions - for (const perm of PERMISSION_DEFINITIONS) { + // S'assure que les permissions core sont dans le registre, puis seed depuis + // le registre — qui contient core + permissions enregistrées par les modules. + registerPermissions(PERMISSION_DEFINITIONS); + const allPermissions = getRegisteredPermissions(); + + for (const perm of allPermissions) { await query( - `INSERT INTO zen_auth_permissions (key, name, group_name) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, - [perm.key, perm.name, perm.group_name] + `INSERT INTO zen_auth_permissions (key, name, description, group_name) + VALUES ($1, $2, $3, $4) + ON CONFLICT (key) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, group_name = EXCLUDED.group_name`, + [perm.key, perm.name, perm.description, perm.group_name] ); } @@ -100,12 +107,14 @@ async function seedDefaultRolesAndPermissions() { const adminRole = await query(`SELECT id FROM zen_auth_roles WHERE name = 'admin'`); const adminId = adminRole.rows[0].id; - for (const perm of PERMISSION_DEFINITIONS) { - await query( - `INSERT INTO zen_auth_role_permissions (role_id, permission_key) VALUES ($1, $2) ON CONFLICT DO NOTHING`, - [adminId, perm.key] - ); - } + // Toute permission présente dans le catalogue est attribuée au rôle admin — + // y compris les permissions ajoutées par les modules après le premier init. + await query( + `INSERT INTO zen_auth_role_permissions (role_id, permission_key) + SELECT $1, key FROM zen_auth_permissions + ON CONFLICT DO NOTHING`, + [adminId] + ); // User role const userRoleId = generateId(); diff --git a/src/core/users/index.js b/src/core/users/index.js index 52f6f99..13a7732 100644 --- a/src/core/users/index.js +++ b/src/core/users/index.js @@ -4,4 +4,14 @@ export { createSession, validateSession, deleteSession, deleteUserSessions, refr export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken } from './verifications.js'; export { hashPassword, verifyPassword, generateToken, generateId } from './password.js'; export { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole } from './roles.js'; -export { PERMISSIONS, PERMISSION_DEFINITIONS, getPermissionGroups, hasPermission, getUserPermissions } from './permissions.js'; +export { + PERMISSIONS, + PERMISSION_DEFINITIONS, + getPermissionGroups, + hasPermission, + getUserPermissions, + registerPermission, + registerPermissions, + getRegisteredPermissions, + getRegisteredPermissionKeys, +} from './permissions.js'; diff --git a/src/core/users/permissions-registry.js b/src/core/users/permissions-registry.js new file mode 100644 index 0000000..097e9b9 --- /dev/null +++ b/src/core/users/permissions-registry.js @@ -0,0 +1,47 @@ +/** + * Registre runtime des permissions. + * + * Le core enregistre ses permissions au boot (initializeZen) ; chaque module + * externe enregistre les siennes via son hook register(). Le registre alimente + * à la fois le seed BD (zen-db init) et la validation runtime (updateRole). + * + * Le registre est un singleton process-local persisté via Symbol.for sur + * globalThis pour survivre aux hot-reloads Next.js. + */ + +const REGISTRY_KEY = Symbol.for('__ZEN_PERMISSIONS_REGISTRY__'); +if (!globalThis[REGISTRY_KEY]) globalThis[REGISTRY_KEY] = new Map(); +/** @type {Map} */ +const registry = globalThis[REGISTRY_KEY]; + +export function registerPermission({ key, name, description, group_name }) { + if (typeof key !== 'string' || !key) { + throw new TypeError('registerPermission: "key" must be a non-empty string'); + } + if (typeof name !== 'string' || !name) { + throw new TypeError(`registerPermission(${key}): "name" must be a non-empty string`); + } + if (typeof group_name !== 'string' || !group_name) { + throw new TypeError(`registerPermission(${key}): "group_name" must be a non-empty string`); + } + registry.set(key, { key, name, description: description ?? null, group_name }); +} + +export function registerPermissions(list) { + if (!Array.isArray(list)) { + throw new TypeError('registerPermissions: argument must be an array'); + } + for (const perm of list) registerPermission(perm); +} + +export function getRegisteredPermissions() { + return [...registry.values()]; +} + +export function getRegisteredPermissionKeys() { + return new Set(registry.keys()); +} + +export function clearRegisteredPermissions() { + registry.clear(); +} diff --git a/src/core/users/permissions.js b/src/core/users/permissions.js index ca03128..b00cbe6 100644 --- a/src/core/users/permissions.js +++ b/src/core/users/permissions.js @@ -1,5 +1,11 @@ import { query } from '@zen/core/database'; export { PERMISSIONS, PERMISSION_DEFINITIONS, getPermissionGroups } from './constants.js'; +export { + registerPermission, + registerPermissions, + getRegisteredPermissions, + getRegisteredPermissionKeys, +} from './permissions-registry.js'; export async function hasPermission(userId, permissionKey) { const result = await query( diff --git a/src/core/users/roles.js b/src/core/users/roles.js index 1b712c4..c820615 100644 --- a/src/core/users/roles.js +++ b/src/core/users/roles.js @@ -1,8 +1,6 @@ import { query, transaction } from '@zen/core/database'; import { generateId } from './password.js'; -import { PERMISSIONS } from './permissions.js'; - -const VALID_PERMISSION_KEYS = new Set(Object.values(PERMISSIONS)); +import { getRegisteredPermissionKeys } from './permissions-registry.js'; export async function listRoles() { const result = await query( @@ -83,7 +81,8 @@ export async function updateRole(roleId, { name, description, color, permissionK ); if (!isSystem && permissionKeys !== undefined) { - const safeKeys = [...new Set(permissionKeys)].filter(k => VALID_PERMISSION_KEYS.has(k)); + const validKeys = getRegisteredPermissionKeys(); + const safeKeys = [...new Set(permissionKeys)].filter(k => validKeys.has(k)); await client.query(`DELETE FROM zen_auth_role_permissions WHERE role_id = $1`, [roleId]); for (const key of safeKeys) { await client.query( diff --git a/src/features/admin/README.md b/src/features/admin/README.md index 2b210a8..6b17066 100644 --- a/src/features/admin/README.md +++ b/src/features/admin/README.md @@ -2,6 +2,8 @@ Ce répertoire fournit l'interface d'administration complète : layout, navigation, tableau de bord, gestion des utilisateurs et des rôles. Il expose un **registre runtime** pour permettre aux projets consommateurs d'ajouter des widgets, des entrées de navigation et des pages sans modifier le core. +> Le pattern `zen.extensions.js` documenté ici reste valide pour les extensions in-projet (extensions ad hoc spécifiques à une app). Pour distribuer une extension réutilisable comme un package npm, consulter [docs/MODULES.md](../../../docs/MODULES.md) — la même API d'enregistrement s'utilise mais le module est auto-découvert via les `dependencies` du projet consommateur. + --- ## Structure diff --git a/src/features/admin/components/RoleEditModal.client.js b/src/features/admin/components/RoleEditModal.client.js index e4dfe2d..a6ba545 100644 --- a/src/features/admin/components/RoleEditModal.client.js +++ b/src/features/admin/components/RoleEditModal.client.js @@ -3,9 +3,6 @@ import { useState, useEffect } from 'react'; import { Input, Textarea, Switch, Modal, ColorPicker } from '@zen/core/shared/components'; import { useToast } from '@zen/core/toast'; -import { getPermissionGroups } from '@zen/core/users/constants'; - -const PERMISSION_GROUPS = getPermissionGroups(); const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => { const toast = useToast(); @@ -19,9 +16,12 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => { const [description, setDescription] = useState(''); const [color, setColor] = useState('#6b7280'); const [selectedPerms, setSelectedPerms] = useState([]); + // Catalogue dynamique des permissions (core + modules), récupéré via l'API. + const [permissionGroups, setPermissionGroups] = useState({}); useEffect(() => { if (!isOpen) return; + fetchPermissions(); if (isNew) { setName(''); setDescription(''); @@ -33,6 +33,18 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => { fetchRole(); }, [isOpen, roleId]); + const fetchPermissions = async () => { + try { + const response = await fetch('/zen/api/permissions', { credentials: 'include' }); + if (!response.ok) return; + const data = await response.json(); + setPermissionGroups(data.groups || {}); + } catch { + // Si le catalogue n'est pas joignable, on laisse l'utilisateur sauvegarder + // ses changements ; les permissions invalides sont filtrées côté serveur. + } + }; + const fetchRole = async () => { try { setLoading(true); @@ -146,7 +158,7 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {

Permissions

- {Object.entries(PERMISSION_GROUPS).map(([group, perms]) => ( + {Object.entries(permissionGroups).map(([group, perms]) => (

diff --git a/src/features/auth/api.js b/src/features/auth/api.js index 799036b..b698268 100644 --- a/src/features/auth/api.js +++ b/src/features/auth/api.js @@ -12,7 +12,7 @@ import { updateUser, requestPasswordReset } from './auth.js'; import { hashPassword, verifyPassword, generateId } from '../../core/users/password.js'; import { createEmailChangeToken, verifyEmailChangeToken, applyEmailChange, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail, sendPasswordChangedEmail, sendPasswordResetEmail, sendInvitationEmail } from './email.js'; import { createAccountSetup } from '../../core/users/verifications.js'; -import { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole, deleteUserSessions, PERMISSIONS } from '@zen/core/users'; +import { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole, deleteUserSessions, PERMISSIONS, getRegisteredPermissions } from '@zen/core/users'; import { uploadImage, deleteFile, generateUniqueFilename, getFileExtension, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, validateUpload } from '@zen/core/storage'; import { getPublicBaseUrl } from '@zen/core/shared/config'; @@ -563,6 +563,21 @@ async function handleListRoles() { return apiSuccess({ roles }); } +// --------------------------------------------------------------------------- +// GET /zen/api/permissions (admin only) +// Catalogue dynamique : core + permissions enregistrées par les modules. +// --------------------------------------------------------------------------- + +async function handleListPermissions() { + const permissions = getRegisteredPermissions(); + const groups = permissions.reduce((acc, perm) => { + if (!acc[perm.group_name]) acc[perm.group_name] = []; + acc[perm.group_name].push(perm); + return acc; + }, {}); + return apiSuccess({ permissions, groups }); +} + // --------------------------------------------------------------------------- // POST /zen/api/roles (admin only) // --------------------------------------------------------------------------- @@ -934,6 +949,7 @@ export const routes = defineApiRoutes([ { path: '/users/:id/password', method: 'PUT', handler: handleAdminSetUserPassword, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE }, { path: '/users/:id/send-password-reset', method: 'POST', handler: handleAdminSendPasswordReset, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE }, { path: '/roles', method: 'GET', handler: handleListRoles, auth: 'admin', permission: PERMISSIONS.ROLES_VIEW }, + { path: '/permissions', method: 'GET', handler: handleListPermissions, auth: 'admin', permission: PERMISSIONS.ROLES_VIEW }, { path: '/roles', method: 'POST', handler: handleCreateRole, auth: 'admin', permission: PERMISSIONS.ROLES_MANAGE }, { path: '/roles/:id', method: 'GET', handler: handleGetRole, auth: 'admin', permission: PERMISSIONS.ROLES_VIEW }, { path: '/roles/:id', method: 'PUT', handler: handleUpdateRole, auth: 'admin', permission: PERMISSIONS.ROLES_MANAGE }, diff --git a/src/features/init.js b/src/features/init.js index 6e6905e..d7c411b 100644 --- a/src/features/init.js +++ b/src/features/init.js @@ -1,25 +1,66 @@ /** - * Core Feature Database Initialization (CLI) + * Database initialization for features and modules. * - * Initialise et supprime les tables des features core. La liste est aujourd'hui - * limitée à auth — le seul feature avec un schéma. Ajouter ici si un autre - * feature gagne un db.js avec createTables()/dropTables(). + * - Features core : auth (et tout futur core ayant un db.js). + * - Modules externes : découverts via discoverModules() ; chaque module + * exporte ses propres createTables/dropTables. + * + * Les permissions ajoutées par les modules doivent être enregistrées AVANT + * le seed de la BD pour qu'elles soient persistées et auto-attribuées au + * rôle admin. C'est pour cela qu'on appelle register() de chaque module + * avant initFeatures(). */ import { createTables as authCreate, dropTables as authDrop } from './auth/db.js'; import { done, fail, info, step } from '@zen/core/shared/logger'; +import { discoverModules, validateModuleEnvVars } from '../core/modules/discover.server.js'; +import { getRegisteredModules } from '../core/modules/registry.js'; +import { registerPermissions } from '../core/users/permissions-registry.js'; -const FEATURES = [ +const CORE_FEATURES = [ { name: 'auth', createTables: authCreate, dropTables: authDrop }, ]; +async function loadModules() { + await discoverModules(); + const modules = getRegisteredModules(); + validateModuleEnvVars(modules); + + // Enregistre les permissions du module et exécute son register() pour que + // tous les hooks runtime soient en place avant le seed. + for (const mod of modules) { + if (Array.isArray(mod.manifest?.permissions)) { + registerPermissions(mod.manifest.permissions); + } + if (typeof mod.register === 'function') { + try { + await mod.register(); + } catch (error) { + fail(`zen-modules: ${mod.manifest.name} register() threw — ${error.message}`); + } + } + } + return modules; +} + export async function initFeatures() { const created = []; const skipped = []; step('Initializing feature databases...'); - for (const { name, createTables } of FEATURES) { + // Charger les modules d'abord pour que leurs permissions soient connues + // au moment du seed (et donc auto-attribuées au rôle admin). + const modules = await loadModules(); + + const targets = [ + ...CORE_FEATURES, + ...modules + .filter(m => typeof m.createTables === 'function') + .map(m => ({ name: m.manifest.name, createTables: m.createTables, dropTables: m.dropTables })), + ]; + + for (const { name, createTables } of targets) { try { step(`Initializing ${name}...`); if (typeof createTables !== 'function') { @@ -40,7 +81,18 @@ export async function initFeatures() { } export async function dropFeatures() { - for (const { name, dropTables } of [...FEATURES].reverse()) { + const modules = await loadModules(); + + // Ordre de création : core, puis modules. Drop = ordre inverse pour que + // les tables modules (qui peuvent avoir des FK vers core) tombent d'abord. + const targets = [ + ...CORE_FEATURES, + ...modules + .filter(m => typeof m.dropTables === 'function') + .map(m => ({ name: m.manifest.name, dropTables: m.dropTables })), + ]; + + for (const { name, dropTables } of [...targets].reverse()) { try { if (typeof dropTables !== 'function') { info(`${name} has no dropTables function`); diff --git a/src/shared/lib/init.js b/src/shared/lib/init.js index afe6d5c..643044b 100644 --- a/src/shared/lib/init.js +++ b/src/shared/lib/init.js @@ -19,7 +19,11 @@ import { registerStoragePolicies, clearStorageConfig } from '@zen/core/storage'; import { validateSession } from '../../features/auth/session.js'; import { routes as authRoutes } from '../../features/auth/api.js'; import { storageAccessPolicies } from '../../features/auth/storage-policies.js'; -import { done, warn } from './logger.js'; +import { PERMISSION_DEFINITIONS } from '../../core/users/constants.js'; +import { registerPermissions, clearRegisteredPermissions } from '../../core/users/permissions-registry.js'; +import { discoverModules, validateModuleEnvVars } from '../../core/modules/discover.server.js'; +import { getRegisteredModules, clearRegisteredModules } from '../../core/modules/registry.js'; +import { done, warn, fail } from './logger.js'; const ZEN_INIT_KEY = Symbol.for('__ZEN_INITIALIZED__'); @@ -38,6 +42,22 @@ export async function initializeZen() { configureRouter({ resolveSession: validateSession }); registerFeatureRoutes(authRoutes); registerStoragePolicies(storageAccessPolicies); + registerPermissions(PERMISSION_DEFINITIONS); + + // Découverte et activation des modules @zen/module-* + await discoverModules(); + const modules = getRegisteredModules(); + validateModuleEnvVars(modules); + for (const mod of modules) { + if (Array.isArray(mod.manifest?.permissions)) { + registerPermissions(mod.manifest.permissions); + } + try { + await mod.register(); + } catch (error) { + fail(`zen-modules: ${mod.manifest.name} register() threw — ${error.message}`); + } + } done('ZEN: ready'); @@ -49,5 +69,7 @@ export function resetZenInitialization() { clearRouterConfig(); clearFeatureRoutes(); clearStorageConfig(); + clearRegisteredPermissions(); + clearRegisteredModules(); warn('ZEN: initialization reset'); }