Nodefony 10.0.0-beta.5
Framework Node.js fullstack — HTTP & WebSocket, même contexte

Data plane d'administration — le pont /nodefony/<ns>/api/*

stable@nodefony/frameworkmis à jour 2026-07-21

N’importe quel module (et le kernel lui-même) peut exposer sa donnée d’admin — statistiques, introspection, actions — de façon cohérente CLI ↔ Web. Chaque module DÉCLARE ce qu’il expose (IAdminApi) ; un service unique, l’AdminBroker, COLLECTE ces déclarations au boot et MONTE les routes /nodefony/<namespace>/api/*. Studio n’est qu’un lecteur de ce plan de données : il ne le possède pas. Tout est ancré sur nodefony/service/AdminBroker.ts et le contrat core IAdminApi.ts.

📍 Documentation › Framework › Data plane admin

🧠 Le modèle mental — un annuaire qui monte des routes#

Sépare qui produit la donnée (un module, sans rien savoir du transport) de qui la transporte (le broker, seul à posséder le Router). C’est une inversion de dépendance : le contrat producteur vit au plus bas niveau (le core), le montage vit dans le framework.

Trois idées à retenir :

  1. Le producteur ne connaît pas le transport — un handler lit un IAdminRequest (IAdminApi.ts#IAdminRequest) et rend du JSON. Il ne touche jamais au socket ni à la Response.
  2. Un seul controller pont — toutes les routes admin pointent vers AdminApiController.dispatch() (AdminApiController.ts#AdminApiController.dispatch). Pas de génération dynamique de classes ; chaque route reste une vraie Route (404/405 du Router intacts).
  3. Le broker possède le Router, pas le kernel — c’est pourquoi il vit dans @nodefony/framework, niveau qui monte les routes, alors que le contrat producteur vit dans le core.

📖 Lexique#

Terme Sens (dans cette page)
Data plane (admin) Le plan de données d’admin : l’ensemble des routes /nodefony/<ns>/api/* qui exposent l’état/actions.
AdminBroker Le service qui collecte les IAdminApi et monte leurs routes. Le transporteur.
IAdminApi Le contrat qu’un module implémente pour DÉCLARER son admin (namespace + endpoints). Le producteur.
Namespace Segment d’identité d’un producteur → /nodefony/<namespace>/api/* (ex. http, security, kernel).
Endpoint Une action déclarée (IAdminEndpoint) : chemin relatif, méthode, rôle, handler.
IAdminRequest Projection normalisée du contexte HTTP/WS passée au handler (params, query, body, user, roles).
IAdminResponse Enveloppe optionnelle du retour d’un handler (status, headers, body).
RBAC Role-Based Access Control : l’accès dépend des rôles de l’appelant.
ROLE_NODEFONY_* Rôles de la plateforme (admin du framework), distincts des rôles applicatifs ROLE_* d’un tenant.
Pont / dispatch Le controller unique qui, à chaque requête, retrouve l’endpoint et exécute son handler.
Deux transports Toute action admin déclare AUSSI le transport WebSocket → invocable par le pont WS-RPC api.request.
Duplex Une même action servie sur HTTP et WebSocket (le différenciateur Nodefony).
Catalogue / discovery La liste des producteurs + endpoints, exposée en JSON pour que Studio bâtisse sa navigation.
Playground La console dev qui joue n’importe quel controller depuis le navigateur (/nodefony/playground).
BFF Backend-For-Frontend : la session cookie opaque qui authentifie Studio en amont du RBAC.
Fail-closed En cas de doute (rôle absent, endpoint non trouvé) → on REFUSE. Jamais d’ouverture par défaut.

Qu’est-ce que c’est ? — un data plane, pas une vue#

Le problème. Un framework doit s’administrer : lister ses routes, ses sessions, son firewall, relancer une tâche. Sans convention, chaque module invente son URL, sa forme de réponse, sa garde — et l’admin Web diverge de la CLI. Le résultat est une nébuleuse d’endpoints hétérogènes, impossibles à découvrir automatiquement.

La réponse. Le data plane admin est une convention unique : tout ce qui s’administre s’expose sous /nodefony/<module>/api/*, avec la même projection de requête, la même garde RBAC et la même sérialisation JSON. Un module ne code jamais une route d’admin à la main — il déclare sa donnée, le broker la monte.

💡 Astuce

« Data plane » se lit plan de données : la couche qui transporte l’état et les actions d’admin, par opposition au plan de contrôle (l’UI de Studio qui décide quoi afficher). Studio consomme le data plane ; il ne le contient pas — le même plan existe même si Studio n’est pas chargé.

La vision Nodefony — le contrat au plus bas, le montage au bon niveau#

Nodefony sépare deux rôles par inversion de dépendance :

Pour s’enregistrer sans dépendre du framework, un producteur récupère le broker via sa vue minimale IAdminRegistry (IAdminApi.ts#IAdminRegistry) — juste register() — depuis le container. Le kernel n’étant pas un Module, c’est le framework qui construit et enregistre l’IAdminApi du kernel à sa place (createKernelAdminApi(), cité plus bas).

Le compromis assumé : un seul controller pont (AdminApiController.ts#AdminApiController) sert les N endpoints. On y gagne zéro génération de classe, un dispatch O(1), et une garde RBAC + idempotence appliquée au même endroit pour tout le monde.

🚀 Démarrage rapide#

Objectif : exposer GET /nodefony/shop/api/stats et POST /nodefony/shop/api/reindex depuis un module « shop » d’une app générée par nodefony create app. Le producteur déclare, le module enregistre, le broker monte.

// modules/shop/index.ts — le producteur ET son enregistrement, vus d'une app.
import { Module, Kernel } from "nodefony";
import type {
  Container,
  IAdminApi,
  IAdminEndpoint,
  IAdminRequest,
  IAdminResponse,
  IAdminRegistry,
} from "nodefony";

/** Service métier « shop » — résolu du container, jamais importé par le broker. */
interface ShopService {
  stats(full: boolean): Promise<{ orders: number; revenue?: number }>;
  reindex(): Promise<{ jobId: string }>;
}

/**
 * Producteur admin du module « shop » → monté sous `/nodefony/shop/api/*`.
 * Un handler lit un `IAdminRequest` (projection du contexte) et rend du JSON :
 * zéro socket, zéro `Response`. C'est ce découplage qui rend la même action
 * invocable en HTTP ET par le pont WebSocket `api.request`.
 */
function createShopAdminApi(shop: ShopService): IAdminApi {
  const endpoints: IAdminEndpoint[] = [
    {
      // GET /nodefony/shop/api/stats — rôle par défaut ROLE_NODEFONY_ADMIN.
      // Gradation par rôle : le détail (CA) n'est rendu qu'à un admin.
      path: "stats",
      summary: "Compteurs de la boutique (commandes, CA)",
      handler: (req: IAdminRequest) =>
        shop.stats(req.roles.includes("ROLE_NODEFONY_ADMIN")),
    },
    {
      // POST /nodefony/shop/api/reindex — mutation : le broker impose une clé
      // Idempotency-Key côté WebSocket (rejeu de socket), optionnelle en HTTP.
      path: "reindex",
      method: "POST",
      role: "ROLE_NODEFONY_ADMIN",
      summary: "Relance l'indexation du catalogue",
      handler: async (): Promise<IAdminResponse<{ jobId: string }>> => {
        const { jobId } = await shop.reindex();
        return { status: 202, body: { jobId } };
      },
    },
  ];
  return {
    adminNamespace: "shop",
    adminDescriptor: () => ({
      label: "Shop",
      icon: "shopping-cart",
      order: 50,
    }),
    adminEndpoints: () => endpoints,
  };
}

/**
 * Le module enregistre son producteur au `onKernelBoot` — AVANT que le framework
 * ne monte les routes (`onKernelReady` → `broker.mountAll()`). Le broker n'est
 * présent que si `@nodefony/framework` est chargé : sinon, no-op silencieux
 * (le module reste utilisable sans data plane admin).
 */
class ShopModule extends Module {
  constructor(kernel: Kernel) {
    super("shop", kernel, import.meta.url, {});
  }

  override async onKernelBoot(): Promise<this> {
    const container = this.kernel?.container as Container | undefined;
    const registry = container?.get("adminBroker") as
      IAdminRegistry | undefined;
    if (registry && container && !registry.has("shop")) {
      const shop = container.get("shop") as ShopService;
      registry.register(createShopAdminApi(shop));
    }
    return this;
  }
}

export default ShopModule;

Ce qu’on observe#

# 1) Sans session Studio (BFF) : la zone firewall `nodefony-admin` verrouille → 401
curl -si http://localhost:5151/nodefony/shop/api/stats | head -1
# HTTP/1.1 401 Unauthorized

# 2) Authentifié en admin (cookie de session BFF) → 200 + l'identité du pod qui a répondu
curl -s -b /tmp/jar http://localhost:5151/nodefony/shop/api/stats
# {"orders":128,"revenue":48213}

# 3) La mutation → 202, corps porté par le `return` du handler
curl -si -b /tmp/jar -X POST http://localhost:5151/nodefony/shop/api/reindex | head -1
# HTTP/1.1 202 Accepted

# 4) Le catalogue : ce que Studio lit pour bâtir sa navigation admin
curl -s -b /tmp/jar http://localhost:5151/nodefony/framework/api/admin | head -c 160
# {"producers":[{"namespace":"kernel",…},{"namespace":"shop","label":"Shop",…}]}

ℹ️ Note

Chaque réponse HTTP porte un en-tête x-nodefony-instance (AdminApiController.ts#AdminApiController.dispatch) : en multi-pod, il dit quel process a répondu (le data plane est per-instance).

🏗️ Architecture interne — register → mountAll → dispatch#

Deux temps : un montage au boot (une fois), un dispatch par requête (O(1)).

# Étape Où
1 Le producteur s’enregistre AdminBroker.register() (AdminBroker.ts#AdminBroker.register)
2 Le framework monte tout AdminBroker.mountAll() (AdminBroker.ts#AdminBroker.mountAll)
3 Une route par endpoint (nom déterministe) Router.createRoute() (AdminBroker.ts#AdminBroker.mountAll)
4 Le controller pont estampillé une fois Router.setController() idempotent (AdminBroker.ts#AdminBroker.mountAll)
5 Dispatch : lookup de la route AdminBroker.resolve() (AdminApiController.ts#AdminApiController.runAdmin)
6 Projection du contexte en requête admin buildRequest() (AdminApiController.ts#AdminApiController.buildRequest)
7 Normalisation du retour normalizeAdminResult() (executeAdmin.ts#normalizeAdminResult)

Points de conception saillants :

❗ Important

Convention de route figée : le data plane est toujours en ≥ 3 segments /nodefony/<module>/api/* (IAdminBroker.ts#IAdminBroker). Jamais une route admin mono-segment /nodefony/<module> — elle entrerait en collision avec le fallback SPA de Studio. Le chemin relatif d’un endpoint a ≥ 1 segment (types/IAdminApi.ts#IAdminEndpoint.path) : la racine /nodefony/<ns>/api est réservée.

🔐 RBAC — autorisation du data plane#

Deux gardes se succèdent, dans cet ordre :

  1. Le firewall AUTHENTIFIE en amont. La zone nodefony-admin (config.ts) couvre ^/nodefony/[^/]+/api(/|$) (config.ts) avec l’authenticator session (cookie BFF) : sans session, c’est 401 avant même le controller.
  2. Le broker tranche le RÔLE. À l’exécution, le pont compare le rôle exigé aux rôles de l’appelant via la fonction pure isAdminGranted() (adminRbac.ts#isAdminGranted). Rôle absent → 403. Le refus est prononcé au CŒUR (executeAdmin.ts), pas dans le controller HTTP : c’est ce qui fait que les deux chemins d’appel — la route et le pont MCP — refusent à l’identique.

La décision est fail-closed : un authentifié sans le rôle requis — y compris roles=[] (compte non doté) — est rejeté (adminRbac.ts#isAdminGranted). C’était l’ex-fail-open historique (un roles.length > 0 && héritait du « mode mock » d’avant l’auth) : l’absence de rôle ne vaut pas laissez-passer.

💡 Astuce

ROLE_NODEFONY_* = rôles de la plateforme (administrer le framework), distincts des rôles applicatifs ROLE_* d’un tenant. Un endpoint peut exiger un rôle plus fin (role: "ROLE_…") ou graduer l’information dans son handler en lisant request.roles.

🔌 HTTP et WebSocket — la même action#

Toute action admin déclare aussi le transport WEBSOCKET (AdminBroker.ts#AdminBroker.mountAll) : la route est montée avec [method, "WEBSOCKET"]. Elle devient donc invocable par le pont WS-RPC api.request (WebsocketContext.ts#WebsocketContext.request) — même action, même handler, même réponse. Seul l’emballage diffère :

Les mutations sont pontables par socket. La sécurité d’écriture repose alors sur l’idempotence (idempotencyGate(), AdminApiController.ts#AdminApiController.idempotencyGate) : la clé Idempotency-Key est obligatoire en WS (une socket reconnecte et rejoue), optionnelle en HTTP (required: false, AdminApiController.ts#AdminApiController.idempotencyGate). Un GET n’est jamais idempotenté (AdminApiController.ts#AdminApiController.idempotencyGate) ; la porte est évaluée après le RBAC (un 403 ne consomme aucune entrée). Le helper est le même que le seam @Idempotent des controllers userland — voir Idempotence.

🧩 Extension — déclarer l’API d’admin de son module#

Trois pas, du point de vue d’un module :

  1. Écrire un IAdminApi : adminNamespace (url-safe, stable), adminDescriptor() (sidebar Studio, IAdminApi.ts#IAdminApi.adminDescriptor) et adminEndpoints() (IAdminApi.ts#IAdminApi.adminEndpoints). Un endpoint peut renvoyer la donnée brute (assumée {status:200, body}) ou une IAdminResponse pour piloter statut/en-têtes (IAdminApi.ts#IAdminResponse).
  2. S’enregistrer au onKernelBoot via IAdminRegistry.register() (IAdminApi.ts#IAdminRegistry.register), récupéré par container.get("adminBroker"). Rendre l’appel idempotent (registry.has(ns) avant register) — modèle de tous les producteurs.
  3. Laisser le framework monter : à onKernelReady, Framework.onKernelReady() enregistre les producteurs internes puis appelle broker.mountAll() (index.ts#Framework.onKernelReady).

Handlers lazy : résous tes services dans le handler (à la requête), jamais au montage — un service désactivable renvoie alors 503 proprement au lieu de casser le boot.

Ce que publie un endpoint — l’état réel, aucun secret, ses capacités déclarées#

Tout ce que renvoie un handler part tel quel vers la console, le pont socket et les agents. Trois règles en découlent.

1. Un DTO ne porte aucun secret : il en dit la présence. Un mot de passe, une clé ou un jeton se décrivent par un booléen. Le résumé d’un utilisateur expose hasPassword, jamais le hash (UserAdminApi.ts#IUserSummary). La description du pare-feu expose synchronizerToken: boolean (IFirewallDescription.ts#IFirewallDescription). La redaction se fait en construisant le DTO, champ par champ. Copier l’objet puis retirer les clés sensibles laisserait passer la prochaine qui sera ajoutée. Un test le prouve : JSON.stringify(dto) ne contient pas la valeur sentinelle posée dans la configuration.

2. Une description montre ce qui TOURNE, pas la configuration relue. Une méthode describe() lit les structures que le service a bâties au démarrage (IFirewall.ts#IFirewall.describe, IBackplane.ts#IBackplane.describe). Un authenticator mal orthographié dans la configuration n’y figure pas, puisqu’il n’est pas monté. Relire la configuration montrerait l’intention, et la console afficherait comme active une défense qui ne tourne pas.

3. Une capacité de liste non déclarée est refusée en 400, jamais ignorée. Tri, filtres, recherche et facettes se déclarent dans page (IAdminApi.ts#IAdminPageCapabilities), et le handler lit la même donnée : il passe ses champs triables à parsePageQuery() (pageQuery.ts#parsePageQuery) et son vocabulaire de filtres à parseFilters() (pageFilters.ts#parseFilters). Ces deux lecteurs refusent en 400 un tri hors liste, un ?q= sur un endpoint qui ne cherche pas, une valeur mal formée et un paramètre que personne ne reconnaît. Lire request.query.order à la main renvoie une page non triée que le client prend pour triée. Avec un filtre mal orthographié, c’est la collection entière qui revient.

import { parseFilters, parsePageQuery, type IAdminEndpoint } from "nodefony";

// Une constante, deux lectures : le catalogue et le handler ne peuvent pas diverger.
const SORTABLE = ["createdAt", "total"] as const;
const FILTERS = { status: ["paid", "pending", "refunded"] } as const;

const listOrders: IAdminEndpoint = {
  path: "orders",
  method: "GET",
  summary: "Commandes, paginées.",
  page: { sortable: () => SORTABLE, filters: FILTERS },
  handler: async (request) => {
    const query = parsePageQuery(request.query, { sortable: SORTABLE }); // ?order=x → 400
    const filters = parseFilters(request.query, FILTERS); // ?status=zzz, ?stauts= → 400
    return orders.listPage({ ...query, ...filters });
  },
};

Les producteurs réels (à imiter, sans les redocumenter)#

Le broker lui-même est déclaré comme service du framework (index.ts). Les producteurs internes sont enregistrés au onKernelReady du framework :

Namespace Producteur Rôle
kernel createKernelAdminApi (KernelAdminApi.ts#createKernelAdminApi) modules, process, uptime, livez
framework createFrameworkAdminApi (FrameworkAdminApi.ts#createFrameworkAdminApi) dump du Router + catalogue + Playground (dev)
syslog createSyslogAdminApi (SyslogAdminApi.ts#createSyslogAdminApi) viewer de logs (dev)

Les modules externes s’enregistrent depuis leur propre onKernelBoot :

Namespace Module Enregistrement
http @nodefony/http createHttpAdminApi (http/index.ts#Http.onKernelRegister)
security @nodefony/security registerSecurityAdminApi (security/index.ts#Security.onKernelBoot)
user @nodefony/user adminNamespace (UserAdminApi.ts#createUserAdminApi)
orm @nodefony/orm-core adminNamespace (OrmAdminApi.ts#createOrmAdminApi)

Pour le détail de chacun, se reporter à la doc de son module — le broker reste agnostique de leur contenu.

🧰 API publique#

Depuis une app : AdminBroker (le service) et les types IAdminApi, IAdminEndpoint, IAdminRequest, IAdminResponse, IAdminRegistry, IAdminDescriptor — tous exportés par @nodefony/framework et nodefony. Les signatures exactes vivent dans .ai/symbols.json — jamais recopiées ici (elles s’y périmeraient).

Membre (IAdminBroker) Rôle Ancre
register(api) Enregistre un producteur (throw si namespace pris/monté) AdminBroker.ts#AdminBroker.register
unregister(ns) Retire un producteur (et ses routes si montées) AdminBroker.ts#AdminBroker.unregister
has(ns) / getApi(ns) Interrogation du registre AdminBroker.ts#AdminBroker.getApi
list() Producteurs enregistrés (immuable) AdminBroker.ts#AdminBroker.list
resolvePath(ns, path) Chemin absolu d’un endpoint sans le monter AdminBroker.ts#AdminBroker.resolvePath
mountAll() Monte toutes les routes (idempotent) AdminBroker.ts#AdminBroker.mountAll
resolve(routeName) Lookup O(1) d’une route montée (utilisé par le pont) AdminBroker.ts#AdminBroker.resolve
routes() Introspection des routes montées (source du catalogue) AdminBroker.ts#AdminBroker.routes

📡 Observabilité — Studio#

⚠️ Pièges (symptôme → cause → correction)#

Symptôme Cause (dans le code) Correction
register() throw « routes figées » Appel après mountAll() (AdminBroker.ts#AdminBroker.mountAll) Enregistrer au onKernelBoot, pas plus tard
register() throw « namespace déjà enregistré » Deux producteurs sur le même adminNamespace (AdminBroker.ts#AdminBroker.register) Namespace unique ; garder register() idempotent (has(ns) avant)
401 sur toute route /nodefony/<ns>/api/* Zone nodefony-admin : pas de session BFF (config.ts) S’authentifier (login BFF) ; pour une sonde publique → public: true + zone anonyme
403 alors qu’on est connecté Rôle manquant, isAdminGranted fail-closed (adminRbac.ts#isAdminGranted) Doter le compte du rôle requis (défaut ROLE_NODEFONY_ADMIN)
WS : mutation refusée 400 clé requise Idempotence : clé obligatoire par socket (AdminApiController.ts#AdminApiController.idempotencyGate) Fournir Idempotency-Key sur la mutation WS
Route admin injoignable / collision Studio Endpoint mono-segment /nodefony/<module> (IAdminBroker.ts#IAdminBroker) Toujours ≥ 3 segments /nodefony/<ns>/api/<path>
500 « Admin endpoint not registered » adminRoute absent du registre — incohérence interne (AdminApiController.ts#AdminApiController.runAdmin) Vérifier que le producteur a bien été enregistré avant mountAll()

🧪 Tests & couverture#

Deux familles couvrent la brique — les chiffres exacts vivent dans la carte de l’aperçu (régénérée depuis vitest, jamais figée ici) :

Ce qui manque aujourd’hui : aucun banc de charge/mémoire dédié au broker seul — le coût est mesuré au niveau du pipeline complet (memory.test.ts de @nodefony/http + suites de charge). Pour ces axes, voir les skills nodefony-load-test et nodefony-check-memory-health.

Couverture : npm run coverage dans @nodefony/framework.

🔗 Pour aller plus loin#