---
title: "Décorateurs — la surface déclarative des contrôleurs"
source: src/packages/@nodefony/framework/docs/decorateurs.md
url: https://nodefony.github.io/nodefony-core/docs/modules/framework/decorateurs/
---


> Un contrôleur Nodefony ne s'enregistre pas, ne se configure pas, ne se branche pas : il se
> **décrit**. `@controller` dit où il vit, `@Get` dit quand il répond, `@Body` dit ce qu'il reçoit,
> `@HttpCode` dit comment il répond, `@IsGranted` dit qui a le droit. Cette page est **la table de
> référence** des 36 décorateurs du module : pour chacun, sa cible, son effet et un exemple court.
> Tout est ancré sur `nodefony/decorators/routerDecorators.ts` — le fichier unique qui les porte tous.

📍 [Documentation](../../../) › [Framework](../) › **Décorateurs**

## 🧠 Le modèle mental — trois temps, jamais confondus

C'est LA chose à comprendre : un décorateur **ne fait rien** au moment où tu l'écris. Il écrit une
étiquette. Trois moments distincts se partagent le travail, et chaque bizarrerie de la page découle
de ce découpage.

```mermaid
flowchart TD
  subgraph T1["1 · À l'IMPORT du fichier"]
    D["@Get / @Body / @IsGranted…<br/>posent des métadonnées Reflect"]
  end
  subgraph T2["2 · Au MONTAGE (une seule fois)"]
    C["@controller lit routes:definitions<br/>→ Router.createRoute()"]
    CS["@controllers → hook onBoot<br/>→ Router.setController()"]
  end
  subgraph T3["3 · À la 1ʳᵉ REQUÊTE de la route"]
    RM["resolveActionMeta()<br/>fige RouteActionMeta sur la route"]
    RQ["requêtes suivantes : 0 Reflect, O(1)"]
  end
  D --> C --> CS --> RM --> RQ
```

1. **À l'import**, chaque décorateur appelle `Reflect.defineMetadata` et rend la main. Zéro route
   créée, zéro service résolu.
2. **Au montage**, `controller()` (`routerDecorators.ts#controller`) relit ces métadonnées et fabrique les
   objets `Route` ; `controllers()` (`routerDecorators.ts#controllers`) accroche le contrôleur au module sur
   le hook `onBoot` du kernel.
3. **À la première requête** de chaque route, `resolveActionMeta()` (`routerDecorators.ts#resolveActionMeta`)
   consolide toutes les étiquettes de l'action en **un objet figé** posé sur la route. Les requêtes
   suivantes ne lisent plus aucune métadonnée.

> [!IMPORTANT]
> Conséquence directe : **une route n'existe que si son fichier a été importé**. Un contrôleur oublié
> dans le tableau `@controllers([...])` ne produit aucune erreur — il produit un `404`.

## 📖 Lexique

| Terme                  | Sens                                                                                                                 |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------- |
| Décorateur             | Annotation TS (`@Get(…)`) exécutée à l'import, qui attache une information à une classe, une méthode ou un argument. |
| Décorateur legacy      | Le format historique TypeScript (`experimentalDecorators`), le seul utilisé ici — voir « Le contrat TypeScript ».    |
| Métadonnée (`Reflect`) | Étiquette clé→valeur rangée sur une classe par `reflect-metadata`, relisible plus tard sans toucher au code.         |
| Cible                  | Ce que le décorateur annote : **classe**, **méthode**, ou **paramètre** d'une méthode.                               |
| Décorateur **dual**    | Utilisable en classe (vaut pour toutes les actions) **et** en méthode (une seule action).                            |
| Action                 | La méthode du contrôleur qui traite la requête.                                                                      |
| Montage                | Le moment où `@controller` transforme les métadonnées en routes réelles dans le `Router`.                            |
| `RouteActionMeta`      | Le résumé figé (par route) de tous les décorateurs de l'action — lu par le `Resolver`.                               |
| Clause (autorisation)  | Un `@IsGranted`/`@RequireScope` : plusieurs attributs en **OU**, plusieurs clauses en **ET**.                        |
| Scope (`api:action`)   | Droit porté par un **jeton machine** (clé API, JWT) ; ne bride jamais un humain.                                     |
| ALS                    | _AsyncLocalStorage_ : la bulle Node qui transporte la requête courante sans la passer en argument.                   |
| Mutation               | Méthode non sûre : `POST`/`PUT`/`PATCH`/`DELETE` (RFC 9110 §9.2.1).                                                  |
| Hot path / cold path   | Chemin parcouru à **chaque** requête / chemin parcouru rarement (montage, 1ʳᵉ requête).                              |

## Qu'est-ce qu'un décorateur, concrètement ?

Imagine des **étiquettes collées sur une machine** avant sa mise en service. Aucune ne fait tourner
la machine ; elles disent au monteur quoi brancher : « alimentation 220 V », « ne pas ouvrir sans
habilitation », « sortie : 3 bars ». Le monteur passe une fois, lit toutes les étiquettes, et câble
en conséquence.

Un décorateur Nodefony, c'est exactement ça :

```typescript
@Get("/{id}")                       // étiquette : réponds à GET /prefix/{id}
@HttpCode(200)                      // étiquette : statut par défaut 200
@IsGranted("ROLE_USER")             // étiquette : réservé aux porteurs du rôle
async read(@Param("id") id: string) // étiquette d'argument : passe-moi la variable d'URL `id`
```

Sans décorateurs, il faudrait écrire à la main un fichier de routes (le chemin, la méthode, le nom du
contrôleur, l'action, les droits), le maintenir en parallèle du code, et le voir diverger. **Le
décorateur supprime la double vérité** : la déclaration vit sur l'action qu'elle décrit.

### Le contrat TypeScript — décorateurs _legacy_, et pourquoi ça compte

Nodefony utilise le format **legacy** de TypeScript : `experimentalDecorators: true` (dans le
`tsconfig.json` racine, repris par le module — `framework/tsconfig.json`). Ce n'est pas un détail
historique : c'est ce qui rend possibles les **décorateurs de paramètre** (`@Param`, `@Body`…, et
`@Inject("nom")` côté conteneur) — le format standard ES ne les propose pas.

`emitDecoratorMetadata`, en revanche, vaut **`false`** : Nodefony ne lit aucun type émis par le
compilateur. Une dépendance de constructeur se **nomme** par `@Inject("nom")`
(`kernelDecorator.ts#Inject`) ; un paramètre non nommé reçoit l'argument positionnel, ou
`undefined`, quel que soit son type ([ADR-0018](https://github.com/nodefony/nodefony-core/blob/main/docs/adr/0018-decorateurs-sans-metadonnees-de-type.md)).
Ne le repasser à `true` que pour une bibliothèque tierce qui lit les métadonnées `design:*`.

Concrètement, dans une app générée par `nodefony create app`, ces options sont **déjà** dans le
`tsconfig.json`. Tu n'as rien à faire — sauf si tu pars d'un `tsconfig` à toi : sans
`experimentalDecorators`, les décorateurs ne compilent pas.

> [!WARNING]
> `reflect-metadata` doit être chargé **avant** tout décorateur. `routerDecorators.ts` l'importe
> pour toi dès que tu importes un décorateur du framework — mais si tu écris ton propre décorateur
> dans un fichier chargé plus tôt, mets-y `import "reflect-metadata";` en tête.

## La vision Nodefony

Trois partis pris expliquent la forme de cette surface, et un développeur qui les connaît ne se fait
jamais surprendre.

**1 — Un décorateur n'écrit QUE des métadonnées.** Aucun décorateur du framework ne contient de
logique de sécurité, de session ou d'idempotence. `IsGranted()` (`routerDecorators.ts#IsGranted`) pose une
clause ; c'est le `Resolver` qui appellera le moteur d'autorisation, **résolu par son nom** dans le
conteneur (`Resolver._enforceSecurity()`, `Resolver.ts#Resolver._enforceSecurity`). Pourquoi ce détour : `@nodefony/framework`
ne dépend **pas** de `@nodefony/security` — sans ça, les deux modules formeraient un cycle. Le prix à
payer est visible : une route gardée alors que le module `security` est absent renvoie **403**, pas
une erreur de démarrage (fail-closed, `Resolver.ts#Resolver._enforceSecurity`).

**2 — Tout est figé une fois, puis relu en O(1).** Les métadonnées de l'action sont consolidées au
premier passage dans `computeActionMeta()` (`routerDecorators.ts#computeActionMeta`) puis gelées sur la route.
L'objet `RouteActionMeta` (`routerDecorators.ts#RouteActionMeta`) est **partagé par toutes les requêtes** — le
framework ne le mute jamais, et ton code non plus. Une action non décorée obtient des champs à `null`,
ce qui vaut **zéro branche** dans le chemin chaud.

**3 — Les mêmes décorateurs pour HTTP et WebSocket.** C'est le différenciateur du framework : un
contrôleur ne change pas de forme selon le transport. Une action WS se déclare avec `@route` et le
transport `WEBSOCKET` dans ses `requirements` ; ses paramètres s'injectent avec les mêmes `@Body`,
`@Query`, `@CurrentUser`.

## 🚀 Démarrage rapide

Vu depuis une app créée par `nodefony create app`. Rien à configurer : **les décorateurs ne se
règlent pas, ils se déclarent**.

### Le contrôleur

```typescript
// nodefony/controller/BookController.ts — complet, compile tel quel
import {
  Controller,
  controller,
  Get,
  Post,
  Delete,
  Param,
  Query,
  Body,
  HttpCode,
  Header,
  IsGranted,
  CurrentUser,
} from "@nodefony/framework";
import type { IUser } from "@nodefony/user";

interface BookInput {
  title: string;
  author: string;
}

// Le préfixe s'applique à TOUTES les routes de la classe.
@controller("/api/books")
class BookController extends Controller {
  // GET /api/books?q=… — `@Query` sans valeur present → undefined, jamais throw.
  @Get("")
  async list(@Query("q") q?: string) {
    return this.renderJson({ items: [], q: q ?? null });
  }

  // GET /api/books/{id} — `{id}` est capturé et injecté par son NOM.
  @Get("/{id}")
  async read(@Param("id") id: string) {
    return this.renderJson({ id, title: "Le Horla" });
  }

  // POST /api/books — 201 + en-tête posés AVANT l'exécution de l'action.
  // @IsGranted est évalué encore avant : un 403 n'instancie même pas ce contrôleur.
  @Post("")
  @HttpCode(201)
  @Header("Cache-Control", "no-store")
  @IsGranted(["ROLE_USER"])
  async create(@Body() dto: BookInput, @CurrentUser() user: IUser) {
    return this.renderJson({ id: "b_42", ...dto, owner: user.identifier });
  }

  // DELETE /api/books/{id} — `subject: "id"` passe la variable d'URL au voter
  // métier (« cet utilisateur est-il propriétaire de CE livre ? »).
  @Delete("/{id}")
  @HttpCode(204)
  @IsGranted("book.delete", { subject: "id" })
  // ⚠️ PAS `remove` : `Controller` hérite de `Service.remove()` — voir les Pièges.
  async destroy(@Param("id") id: string) {
    void id;
    return null; // 204 : le Resolver envoie une réponse vide (RFC 9110)
  }
}

export default BookController;
```

### Le branchement (une ligne, dans le module de l'app)

```typescript
// index.ts du module — `nodefony create controller` fait ce câblage pour toi
import { Kernel, Module } from "nodefony";
import { Controller, controller, controllers, Get } from "@nodefony/framework";

@controller("/hello")
class HelloController extends Controller {
  @Get("")
  async index() {
    return this.renderJson({ hello: "nodefony" });
  }
}

// Sans cette ligne, les routes existent mais aucun module ne les porte → 404.
@controllers([HelloController])
class AppModule extends Module {
  constructor(kernel: Kernel) {
    super("app", kernel, import.meta.url, {});
  }
}

export default AppModule;
```

### Ce qu'on observe

```bash
# 1) Lecture publique
curl -s http://localhost:5151/api/books/42
# {"id":"42","title":"Le Horla"}

# 2) Création sans rôle → 403 rendu AVANT l'instanciation du contrôleur
curl -si -X POST http://localhost:5151/api/books \
  -H 'Content-Type: application/json' -d '{"title":"X","author":"Y"}' | head -1
# HTTP/1.1 403 Forbidden

# 3) Créée avec le rôle : le 201 et l'en-tête viennent des décorateurs
curl -si -b /tmp/jar -X POST http://localhost:5151/api/books \
  -H 'Content-Type: application/json' -d '{"title":"X","author":"Y"}' | head -3
# HTTP/1.1 201 Created
# Cache-Control: no-store

# 4) Méthode non déclarée pour ce chemin → 405 avec l'agrégat des méthodes
curl -si -X PUT http://localhost:5151/api/books/42 | head -2
# HTTP/1.1 405 Method Not Allowed
# Allow: GET, HEAD, DELETE
```

## 🧰 La table de référence — toute la surface décorateur

Six familles, **36 décorateurs**, un seul fichier source. Le tableau de synthèse sert à choisir en
5 secondes ; les tables détaillées qui suivent donnent l'effet exact et un exemple.

| Famille | Ce qu'elle décide | Décorateurs |
| --- | --- | --- |
| **Déclaration** | Où vit le contrôleur, quelles routes il porte | `@controllers` `@controller` `@route` `@Domain` `@Scope` |
| **Méthodes HTTP** | Quand l'action répond | `@Get` `@Post` `@Put` `@Patch` `@Delete` `@Options` `@Head` `@All` |
| **Paramètres** | Ce que l'action reçoit en arguments | `@Param` `@Query` `@Body` `@Headers` `@Cookie` `@Session` `@CurrentUser` `@Req` `@Res` `@UploadedFile` `@UploadedFiles` |
| **Réponse** | Statut, en-têtes, redirection | `@HttpCode` `@Header` `@Redirect` |
| **Sécurité** | Qui passe, qui décide, quelles défenses | `@IsGranted` `@RequireScope` `@RequireAuthentication` `@Anonymous` `@BypassFirewall` `@Csp` `@CsrfProtect` `@CsrfExempt` |
| **Cycle de la requête** | Session, anti-rejeu | `@UseSession` `@Idempotent` |

> Tous s'importent depuis `"@nodefony/framework"` — jamais par un chemin relatif interne.

### Déclaration — classe, module, route

| Décorateur | Cible | Effet | Exemple |
| --- | --- | --- | --- |
| `@controllers([…])` | **module** | Rattache des contrôleurs au module sur le hook `onBoot` ; sans lui, aucune route n'est servie (`controllers()`, `routerDecorators.ts#controllers`) | `@controllers([BookController])` |
| `@controller("/prefix")` | **classe** | Pose le préfixe d'URL **et déclenche la création des routes** de la classe (`controller()`, `routerDecorators.ts#controller`) | `@controller("/api/books")` |
| `@route(nom, options)` | méthode | Forme complète : nom explicite, chemin, `requirements`, `defaults`, hôte (`route()`, `routerDecorators.ts#route`) | `@route("ws-echo", { path: "/echo", requirements: { methods: ["WEBSOCKET"] } })` |
| `@Domain(motif \| motifs)` | **dual** | Restreint la route (ou la classe) à un ou plusieurs vhosts ; hors domaine → **403** (`Domain()`, `routerDecorators.ts#Domain`) | `@Domain("*.cdn.example.com")` |
| `@Scope("request")` | **classe** | Une instance de contrôleur par requête — par connexion en WebSocket — au lieu de l'instance partagée par défaut (`Scope()`, `routerDecorators.ts#Scope`) | `@Scope("request")` |

**`@controller` est le déclencheur.** Il relit les métadonnées posées par `@route`/`@Get`/… puis les
**efface** (`Reflect.deleteMetadata`, `routerDecorators.ts#controllers`) : une classe ne se monte qu'une
fois. Il traite au passage la route « magique » `path: "*"` en **dernier**, quel que soit son ordre
d'écriture (`hasMagic`, `routerDecorators.ts#controller`) — sinon un attrape-tout masquerait les routes précises.

**Le singleton — le défaut — est un contrat.** L'instance étant partagée, l'action ne doit lire ni
écrire **aucun** état de requête sur `this` : tout passe par les arguments décorés et les accesseurs,
qui retrouvent la requête courante via l'ALS (`ControllerScope`, `Controller.ts#ControllerScope`). `@Scope("request")`
rend une instance par requête : pour injecter un service de portée `request`, préparer chaque
requête dans `initialize()`, ou garder un état sur `this`.

> [!NOTE]
> Le core `nodefony` exporte lui aussi un `Scope` (les portées du conteneur d'injection). Celui des
> contrôleurs s'importe **depuis `@nodefony/framework`** — l'homonymie est signalée dans le code
> (`routerDecorators.ts#Scope`).

### Méthodes HTTP

Toutes les fabriques sortent du même moule, `httpMethodDecorator()` (`routerDecorators.ts#httpMethodDecorator`) :
elles nomment la route automatiquement `ClasseName::methode` et posent `requirements.methods`.

| Décorateur               | Méthode filtrée | Ancre                                     | Exemple             |
| ------------------------ | --------------- | ----------------------------------------- | ------------------- |
| `@Get(path?, opts?)`     | `GET`           | `Get` (`routerDecorators.ts#Get`)         | `@Get("/{id}")`     |
| `@Post(path?, opts?)`    | `POST`          | `Post` (`routerDecorators.ts#Post`)       | `@Post("")`         |
| `@Put(path?, opts?)`     | `PUT`           | `Put` (`routerDecorators.ts#Put`)         | `@Put("/{id}")`     |
| `@Delete(path?, opts?)`  | `DELETE`        | `Delete` (`routerDecorators.ts#Delete`)   | `@Delete("/{id}")`  |
| `@Patch(path?, opts?)`   | `PATCH`         | `Patch` (`routerDecorators.ts#Patch`)     | `@Patch("/{id}")`   |
| `@Options(path?, opts?)` | `OPTIONS`       | `Options` (`routerDecorators.ts#Options`) | `@Options("/{id}")` |
| `@Head(path?, opts?)`    | `HEAD`          | `Head` (`routerDecorators.ts#Head`)       | `@Head("/{id}")`    |
| `@All(path?, opts?)`     | **aucune**      | `All()` (`routerDecorators.ts#All`)       | `@All("/proxy/*")`  |

Deux points qu'un dev découvre sinon à ses dépens :

- **Le nom de route est automatique et déterministe** : `BookController::read`. Utile pour les logs,
  l'écran Routes de Studio et `forward()`. Deux actions homonymes dans deux classes ne collisionnent
  pas ; deux méthodes de même nom dans la même classe, si (c'est impossible en TS).
- **`@All` n'émet aucun `requirements.methods`** — la route matche donc **toutes** les méthodes et ne
  produit jamais de `405`. À réserver aux proxies et attrape-tout ; une API REST gagne à déclarer ses
  méthodes, ne serait-ce que pour l'en-tête `Allow`.

Le second argument accepte les options de route non redondantes — `Omit<RouteOptions, "path" | "method">`
(`routerDecorators.ts#route`), notamment `defaults`, `requirements`, `host`, `bypassFirewall` et `fallback`
(`RouteOptions`, `Route.ts#RouteOptions`) :

```typescript
@Get("/{page}", { defaults: { page: "1" }, requirements: { scheme: "https" } })
async index(@Param("page") page: string) { /* … */ }
```

`requirements.scheme` se compare au scheme **effectif** de la requête — celui qu'annonce un proxy de
confiance compris : hors exigence, la route répond **403**, jamais une redirection
(`Route.ts#Route.matchRequirements`). Rediriger `http` vers `https` relève de la bordure (HSTS, proxy
inverse), pas du routage. `fallback: true` fait de la route un **repli**, servi seulement si aucune
autre ne correspond — voir [le routage](../routing/#-ordre-de-résolution--trois-situations).

### Paramètres — ce que l'action reçoit

Onze décorateurs, tous produits par `paramDecoratorFactory()` (`routerDecorators.ts#paramDecoratorFactory`) sauf
`@Body`, qui accepte une option supplémentaire. Chacun pose `{ source, key, index }` ; la valeur est
calculée par `resolveParamArg()` (`routerDecorators.ts#resolveParamArg`), une fonction **pure** — ce qui la rend
testable sans démarrer de serveur.

| Décorateur          | Sans clé renvoie…                    | Avec clé renvoie…                          | Ancre                                                 |
| ------------------- | ------------------------------------ | ------------------------------------------ | ----------------------------------------------------- |
| `@Param("id")`      | toutes les variables d'URL (objet)   | la variable d'URL nommée                   | `Param` (`routerDecorators.ts#Param`)                 |
| `@Query("q")`       | toute la query string                | un paramètre de la query string            | `Query` (`routerDecorators.ts#Query`)                 |
| `@Body("field")`    | le corps parsé entier                | un champ du corps parsé                    | `Body()` (`routerDecorators.ts#Body`)                 |
| `@Headers("x-foo")` | tous les en-têtes de requête         | un en-tête (**lookup en minuscules**)      | `Headers` (`routerDecorators.ts#Headers`)             |
| `@Cookie("sid")`    | la map des cookies                   | un cookie (objet `Cookie`, champ `.value`) | `Cookie` (`routerDecorators.ts#Cookie`)               |
| `@Session("user")`  | l'objet `Session` vivant             | `session.get(clé)`                         | `Session` (`routerDecorators.ts#Session`)             |
| `@CurrentUser()`    | l'utilisateur résolu par le firewall | —                                          | `CurrentUser` (`routerDecorators.ts#CurrentUser`)     |
| `@Req()`            | la requête brute du contexte         | —                                          | `Req` (`routerDecorators.ts#Req`)                     |
| `@Res()`            | la réponse du contexte               | —                                          | `Res` (`routerDecorators.ts#Res`)                     |
| `@UploadedFile()`   | le **premier** fichier téléversé     | —                                          | `UploadedFile` (`routerDecorators.ts#UploadedFile`)   |
| `@UploadedFiles()`  | tous les fichiers téléversés         | —                                          | `UploadedFiles` (`routerDecorators.ts#UploadedFiles`) |

La liste des sources possibles est fermée et typée : `ParamSource` (`routerDecorators.ts#ParamSource`).

#### Trois comportements à connaître

**`@CurrentUser` lit l'ALS, jamais un argument caché.** La valeur vient de `RequestContext.getUser()`
(`routerDecorators.ts#CurrentUser`) : l'utilisateur posé par le firewall. C'est **l'utilisateur**, jamais le
justificatif (mot de passe, jeton). Hors zone authentifiée, la valeur est `undefined` — y compris
pour l'anonyme d'une zone qui l'accepte — : le décorateur n'authentifie rien, il expose ce qui a déjà
été prouvé. Sans décorateur, la même valeur se lit par `this.user` dans le contrôleur.

**`@Session` active la session à lui seul.** La simple présence d'un paramètre `@Session` vaut
déclaration d'intention : `resolveSessionIntent()` (`routerDecorators.ts#resolveSessionIntent`) la détecte et pose
l'intent, exactement comme `@UseSession()`. Une route sans l'un ni l'autre ne paie aucune session.

**`@Body({ stream: true })` court-circuite le parsing.** Pour un gros téléversement (vidéo,
sauvegarde), on injecte le **flux brut** de la requête au lieu du corps chargé en mémoire ; le
pipeline saute alors le parsing pour cette route, décision prise en amont par
`routeExpectsBodyStream()` (`routerDecorators.ts#routeExpectsBodyStream`) :

```typescript
@Post("/upload")
async upload(@Body({ stream: true }) stream: NodeJS.ReadableStream) {
  await pipeline(stream, createWriteStream("/data/upload.bin")); // 0 pic mémoire
  return this.renderJson({ ok: true });
}
```

> [!TIP]
> L'ordre d'écriture des paramètres décorés n'a aucune importance : chaque valeur est placée à son
> **index déclaré** par `buildParamArgs()` (`routerDecorators.ts#buildParamArgs`), et les trous restent
> `undefined`. Tu peux mélanger décorés et non décorés — les non décorés reçoivent `undefined`.

### Routes à schéma — l'entrée validée ET typée

Un décorateur de paramètre **affirme** un type sans le vérifier : `@Body() dto: CreateOrder` compile,
et un client peut envoyer autre chose. L'option `schema` des décorateurs de route (`@route`, `@Get`,
`@Post`…) **déclare** le contrat de l'action, partie par partie, et le framework le fait respecter
avant de l'exécuter (ADR-0018) :

```typescript
import { Post, type RouteInput } from "@nodefony/framework";
import { z } from "zod";

const postParams = z.object({ id: z.coerce.number().int().positive() });
const postBody = z.object({ title: z.string().min(3) });

@Post("/posts/{id}", { schema: { params: postParams, body: postBody } })
update(input: RouteInput<{ params: typeof postParams; body: typeof postBody }>) {
  return { id: input.params.id, title: input.body.title }; // id : number
}
```

| Partie    | Ce qu'elle valide                                                                        |
| --------- | ---------------------------------------------------------------------------------------- |
| `params`  | les variables de l'URL (`/posts/{id}`)                                                   |
| `query`   | la query — sur le pont WebSocket, celle du chemin **invoqué**                            |
| `body`    | le corps **décodé** (JSON, formulaire) — sur le pont WebSocket, celui porté par la trame |
| `headers` | les en-têtes, clés en minuscules — sur WebSocket, ceux de la poignée de main             |

**Ce que fait le framework, dans l'ordre :**

- chaque partie déclarée est un [Standard Schema](https://standardschema.dev) — Zod, Valibot,
  ArkType… ; l'interface est recopiée au cœur (`runtime/standardSchema.ts#IStandardSchemaV1`), aucune
  dépendance n'est imposée ;
- la validation a lieu **après** les gardes (`@IsGranted` : un 403 ne révèle pas le contrat) et
  **avant** l'instanciation du contrôleur : une entrée invalide ne construit rien
  (`Resolver.ts#Resolver.executeAction`) ;
- elle est **synchrone** tant que chaque validateur l'est ; un raffinement asynchrone (« ce slug
  est-il libre ? ») la fait attendre, et seulement lui (`routeSchema.ts#validateRouteInput`) ;
- les quatre parties sont jugées ensemble : un échec rend **toutes** les anomalies en une fois, dans
  une `SchemaValidationError` (`runtime/standardSchema.ts#SchemaValidationError`) — **422**, la même
  forme qu'une `ZodError`, sur HTTP (`error.fields`) comme sur le pont WebSocket (`data.fields`).
  Chaque champ porte sa partie : `{ "field": "title", "in": "body", "message": "…" }` ;
- l'action reçoit **un seul argument**, `input`, qui porte toujours les quatre parties : la
  **sortie** du schéma (coercitions, valeurs par défaut) pour une partie déclarée, la valeur brute
  pour les autres.

**La signature est vérifiée à la compilation.** Un `input` dont un champ diverge du schéma, ou un
second argument, ne compile pas (`routerDecorators.ts#IHttpMethodDecorator`). L'identité se lit par
`this.user`, pas par `@CurrentUser`.

**Exclusif des décorateurs de paramètre.** Une action à schéma reçoit un argument unique : la mêler de
`@Body`, `@Query`… est refusé **au démarrage**, par une erreur qui nomme la route
(`routerDecorators.ts#assertRouteSchemaDeclaration`, appelé par `Router.setController`). Même refus pour
une partie inconnue (`bodies`) et pour une valeur qui n'est pas un Standard Schema.

**Un gros corps en flux : `bodyStream: true`.** Il remplace `@Body({ stream: true })` : `input.body`
est alors le flux brut, jamais lu en mémoire. `bodyStream` et `schema.body` s'excluent — un flux non
lu ne se valide pas —, et `bodyStream` **exige** un schéma : sans `input`, le flux ne serait livré à
personne. La compilation refuse les deux fautes, le démarrage aussi ; une action sans schéma demande
son flux par `@Body({ stream: true })`.

```typescript
const uploadQuery = z.object({ name: z.string() });

@Post("/upload", { schema: { query: uploadQuery }, bodyStream: true })
async upload(input: RouteInput<{ query: typeof uploadQuery }, true>) {
  await pipeline(input.body, createWriteStream(`/data/${input.query.name}`));
}
```

**Des octets se valident tels quels.** La valeur passée au schéma est celle que le transport a
**décodée** — ni recopiée, ni re-sérialisée : `z.instanceof(Uint8Array)` valide un corps binaire
sans copie, ce qui tiendra quand la socket portera du binaire.

**Sans schéma, rien ne change** — et rien n'est alloué de plus : la route garde ses décorateurs de
paramètre, et le Resolver ne paie qu'une comparaison.

**Une route créée par programme** (`Router.createRoute`) n'accepte ni `schema` ni `bodyStream` : ils
se rangent sur la méthode par un décorateur, et les ignorer en silence mentirait — l'appel est refusé.

#### Et une entité ?

`nodefony create entity` génère les deux étages : la route déclare `createPostSchema` /
`updatePostSchema` (422 à la porte, avant tout appel au service), et le service les revalide dans
`beforeCreate` / `beforeUpdate` (`orm-core/nodefony/src/AbstractCrudService.ts#AbstractCrudService.create`)
pour ses autres portes — CLI, actions temps réel —, où une règle asynchrone (unicité en base) reste
possible. Le schéma d'entrée ne décrit pas la table : ni `id` ni horodatages n'y figurent, et les
champs inconnus sont **retirés** — un client qui glisserait `{ "role": "admin" }` ne s'auto-promeut pas.

#### Sans schéma, le corps n'est pas validé

`@Body()` injecte le corps **tel qu'il a été lu**. Pour un cas isolé hors route à schéma, le geste
reste le schéma en tête d'action — une `ZodError` qui remonte devient le même **422**, reconnu par sa
forme (`http/nodefony/service/error-renderer.ts#toValidationFields`) et non par `instanceof` :

```typescript
@Post("/subscribe")
subscribe(@Body() body: unknown) {
  const dto = subscribeSchema.parse(body); // lève → 422 + fields
  return { ok: true, email: dto.email };
}
```

### Réponse — statut, en-têtes, redirection

| Décorateur                | Cible   | Effet                                                                                           | Exemple                               |
| ------------------------- | ------- | ----------------------------------------------------------------------------------------------- | ------------------------------------- |
| `@HttpCode(201)`          | méthode | Fixe le statut **avant** l'exécution de l'action (`HttpCode()`, `routerDecorators.ts#HttpCode`) | `@HttpCode(204)`                      |
| `@Header("X-Foo", "bar")` | méthode | Ajoute un en-tête ; **s'empile** (plusieurs `@Header` cumulent, `routerDecorators.ts#Header`)   | `@Header("Cache-Control","no-store")` |
| `@Redirect("/url", 302)`  | méthode | Redirige **si** l'action ne renvoie rien (`Redirect()`, `routerDecorators.ts#Redirect`)         | `@Redirect("/login", 302)`            |

Les deux premiers sont appliqués par `Resolver._applyResponseMeta()` (`Resolver.ts#Resolver._applyResponseMeta`) **avant**
l'appel de l'action : ton code peut donc les écraser ensuite (`this.renderJson(data, 202)` gagne).

`@Redirect` a une subtilité utile : si l'action **retourne un objet** portant `url` (et
éventuellement `statusCode`), cet objet **prend le dessus** sur les valeurs du décorateur
(`Resolver._handleRedirect()`, `Resolver.ts#Resolver._handleRedirect`) — la cible peut donc être calculée à l'exécution :

```typescript
@Get("/go")
@Redirect("/fallback", 302)          // cible par défaut
async go(@Query("to") to?: string) {
  return to ? { url: to, statusCode: 307 } : undefined; // undefined → /fallback
}
```

> [!WARNING]
> Redirection sans statut explicite ailleurs dans le code : `Response.redirect()` vaut **302** par
> défaut (Found), jamais 301 — un 301 implicite serait mis en cache par les navigateurs, presque
> irréversiblement. Un code hors de 301/302/303/307/308 retombe aussi sur 302 : passe le code voulu
> quand il compte — `this.redirect(url, 307)` préserve la méthode.

### Sécurité — qui passe, qui décide, quelles défenses

Sept décorateurs, **tous duals** (classe ou méthode) et **tous sans logique** : ils posent une
étiquette que le `Resolver` ou le firewall consommera.

| Décorateur                                | Effet                                                                                                                                                             | Ancre                                                                   |
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `@IsGranted(attr \| attrs, { subject })`  | Exige un attribut (rôle `ROLE_*` ou règle métier). Tableau = **OU** ; empilés = **ET** ; refus → **403**                                                          | `IsGranted()` (`routerDecorators.ts#IsGranted`)                         |
| `@RequireScope(scope \| scopes)`          | Exige un scope `api:action` d'un **jeton machine** ; no-op pour une session humaine                                                                               | `RequireScope()` (`routerDecorators.ts#RequireScope`)                   |
| `@RequireAuthentication({ maxAge, amr })` | Exige une connexion **récente** (`maxAge`, s) et/ou faite avec certains moyens (`amr` RFC 8176, ex. `otp`) ; évalué après `@IsGranted` ; refus → **401** RFC 9470 | `RequireAuthentication()` (`routerDecorators.ts#RequireAuthentication`) |
| `@Anonymous()`                            | Rend l'action publique : annule l'autorisation **et** l'authentification (le « permitAll »)                                                                       | `Anonymous()` (`routerDecorators.ts#Anonymous`)                         |
| `@BypassFirewall`                         | Court-circuite le firewall (sonde de liveness, webhook signé, endpoint de login). **Sans parenthèses**                                                            | `BypassFirewall` (`routerDecorators.ts#BypassFirewall`)                 |
| `@Csp({ "frame-src": [...] })`            | Ajoute des directives CSP **à cette réponse** ; classe + méthode fusionnent additivement                                                                          | `Csp()` (`routerDecorators.ts#Csp`)                                     |
| `@CsrfProtect()`                          | Opt-**in** au jeton anti-CSRF (double-submit signé) en plus de la défense globale                                                                                 | `CsrfProtect` (`routerDecorators.ts#CsrfProtect`)                       |
| `@CsrfExempt()`                           | Opt-**out** de la défense CSRF **en gardant** l'authentification (webhook, POST cross-origin légitime)                                                            | `CsrfExempt` (`routerDecorators.ts#CsrfExempt`)                         |

#### Rôles et scopes — deux axes, un seul verdict

`@IsGranted` et `@RequireScope` écrivent dans **deux jeux de métadonnées distincts**, puis
`computeSecurityRequirement()` (`routerDecorators.ts#computeSecurityRequirement`) les fusionne en une exigence unique dont
toutes les clauses sont en **ET**. Une seule chaîne d'application côté `Resolver`, deux jurés
différents côté `security` (le voteur de rôles, le voteur de scopes).

```typescript
@controller("/api/orders")
@IsGranted("ROLE_USER") // vaut pour TOUTES les actions de la classe
class OrderController extends Controller {
  @Get("") // hérite ROLE_USER
  async list() {}

  @Post("")
  @RequireScope("orders:write") // + un scope si l'appelant est une clé API
  async create() {} // ⇒ ROLE_USER ET orders:write

  @Get("/health")
  @Anonymous() // annule la garde de classe → route publique
  async health() {}
}
```

Pourquoi deux axes plutôt qu'un : **les rôles disent qui tu es**, **les scopes disent ce qu'une clé a
le droit de faire**. Un humain connecté ne doit pas être bridé par une notion prévue pour restreindre
un jeton délégué — d'où le no-op côté session.

#### La différence entre `@Anonymous`, `@BypassFirewall` et `@CsrfExempt`

Trois façons d'ouvrir une porte, trois portées — les confondre coûte cher :

| Décorateur        | Authentification |   Autorisation   | Défense CSRF | Cas d'usage typique                   |
| ----------------- | :--------------: | :--------------: | :----------: | ------------------------------------- |
| `@Anonymous()`    |     ignorée      |     ignorée      |  conservée   | page publique d'un contrôleur protégé |
| `@BypassFirewall` |     ignorée      | (rien à évaluer) |  conservée   | sonde `/health`, endpoint de login    |
| `@CsrfExempt()`   |  **conservée**   |  **conservée**   |   ignorée    | webhook signé, API cross-origin       |

`@Anonymous()` pose en réalité **deux** marqueurs : « pas d'autorisation » et « pas de firewall »
(`routerDecorators.ts#Anonymous`) — c'est un `@BypassFirewall` doublé d'une annulation des clauses
héritées de la classe.

> [!CAUTION]
> `@BypassFirewall` s'écrit **sans parenthèses** : c'est un drapeau, pas une fabrique. Écrire
> `@BypassFirewall()` appelle la fonction avec `undefined` en cible et **n'ouvre rien** — la route
> reste gardée. Le sens du défaut est volontaire (_fail-closed_) : un oubli laisse la route fermée,
> jamais ouverte par erreur.

### Cycle de la requête — session et anti-rejeu

| Décorateur                   | Cible | Effet                                                                                                             | Ancre                             |
| ---------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------- | --------------------------------- |
| `@UseSession({ readOnly? })` | dual  | Déclare le besoin d'une session serveur ; **méthode > classe** (`UseSession()`, `routerDecorators.ts#UseSession`) | `@UseSession({ readOnly: true })` |
| `@Idempotent({ required? })` | dual  | Protège une mutation du double effet via `Idempotency-Key` (`Idempotent()`, `routerDecorators.ts#Idempotent`)     | `@Idempotent()`                   |

**`@UseSession` est la seule façon d'ouvrir une session** (avec un paramètre `@Session`, ou la reprise
d'un cookie existant). Il n'existe plus de « démarrer partout » global : une route qui ne déclare rien
ne coûte aucune lecture de stockage. Une seule option : `readOnly` (lire sans jamais persister —
zéro écriture). La régénération de l'identifiant après authentification est faite par le login
(`AuthFlow`), pas par une option du décorateur.
La forme exacte est celle de `SessionIntent` (`ISession.ts#SessionIntent.readOnly`).

**`@Idempotent` est strict par défaut** : une mutation sans `Idempotency-Key` reçoit **400**. Le mode
souple s'obtient par `@Idempotent({ required: false })` — sans effet en WebSocket, toujours strict
puisqu'une socket rejoue par nature. Les cinq verdicts, les statuts 409/422, la clé scopée par
identité et les stockages distribués sont traités dans la page dédiée →
[idempotence](../idempotence/).

### Le voisinage — décorateurs des autres modules

Ils ne viennent pas de `@nodefony/framework`, mais complètent la même DX ; on les cite pour éviter les
recherches inutiles.

| Décorateur                          | Paquet               | Rôle                                                                                                       |
| ----------------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------- |
| `@services([…])`                    | `nodefony`           | Déclare les services d'un module (`services()`, `kernelDecorator.ts#services`)                             |
| `@injectable()`                     | `nodefony`           | Rend une classe résoluble par le conteneur (`injectable()`, `kernelDecorator.ts#injectable`)               |
| `@Inject("nom")`                    | `nodefony`           | Injecte un service à une position de constructeur (`kernelDecorator.ts#Inject`)                            |
| `@RealtimeAction("méthode")`        | `@nodefony/realtime` | Expose une action JSON-RPC sur socket (`RealtimeAction()`, `realtimeDecorators.ts#RealtimeAction`)         |
| `@RealtimeChannel("canal", policy)` | `@nodefony/realtime` | Déclare un canal temps réel et sa politique (`RealtimeChannel()`, `realtimeDecorators.ts#RealtimeChannel`) |
| `@RealtimeInbound("méthode")`       | `@nodefony/realtime` | Traite un message entrant typé (`RealtimeInbound()`, `realtimeDecorators.ts#RealtimeInbound`)              |

Injection et portées → [injection-portees](../../../architecture/injection-portees/) ·
socket → [realtime](../../realtime/).

> [!NOTE]
> **`@nodefony/security` n'exporte aucun décorateur.** Toutes les annotations de sécurité
> (`@IsGranted`, `@RequireScope`, `@Anonymous`, `@Csp`, `@Csrf*`, `@BypassFirewall`) vivent **ici**,
> dans le framework, précisément pour qu'aucun cycle de dépendance ne se forme. Le moteur qui les
> applique, lui, est dans security → [firewall](../../security/firewall/) ·
> [autorisation](../../security/authorization/).

## 🔌 HTTP et WebSocket — les mêmes décorateurs

Un contrôleur ne change pas de forme selon le transport : ce sont les `requirements.methods` qui
déclarent le canal, `WEBSOCKET` étant une pseudo-méthode du type `HTTPMethod` (`Context.ts#HTTPMethod`).

```typescript
@controller("/ws/chat")
class ChatController extends Controller {
  // Handshake + chaque message arrivent dans CETTE action.
  @route("chat-echo", {
    path: "/echo",
    requirements: { methods: ["WEBSOCKET"] },
  })
  async echo(message: string | Buffer | null) {
    if (!message) return this.renderJson({ handshake: true }); // 1er passage
    return this.render(message.toString());
  }

  // DUPLEX : la même action est joignable en GET et par une frame `api.request`.
  @route("chat-rooms", {
    path: "/rooms",
    requirements: { methods: ["GET", "WEBSOCKET"] },
  })
  async rooms(@Query("limit") limit?: string) {
    return this.renderJson({ rooms: [], limit: limit ?? "25" });
  }
}
```

Trois faits à retenir :

- **Il n'existe pas de décorateur `@Ws`.** Le transport se déclare dans les `requirements` — via
  `@route`, ou via `@All("/x", { requirements: { methods: ["WEBSOCKET"] } })` si tu préfères la forme
  courte. Les fabriques `@Get`/`@Post`… **fusionnent** les méthodes déclarées avec la leur :
  `@Get("/x", { requirements: { methods: ["WEBSOCKET"] } })` sert GET **et** WebSocket
  (`routerDecorators.ts#Get`).
- **Les décorateurs de paramètre fonctionnent pareil.** Pour une invocation par socket, le corps de
  la mutation voyage dans l'ALS et **prime** sur le corps HTTP (vide dans ce cas) — c'est traité dans
  `resolveParamArg()` (`routerDecorators.ts#resolveParamArg`), et `@Query` lit la query du chemin **invoqué**,
  pas celle du handshake (`Resolver._buildParamArgs()`, `Resolver.ts#Resolver._buildParamArgs`).
- **Les gardes s'appliquent identiquement.** `@IsGranted` protège une action joignable par socket
  exactement comme une action HTTP : la décision est prise avant l'instanciation, quel que soit le
  transport.

Les décorateurs propres au temps réel (canaux, actions JSON-RPC) appartiennent à `@nodefony/realtime`
→ [socket Nodefony](../../realtime/).

## ⚙️ Options communes et règles de précédence

Quand la même chose est déclarée à deux endroits, qui gagne ? Les règles sont fixes, et elles ne sont
pas toutes identiques — c'est la source d'erreur n°1.

| Sujet | Règle | Ancre |
| --- | --- | --- |
| `@Domain` | option `host` de la route > méthode > classe | `controller()` (`routerDecorators.ts#controller`) |
| `@BypassFirewall` | **cumulatif** : `true` de la route, de la méthode ou de la classe suffit | `routerDecorators.ts#BypassFirewall` |
| `@UseSession` | méthode > classe (fusion des champs) | `resolveSessionIntent()` (`routerDecorators.ts#resolveSessionIntent`) |
| `@Idempotent` | méthode > classe | `computeIdempotent()` (`routerDecorators.ts#computeIdempotent`) |
| `@IsGranted` / `@RequireScope` | **cumul en ET** : classe **plus** méthode | `computeSecurityRequirement()` (`routerDecorators.ts#computeSecurityRequirement`) |
| `@Anonymous` | méthode → annule tout ce que la classe a posé | `routerDecorators.ts#Anonymous` |
| `@Csp` | fusion **additive** classe + méthode (sources concaténées) | `mergeCspDirectives()` (`routerDecorators.ts#mergeCspDirectives`) |
| `@CsrfProtect` / `@CsrfExempt` | OU logique : classe **ou** méthode suffit | `computeActionMeta()` (`routerDecorators.ts#computeActionMeta`) |
| `@Header` | s'empile (plusieurs en-têtes) ; même clé → dernier écrit gagne | `Header()` (`routerDecorators.ts#Header`) |
| `@HttpCode` | un seul par action (le dernier posé écrase) | `HttpCode()` (`routerDecorators.ts#HttpCode`) |

### Où placer les décorateurs de classe

TypeScript applique les décorateurs de classe **de bas en haut** : celui écrit le plus près de la
classe s'exécute en premier. Deux régimes en découlent :

- **Lus AU MONTAGE** — `@Domain`, `@BypassFirewall` : ils doivent avoir posé leur métadonnée **avant**
  que `@controller` ne construise les routes, donc **sous** `@controller`.
- **Lus PARESSEUSEMENT** (à la 1ʳᵉ requête) — `@IsGranted`, `@RequireScope`, `@Csp`, `@Csrf*`,
  `@Idempotent`, `@UseSession`, `@Scope` : l'ordre est indifférent.

Une seule règle à retenir, sûre dans tous les cas : **`@controller` en haut, le reste en dessous.**

```typescript
@controller("/admin") // ← toujours en premier
@Domain("admin.example.com")
@IsGranted("ROLE_ADMIN")
class AdminController extends Controller {
  /* … */
}
```

## 🏗️ Architecture interne — de l'import à la requête

```mermaid
sequenceDiagram
  participant TS as Fichier contrôleur
  participant R as Reflect metadata
  participant CT as @controller
  participant RTR as Router
  participant RS as Resolver

  TS->>R: @Get / @Body / @IsGranted (à l'import)
  TS->>CT: @controller("/prefix") (dernier décorateur de classe)
  CT->>R: getMetadata("routes:definitions")
  CT->>RTR: createRoute(nom, options) × N
  CT->>R: deleteMetadata (une classe = un montage)
  Note over RTR: onBoot — @controllers → setController(classe, module)
  RS->>R: 1ʳᵉ requête : computeActionMeta → RouteActionMeta figé
  RS->>RS: requêtes suivantes : lecture O(1), 0 Reflect
```

Le snapshot `RouteActionMeta` (`routerDecorators.ts#RouteActionMeta`) regroupe **tout** ce que les décorateurs
ont dit de l'action :

| Champ | Vient de | `null`/`false` quand |
| --- | --- | --- |
| `paramsMeta` | `@Param`/`@Body`/… | aucun paramètre décoré |
| `httpCode` | `@HttpCode` | absent |
| `headerEntries` | `@Header` | absent (entrées pré-dépliées une fois) |
| `redirectMeta` | `@Redirect` | absent |
| `sessionIntent` | `@UseSession` / `@Session` | la route ne veut pas de session |
| `security` | `@IsGranted` + `@RequireScope` | action non gardée (ou `@Anonymous`) |
| `cspDirectives` | `@Csp` | aucune directive déclarée |
| `csrfProtect` / `csrfExempt` | `@CsrfProtect` / `@CsrfExempt` | non déclarés |
| `idempotent` | `@Idempotent` | action non protégée |

Le `Resolver` consomme ce snapshot dans un ordre qui a du sens sécurité :
**garde d'abord, instanciation ensuite**. `security !== null` déclenche
`_enforceSecurity()` (`Resolver.ts#Resolver._enforceSecurity`) **avant** `newController()` — un `403` n'instancie pas le
contrôleur et n'exécute pas son `initialize()`. Puis viennent les arguments
(`_buildParamArgs()`, `Resolver.ts#Resolver._buildParamArgs`), les métadonnées de réponse
(`_applyResponseMeta()`, `Resolver.ts#Resolver._applyResponseMeta`), l'action, et enfin la redirection éventuelle.

Un usage cold path mérite d'être connu : `extractActionScopes()` (`routerDecorators.ts#extractActionScopes`) parcourt
les routes au démarrage pour bâtir le **catalogue des scopes déclarés** — le formulaire de création
de clés API dans Studio propose les scopes réellement utilisés par le code, jamais une liste
maintenue à part.

## ⚡ Performance & mémoire

Un décorateur non employé doit coûter **zéro**. C'est tenu par trois mécanismes vérifiables :

- **Lecture unique.** `resolveActionMeta()` (`routerDecorators.ts#resolveActionMeta`) mémorise le snapshot sur la
  route au premier passage — ensuite, plus aucun appel `Reflect.getMetadata` ni `Object.entries` par
  requête. Le même schéma vaut pour la détection du flux brut
  (`routeExpectsBodyStream()`, `routerDecorators.ts#routeExpectsBodyStream`).
- **`null` plutôt que structure vide.** Une action sans garde a `security: null` : le `Resolver` teste
  un `null` et passe — ni résolution de service, ni `await`, ni allocation (`Resolver.ts#Resolver._enforceActionSecurity`). Idem
  pour `idempotent`, `cspDirectives`, `paramsMeta`.
- **Objets gelés et partagés.** Les exigences de sécurité et d'idempotence sont créées **une fois** et
  `Object.freeze`-ées (`routerDecorators.ts#computeSecurityRequirement`, `routerDecorators.ts#computeCspDirectives`) : une seule instance pour la durée de vie
  du processus, quelle que soit la charge. Corollaire : ne les mute jamais.

Coût résiduel côté montage seulement : la reconstruction de la pile d'appels dans `route()`
(`stackTrace`, `routerDecorators.ts#route`) pour retrouver le fichier source. Elle a lieu **à l'import**, une fois par
route, jamais pendant une requête.

## 🧩 Extension — écrire son propre décorateur

Le module montre le patron à suivre : un décorateur maison **ne fait qu'écrire une métadonnée**, et
un point du pipeline la relit. Pour un simple drapeau dual (classe + méthode), le framework fournit
déjà la fabrique `booleanMarkerDecorator()` (`routerDecorators.ts#booleanMarkerDecorator`), dont `@CsrfProtect` et
`@CsrfExempt` sont les deux usages.

Le squelette d'un drapeau maison, en dehors du framework :

```typescript
import "reflect-metadata";

const AUDIT_METADATA = "app:audit";

/** `@Audited()` — marque une action à tracer. Dual : classe ou méthode. */
export function Audited() {
  return function (
    target: any,
    propertyKey?: string,
    descriptor?: PropertyDescriptor,
  ): any {
    if (propertyKey === undefined) {
      Reflect.defineMetadata(AUDIT_METADATA, true, target); // classe → constructeur
      return target;
    }
    Reflect.defineMetadata(AUDIT_METADATA, true, target, propertyKey); // méthode → prototype
    return descriptor;
  };
}
```

Deux invariants à respecter, tirés du code du module :

1. **Classe → constructeur, méthode → prototype keyé par nom.** C'est la convention de toutes les
   métadonnées du fichier (`routerDecorators.ts#IsGranted` pour `@IsGranted`) ; s'en écarter rend la
   fusion classe/méthode impossible.
2. **Aucune I/O, aucun service, aucun import lourd dans le décorateur.** Il s'exécute à l'import, hors
   de tout kernel : y résoudre un service planterait le simple fait de charger le fichier.

La lecture, elle, se fait au **cold path** (montage ou première requête), jamais à chaque requête.

## 📡 Observabilité — Studio

Le **Playground** (`/nodefony/playground`, dev uniquement) construit un formulaire par action **à
partir des décorateurs** : transports déclarés, paramètres décorés triés par index, et badges de
gardes (`@IsGranted`, scopes, `@Idempotent`, CSRF, intent de session, bypass firewall). C'est le
miroir exact de ce que cette page décrit — si un badge manque, c'est que le décorateur n'est pas là.

L'écran **Routes** (`/nodefony/routes`) et le point d'API `/nodefony/framework/api/routes` listent les routes issues de
`@controller`/`@route`, avec leur nom auto-généré et leurs `requirements`.

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

| Symptôme | Cause (dans le code) | Correction |
| --- | --- | --- |
| `404` sur une route pourtant décorée | Contrôleur jamais importé, ou absent de `@controllers([…])` | L'ajouter au tableau `@controllers` du module |
| `Action « remove » … : ce nom est RÉSERVÉ` au démarrage — ou `TS2416` au build | L'action reprend le nom d'un membre de `Controller` : la classe étend `Service`, qui expose déjà `remove(name): boolean` ([`Service.ts#Service.remove`](https://github.com/nodefony/nodefony-core/blob/main/src/nodefony/src/Service.ts)), `set`, `get`, `clean`… Le décorateur refuse le nom avant que le conflit n'atteigne le compilateur. | Renommer l'action (`destroy`, `deleteOne`…). Le nom d'une action est libre : c'est le chemin du décorateur qui fait l'URL. |
| `404` après avoir déplacé `@controller` sous `@Domain` | `@controller` monte les routes ; les décorateurs lus au montage doivent être **sous** | Remettre `@controller` en **premier** (le plus haut) |
| Le vhost de `@Domain` classe est ignoré | `@Domain` placé **au-dessus** de `@controller` → posé trop tard | Placer `@Domain` sous `@controller` |
| `@BypassFirewall` n'ouvre rien | Écrit **avec** parenthèses — c'est un drapeau, pas une fabrique | `@BypassFirewall` (sans `()`) |
| Une route de classe reste publique malgré l'option | `bypassFirewall` est **cumulatif** : le `true` de la classe l'emporte | Retirer `@BypassFirewall` de la classe et le poser action par action |
| `403` alors que le rôle est bon | Module `security` absent, ou route hors zone firewall → aucun jeton (fail-closed) | Charger `@nodefony/security` et couvrir la route par une zone |
| `@CurrentUser()` vaut `undefined` | Route hors zone firewall — l'identité n'est jamais résolue hors zone | Couvrir la route par une zone (voir [firewall](../../security/firewall/)) |
| `@Session()` toujours `null` | Aucun intent : ni `@UseSession`, ni paramètre `@Session`, ni cookie repris | Ajouter `@UseSession()` sur l'action ou la classe |
| `@Headers("X-Foo")` vaut `undefined` | Node met les en-têtes en minuscules ; la recherche est normalisée mais la clé compte | Utiliser la forme minuscule (`"x-foo"`) |
| `@Redirect` ne redirige pas | L'action a retourné une valeur — la redirection ne joue que sur `undefined`/`null` | Ne rien retourner, ou retourner `{ url, statusCode }` |
| Réponse `302` alors qu'un autre code était voulu | `Response.redirect()` vaut 302 par défaut, et un code hors 301/302/303/307/308 y retombe | Passer un code de redirection valide : `this.redirect(url, 307)` |
| Une méthode nommée `session`/`request`/`response` est refusée | Même règle : ce sont des **accesseurs** de `Controller`. Sans le garde-fou ils ne cassaient rien au build — ils masquaient l'action en silence. | Renommer l'action (aussi : `get`, `set`, `method`, `context`, `route`) |
| Deux requêtes se mélangent leurs données | Un état de requête stocké sur `this` d'un singleton (le défaut), là où les filets ne voient pas (`#privé`, objet muté) | N'utiliser que des arguments décorés, ou déclarer `@Scope("request")` |
| La route `*` avale toutes les autres | Attendu : elle est montée en dernier mais matche tout ce qui reste | Vérifier que les routes précises sont bien déclarées (elles gagnent) |

## 🧪 Tests & couverture

Quatre suites unitaires et deux bancs d'intégration couvrent la surface — les chiffres exacts vivent
dans la carte régénérée depuis vitest, jamais figés ici :

- **unit `routerDecorators`** : création de route par `@controller`, application du préfixe, routes
  multiples, effacement des métadonnées après montage, route magique `*` montée en dernier, stockage
  des métadonnées `@Param`/`@Body`/`@Query` ;
- **unit `httpMethodDecorators`** : nommage automatique `Classe::méthode`, `requirements.methods` par
  verbe, `@All` sans contrainte, `405` sur méthode non déclarée, `@HttpCode`/`@Header`
  (accumulation)/`@Redirect` et leurs combinaisons ;
- **unit `paramDecorators`** : pose des métadonnées, accumulation sur une même méthode, résolution de
  chaque source, robustesse sur contexte partiel (WS), placement positionnel des arguments ;
- **unit `securityDecorators`** : OU interne d'une clause, ET entre clauses empilées, fusion
  classe+méthode, `subject`, annulation par `@Anonymous`, axe scope, descripteur gelé,
  `@CurrentUser` depuis l'ALS ;
- **intégration** (`@nodefony/http`, serveur réel) : `decorators` (paramètres bout en bout) et
  `decorators-response` (statut et en-têtes réellement émis).

Ce qui **manque** aujourd'hui : aucun banc de charge ni test mémoire dédié à la surface décorateur —
c'est cohérent avec le fait que tout y est cold path (montage, première requête), mais un
contrôleur singleton mal utilisé se prouverait mieux sous charge (skill `nodefony-load-test`).

Couverture : `npm run coverage` dans `@nodefony/framework`.

## 🔗 Pour aller plus loin

- ⬆️ **Retour au hub** : [@nodefony/framework — vue du module](../) · [Toute la documentation](../../../)
- 🧭 **Pages sœurs** : [routing](../routing/) (comment une route est compilée et choisie) ·
  [controller](../controller/) (cycle de vie et helpers de rendu) ·
  [idempotence](../idempotence/) (`@Idempotent` en profondeur)
- 🔐 **Le moteur derrière les gardes** : [firewall](../../security/firewall/) ·
  [autorisation](../../security/authorization/) · [CSRF](../../security/csrf/)
- 🔌 **Socket et décorateurs temps réel** : [realtime](../../realtime/)
- 🏗️ **Où tout ça s'insère** : [pipeline-requete](../../../architecture/pipeline-requete/) ·
  [injection-portees](../../../architecture/injection-portees/)
