feat(modules): add external module system with auto-discovery and public pages support
- add `src/core/modules/` with registry, discovery (server), and public index - add `src/core/public-pages/` with registry, server component, and public index - add `src/core/users/permissions-registry.js` for runtime permission registration - expose `./modules`, `./public-pages`, and `./public-pages/server` package exports - rename `registerFeatureRoutes` to `registerApiRoutes` with backward-compatible alias - extend `seedDefaultRolesAndPermissions` to include module-registered permissions - update `initializeZen` and shared init to wire module discovery and registration - add `docs/MODULES.md` documenting the `@zen/module-*` authoring contract - update `docs/DEV.md` with references to module system docs
This commit is contained in:
+248
@@ -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/<module>/...
|
||||
```
|
||||
|
||||
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<void>` | 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/<module>/...`
|
||||
|
||||
```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 <PostView id={segments[1]} />;
|
||||
return <BlogIndex />;
|
||||
}
|
||||
```
|
||||
|
||||
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_<module>_` 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 |
|
||||
Reference in New Issue
Block a user