Nodefony
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.

📍 DocumentationFrameworkData 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:33) 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:60). 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.
API souveraine 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:243) — 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:31) 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:76) : 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
1 Le producteur s’enregistre AdminBroker.register() (AdminBroker.ts:45)
2 Le framework monte tout AdminBroker.mountAll() (AdminBroker.ts:104)
3 Une route par endpoint (nom déterministe) Router.createRoute() (AdminBroker.ts:124)
4 Le controller pont estampillé une fois Router.setController() idempotent (AdminBroker.ts:146)
5 Dispatch : lookup de la route AdminBroker.resolve() (AdminApiController.ts:94)
6 Projection du contexte en requête admin buildRequest() (AdminApiController.ts:175)
7 Normalisation du retour normalizeAdminResult() (executeAdmin.ts:90)

Points de conception saillants :

❗ Important

Convention de route figée : le data plane est toujours en ≥ 3 segments /nodefony/<module>/api/* (IAdminBroker.ts:42). 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:155) : 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:137) couvre ^/nodefony/[^/]+/api(/|$) (config.ts:141) 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:24). 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:27). 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 (API souveraine)#

Toute action admin déclare aussi le transport WEBSOCKET (AdminBroker.ts:123) : la route est montée avec [method, "WEBSOCKET"]. Elle devient donc invocable par le pont WS-RPC api.request (WebsocketContext.ts:354) — 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:158) : la clé Idempotency-Key est obligatoire en WS (une socket reconnecte et rejoue), optionnelle en HTTP (required: false, AdminApiController.ts:184). Un GET n’est jamais idempotenté (AdminApiController.ts:149) ; 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:220) et adminEndpoints() (IAdminApi.ts:222). Un endpoint peut renvoyer la donnée brute (assumée {status:200, body}) ou une IAdminResponse pour piloter statut/en-têtes (IAdminApi.ts:67).
  2. S’enregistrer au onKernelBoot via IAdminRegistry.register() (IAdminApi.ts:249), 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:369).

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.

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

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

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

Les modules externes s’enregistrent depuis leur propre onKernelBoot :

Namespace Module Enregistrement
http @nodefony/http createHttpAdminApi (http/index.ts:121)
security @nodefony/security registerSecurityAdminApi (security/index.ts:94)
user @nodefony/user adminNamespace (UserAdminApi.ts:879)
orm @nodefony/orm-core adminNamespace (OrmAdminApi.ts:537)

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:45
unregister(ns) Retire un producteur (et ses routes si montées) AdminBroker.ts:61
has(ns) / getApi(ns) Interrogation du registre AdminBroker.ts:79
list() Producteurs enregistrés (immuable) AdminBroker.ts:87
resolvePath(ns, path) Chemin absolu d’un endpoint sans le monter AdminBroker.ts:91
mountAll() Monte toutes les routes (idempotent) AdminBroker.ts:104
resolve(routeName) Lookup O(1) d’une route montée (utilisé par le pont) AdminBroker.ts:96
routes() Introspection des routes montées (source du catalogue) AdminBroker.ts:100

📡 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:46) Enregistrer au onKernelBoot, pas plus tard
register() throw « namespace déjà enregistré » Deux producteurs sur le même adminNamespace (AdminBroker.ts:51) Namespace unique ; garder register() idempotent (has(ns) avant)
401 sur toute route /nodefony/<ns>/api/* Zone nodefony-admin : pas de session BFF (config.ts:141) 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:24) 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:184) Fournir Idempotency-Key sur la mutation WS
Route admin injoignable / collision Studio Endpoint mono-segment /nodefony/<module> (IAdminBroker.ts:42) Toujours ≥ 3 segments /nodefony/<ns>/api/<path>
500 « Admin endpoint not registered » adminRoute absent du registre — incohérence interne (AdminApiController.ts:96) 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#