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:
2026-04-25 10:50:13 -04:00
parent 3098940905
commit a3aff9fa49
23 changed files with 776 additions and 33 deletions
+1 -1
View File
@@ -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';
+11 -4
View File
@@ -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.
+23
View File
@@ -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<void>` | oui |
| `createTables` | `async () => { created?, skipped? }` | si le module a des tables |
| `dropTables` | `async () => void` | si le module a des tables |
+129
View File
@@ -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 ?? ''}`);
}
}
}
}
+1
View File
@@ -0,0 +1 @@
export { registerModule, getRegisteredModules, getRegisteredModule, clearRegisteredModules } from './registry.js';
+45
View File
@@ -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<void>
* - 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<string, object>} */
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();
}
@@ -0,0 +1,28 @@
import { notFound } from 'next/navigation';
import { getPublicModulePage } from './registry.js';
/**
* Composant serveur RSC catch-all pour `/zen/<module>/<...>`.
*
* 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 <Component params={resolved} segments={rest} />;
}
+37
View File
@@ -0,0 +1,37 @@
# Public Module Pages
Registre runtime pour les pages publiques `/zen/<module>/<...>` ajoutées par les modules externes.
## Concept
Tout chemin `/zen/<segment>/...` (sauf `/zen/api/...` réservé aux routes API) est résolu vers le composant enregistré sous `<segment>`. 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/<moduleName>/`. 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.
+1
View File
@@ -0,0 +1 @@
export { registerPublicModulePage, getPublicModulePage, getPublicModulePages } from './registry.js';
+36
View File
@@ -0,0 +1,36 @@
/**
* Registre runtime des pages publiques `/zen/<module>/<...>`.
*
* 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/<module>/` ; 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<string, { moduleName: string, Component: any, title?: string }>} */
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()];
}
+19 -10
View File
@@ -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();
+11 -1
View File
@@ -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';
+47
View File
@@ -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<string, { key: string, name: string, description?: string, group_name: string }>} */
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();
}
+6
View File
@@ -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(
+3 -4
View File
@@ -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(
+2
View File
@@ -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
@@ -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 }) => {
<div className="flex flex-col gap-3">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Permissions</p>
{Object.entries(PERMISSION_GROUPS).map(([group, perms]) => (
{Object.entries(permissionGroups).map(([group, perms]) => (
<div key={group} className="rounded-xl border border-neutral-200 dark:border-neutral-700/60 overflow-hidden">
<div className="px-4 py-2.5 bg-neutral-50 dark:bg-neutral-800/60 border-b border-neutral-200 dark:border-neutral-700/60">
<p className="text-[11px] font-medium font-ibm-plex-mono text-neutral-500 dark:text-neutral-400 uppercase tracking-wide">
+17 -1
View File
@@ -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 },
+59 -7
View File
@@ -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`);
+23 -1
View File
@@ -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');
}