@nodefony/framework 10.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +50 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +211 -0
  6. package/dist/nodefony/config/config.js +61 -0
  7. package/dist/nodefony/config/defineModuleConfig.js +36 -0
  8. package/dist/nodefony/controller/AdminApiController.js +163 -0
  9. package/dist/nodefony/controller/ApiKeyController.js +151 -0
  10. package/dist/nodefony/controller/BenchController.js +132 -0
  11. package/dist/nodefony/controller/IssuerMetadataController.js +148 -0
  12. package/dist/nodefony/controller/OAuth2Controller.js +133 -0
  13. package/dist/nodefony/controller/ProtectedResourceMetadataController.js +221 -0
  14. package/dist/nodefony/controller/SessionAuthController.js +141 -0
  15. package/dist/nodefony/controller/TokenAuthController.js +113 -0
  16. package/dist/nodefony/controller/TotpController.js +129 -0
  17. package/dist/nodefony/controller/WebAuthnController.js +242 -0
  18. package/dist/nodefony/controller/oauthAuthority.js +74 -0
  19. package/dist/nodefony/decorators/routerDecorators.js +967 -0
  20. package/dist/nodefony/interfaces/IAdminBroker.js +1 -0
  21. package/dist/nodefony/interfaces/IController.js +1 -0
  22. package/dist/nodefony/interfaces/IIdempotencyStore.js +1 -0
  23. package/dist/nodefony/interfaces/IResolver.js +1 -0
  24. package/dist/nodefony/interfaces/IRoute.js +1 -0
  25. package/dist/nodefony/interfaces/index.js +1 -0
  26. package/dist/nodefony/service/AdminBroker.js +106 -0
  27. package/dist/nodefony/service/Eta.js +68 -0
  28. package/dist/nodefony/service/IdempotencyStore.js +136 -0
  29. package/dist/nodefony/service/router.js +243 -0
  30. package/dist/nodefony/src/Controller.js +515 -0
  31. package/dist/nodefony/src/FrameworkAdminApi.js +268 -0
  32. package/dist/nodefony/src/KernelAdminApi.js +1243 -0
  33. package/dist/nodefony/src/PlaygroundAdminApi.js +97 -0
  34. package/dist/nodefony/src/RedisIdempotencyStore.js +254 -0
  35. package/dist/nodefony/src/Resolver.js +416 -0
  36. package/dist/nodefony/src/ResourceController.js +148 -0
  37. package/dist/nodefony/src/Route.js +476 -0
  38. package/dist/nodefony/src/SyslogAdminApi.js +466 -0
  39. package/dist/nodefony/src/Template.js +15 -0
  40. package/dist/nodefony/src/configMutation.js +186 -0
  41. package/dist/nodefony/src/docsReader.js +929 -0
  42. package/dist/nodefony/src/idempotency.js +137 -0
  43. package/dist/nodefony/src/idempotencyGc.js +32 -0
  44. package/dist/nodefony/src/idempotencyStoreRegistry.js +36 -0
  45. package/dist/nodefony/src/scopeCatalog.js +40 -0
  46. package/dist/nodefony/src/syslogFilters.js +51 -0
  47. package/dist/types/index.d.ts +96 -0
  48. package/dist/types/nodefony/config/config.d.ts +41 -0
  49. package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
  50. package/dist/types/nodefony/controller/AdminApiController.d.ts +70 -0
  51. package/dist/types/nodefony/controller/ApiKeyController.d.ts +49 -0
  52. package/dist/types/nodefony/controller/BenchController.d.ts +45 -0
  53. package/dist/types/nodefony/controller/IssuerMetadataController.d.ts +86 -0
  54. package/dist/types/nodefony/controller/OAuth2Controller.d.ts +81 -0
  55. package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +147 -0
  56. package/dist/types/nodefony/controller/SessionAuthController.d.ts +74 -0
  57. package/dist/types/nodefony/controller/TokenAuthController.d.ts +56 -0
  58. package/dist/types/nodefony/controller/TotpController.d.ts +42 -0
  59. package/dist/types/nodefony/controller/WebAuthnController.d.ts +123 -0
  60. package/dist/types/nodefony/controller/oauthAuthority.d.ts +54 -0
  61. package/dist/types/nodefony/decorators/routerDecorators.d.ts +632 -0
  62. package/dist/types/nodefony/interfaces/IAdminBroker.d.ts +79 -0
  63. package/dist/types/nodefony/interfaces/IController.d.ts +38 -0
  64. package/dist/types/nodefony/interfaces/IIdempotencyStore.d.ts +1 -0
  65. package/dist/types/nodefony/interfaces/IResolver.d.ts +25 -0
  66. package/dist/types/nodefony/interfaces/IRoute.d.ts +31 -0
  67. package/dist/types/nodefony/interfaces/index.d.ts +5 -0
  68. package/dist/types/nodefony/service/AdminBroker.d.ts +37 -0
  69. package/dist/types/nodefony/service/Eta.d.ts +25 -0
  70. package/dist/types/nodefony/service/IdempotencyStore.d.ts +45 -0
  71. package/dist/types/nodefony/service/router.d.ts +53 -0
  72. package/dist/types/nodefony/src/Controller.d.ts +193 -0
  73. package/dist/types/nodefony/src/FrameworkAdminApi.d.ts +35 -0
  74. package/dist/types/nodefony/src/KernelAdminApi.d.ts +71 -0
  75. package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +98 -0
  76. package/dist/types/nodefony/src/RedisIdempotencyStore.d.ts +104 -0
  77. package/dist/types/nodefony/src/Resolver.d.ts +165 -0
  78. package/dist/types/nodefony/src/ResourceController.d.ts +171 -0
  79. package/dist/types/nodefony/src/Route.d.ts +192 -0
  80. package/dist/types/nodefony/src/SyslogAdminApi.d.ts +38 -0
  81. package/dist/types/nodefony/src/Template.d.ts +8 -0
  82. package/dist/types/nodefony/src/configMutation.d.ts +109 -0
  83. package/dist/types/nodefony/src/docsReader.d.ts +369 -0
  84. package/dist/types/nodefony/src/idempotency.d.ts +96 -0
  85. package/dist/types/nodefony/src/idempotencyGc.d.ts +30 -0
  86. package/dist/types/nodefony/src/idempotencyStoreRegistry.d.ts +59 -0
  87. package/dist/types/nodefony/src/scopeCatalog.d.ts +26 -0
  88. package/dist/types/nodefony/src/syslogFilters.d.ts +52 -0
  89. package/docs/admin.md +451 -0
  90. package/docs/controller.md +645 -0
  91. package/docs/decorateurs.md +845 -0
  92. package/docs/idempotence.md +741 -0
  93. package/docs/index.md +151 -0
  94. package/docs/routing.md +648 -0
  95. package/docs/templates.md +380 -0
  96. package/package.json +83 -0
@@ -0,0 +1,98 @@
1
+ import type { IAdminEndpoint } from "nodefony";
2
+ /**
3
+ * Endpoints « Playground » du data plane framework — la source de données de la
4
+ * console vivante des controllers dans Studio (`/nodefony/playground`).
5
+ *
6
+ * Sérialise ce que le Router et les décorateurs savent déjà (routes, transports,
7
+ * paramètres décorés, gardes) : tout est en Reflect/mémoire, il n'y a qu'à
8
+ * l'exposer en JSON. La page Studio générique construit ses formulaires depuis
9
+ * ces métadonnées — AUCUN code généré dans l'app du user, rétroactif sur tout
10
+ * controller (même écrit à la main).
11
+ *
12
+ * **Dev-only par montage** : `createFrameworkAdminApi` n'inclut ces endpoints
13
+ * que si l'app tourne en développement (le playground EXÉCUTE des mutations
14
+ * depuis le navigateur). Hors dev le endpoint n'existe pas (404) — la page
15
+ * Studio affiche « dev uniquement » (fail-loud, pas de dégradation silencieuse).
16
+ *
17
+ * Cold path (introspection à la demande) : le coût de sérialisation par appel
18
+ * est assumé, aucune structure n'est retenue entre les appels.
19
+ */
20
+ /** Un paramètre décoré d'une action (`@Param`/`@Body`/`@Query`…), JSON-safe. */
21
+ export interface PlaygroundParam {
22
+ /** Source d'injection (`param`, `body`, `query`, `headers`, `cookie`, `user`…). */
23
+ source: string;
24
+ /** Clé ciblée (`@Query("q")` → `"q"`) — `null` = l'objet complet. */
25
+ key: string | null;
26
+ /** Position dans la signature de l'action. */
27
+ index: number;
28
+ /** `@Body({ stream: true })` — flux brut, non rejouable par formulaire. */
29
+ stream: boolean;
30
+ }
31
+ /** Les gardes déclaratives d'une action, prêtes à afficher (badges Studio). */
32
+ export interface PlaygroundGuards {
33
+ /** Clauses `@IsGranted`/`@RequireScope` fusionnées (AND) — `null` = non gardée. */
34
+ security: {
35
+ clauses: {
36
+ anyOf: string[];
37
+ subjectParam: string | null;
38
+ }[];
39
+ } | null;
40
+ /** Scopes `api:action` déclarés (`@RequireScope`), dédupliqués. */
41
+ scopes: string[];
42
+ /** `@Idempotent` — `required:true` = clé obligatoire sur mutation. */
43
+ idempotent: {
44
+ required: boolean;
45
+ } | null;
46
+ csrfProtect: boolean;
47
+ csrfExempt: boolean;
48
+ /** Intent `@UseSession`/`@Session` — `null` si la route n'en déclare pas. */
49
+ session: unknown;
50
+ /** Route hors firewall (mécanisme d'auth lui-même). */
51
+ bypassFirewall: boolean;
52
+ }
53
+ /** Une action invocable depuis le playground (1 route du Router). */
54
+ export interface PlaygroundAction {
55
+ /** Nom de route (unique) — clé de deep-link et de rejeu. */
56
+ route: string;
57
+ path: string | null;
58
+ /** Transports déclarés (`["POST","WEBSOCKET"]`…) — majuscules. */
59
+ methods: string[];
60
+ /** Vrai si l'action déclare AUSSI le transport WEBSOCKET (pont `api.request`). */
61
+ duplex: boolean;
62
+ /** Nom de la méthode de classe (action). */
63
+ action: string | null;
64
+ /** Variables de path (`{id}` → `["id"]`), ordre de capture. */
65
+ variables: string[];
66
+ /** Défauts de variables (hors clé interne `controller`). */
67
+ defaults: Record<string, unknown>;
68
+ params: PlaygroundParam[];
69
+ guards: PlaygroundGuards;
70
+ }
71
+ /** Un controller et ses actions, groupés pour la navigation Studio. */
72
+ export interface PlaygroundController {
73
+ /** Nom de classe du controller. */
74
+ name: string;
75
+ /** Module propriétaire (`null` si non rattaché). */
76
+ module: string | null;
77
+ actions: PlaygroundAction[];
78
+ }
79
+ /**
80
+ * Construit le snapshot playground : toutes les routes à controller, groupées
81
+ * par classe de controller, triées (module puis nom, actions par path).
82
+ *
83
+ * Exclut les routes du **pont admin** (`AdminApiController.dispatch`) : le data
84
+ * plane a déjà son catalogue (`GET /nodefony/framework/api/admin`) et ses ~50
85
+ * routes techniques noieraient les controllers applicatifs.
86
+ *
87
+ * @returns la liste des controllers jouables, prête pour la page Studio.
88
+ */
89
+ export declare function buildPlaygroundSnapshot(): {
90
+ controllers: PlaygroundController[];
91
+ };
92
+ /**
93
+ * Endpoints playground à greffer au producteur `framework` (dev uniquement —
94
+ * cf gating dans `createFrameworkAdminApi`).
95
+ *
96
+ * @returns `GET /nodefony/framework/api/playground/routes`.
97
+ */
98
+ export declare function createPlaygroundEndpoints(): IAdminEndpoint[];
@@ -0,0 +1,104 @@
1
+ import type { IIdempotencyKeyEntry, IIdempotencyListQuery, IIdempotencyStore, IdempotencyOutcome, IdempotentResponse, IPage } from "nodefony";
2
+ /**
3
+ * Sous-ensemble structural du client `redis` v6 utilisé par le store — permet de
4
+ * tester contre un double déterministe sans serveur (le vrai `RedisClientType`
5
+ * satisfait cette forme par ses méthodes camelCase v6). `set` renvoie `"OK"` si
6
+ * la valeur est posée, `null` si `NX` l'a empêchée (clé déjà présente).
7
+ *
8
+ * Le store reste **structurel** (pas d'import de `@nodefony/redis`) : la fabrique
9
+ * (`framework/index.ts`) résout le service `redis` par NOM dans le container et
10
+ * passe son client `main` — exactement comme `RedisBackplane` (`@nodefony/realtime`)
11
+ * consomme `getClient("publish"/"subscribe")` sans dépendance directe.
12
+ */
13
+ export interface RedisIdempotencyClientLike {
14
+ set(key: string, value: string, options?: {
15
+ NX?: boolean;
16
+ PX?: number;
17
+ }): Promise<string | null>;
18
+ get(key: string): Promise<string | null>;
19
+ del(key: string): Promise<number>;
20
+ /** Parcours incrémental du keyspace (introspection admin — jamais `KEYS`). */
21
+ scan(cursor: string, options?: {
22
+ MATCH?: string;
23
+ COUNT?: number;
24
+ }): Promise<{
25
+ cursor: string | number;
26
+ keys: string[];
27
+ }>;
28
+ /** TTL résiduel en millisecondes (`-1` = sans expiration, `-2` = absente). */
29
+ pTTL(key: string): Promise<number>;
30
+ }
31
+ /**
32
+ * Store d'idempotence **Redis** (node-redis v6) — implémentation distribuée
33
+ * d'{@link IIdempotencyStore} pour le cluster (dédup des mutations rejouées
34
+ * PARTAGÉE cross-pod, là où l'impl mémoire par défaut reste affine à un pod).
35
+ *
36
+ * **Pourquoi Redis est le bon backing** (modèle Stripe `Idempotency-Key`) :
37
+ * - `SET key … NX PX` = **réservation atomique côté serveur** → le `409`
38
+ * in-flight marche VRAIMENT entre pods (deux requêtes concurrentes sur deux
39
+ * pods : un seul `SET NX` gagne, l'autre voit l'entrée → conflit) ;
40
+ * - **TTL natif** (`PX`) sur le bail in-flight ET la réponse mémorisée → `gc()`
41
+ * superflu (zéro balayage applicatif, ≠ Drizzle).
42
+ *
43
+ * **Placement** : vit dans `@nodefony/framework` (le consommateur du contrat),
44
+ * PAS dans `@nodefony/redis` — calqué sur `RedisBackplane` (`@nodefony/realtime`)
45
+ * qui possède son adaptateur Redis et résout le service `redis` par nom (couplage
46
+ * structurel, 0 dépendance directe → 0 cycle). Le contrat `IIdempotencyStore`
47
+ * vit au CORE (`nodefony`), consommé en `import type`.
48
+ *
49
+ * **Modèle de clés** (préfixe `nf:idem`, cloisonné par application) : `<prefix>:<key>` = string JSON de
50
+ * l'{@link Entry}. La `<key>` est DÉJÀ scopée à l'identité par l'appelant
51
+ * (`evaluateIdempotency` compose `[identity, clientKey]`) → anti-IDOR garanti en
52
+ * amont ; le store reste agnostique au scope.
53
+ *
54
+ * **Empreinte préservée à la complétion** : `complete()` ne reçoit pas le
55
+ * fingerprint → il **relit** l'entrée *in-flight* pour reporter son `f` dans
56
+ * l'entrée *done*. Sans ça, un rejeu de la clé avec un AUTRE payload après
57
+ * complétion ne serait pas détecté (`mismatch` 422 perdu, draft §2.7).
58
+ *
59
+ * **Dégradation gracieuse** : si la connexion `main` n'est pas (ou plus) ouverte
60
+ * (boot/shutdown), `begin` renvoie `fresh` (la mutation s'exécute, **sans**
61
+ * dédup) et `complete`/`abort` sont des no-op — l'idempotence est temporairement
62
+ * inactive plutôt que de bloquer la mutation (fail-soft sur la dispo, comme le
63
+ * session/token store Redis). Trade-off assumé : un rejeu pendant une coupure
64
+ * Redis peut ré-exécuter (le client rejoue alors sa clé au rétablissement).
65
+ */
66
+ export declare class RedisIdempotencyStore implements IIdempotencyStore {
67
+ #private;
68
+ /**
69
+ * @param resolveClient - résolveur **lazy** du client Redis (l'ordre de boot
70
+ * n'est pas garanti à la construction ; `null` = connexion indisponible).
71
+ * @param leaseMs - bail d'une entrée *in-flight* (ms).
72
+ * @param ttlMs - rétention d'une réponse mémorisée (ms).
73
+ */
74
+ constructor(resolveClient: () => RedisIdempotencyClientLike | null, leaseMs?: number, ttlMs?: number, resolvePrefix?: () => string);
75
+ /**
76
+ * Approximation **per-pod, best-effort** : compteur local des réservations
77
+ * faites par CE pod, non décrémenté si le bail expire sans `complete`/`abort`,
78
+ * et désaligné cross-pod (un `begin` sur un pod, un `complete` sur un autre).
79
+ * La vérité cluster passe par `redis-cli` (`SCAN nf:idem:*`/`DBSIZE`), jamais
80
+ * ce getter (un `SCAN` à chaque lecture serait cher). Borné à ≥ 0.
81
+ */
82
+ get size(): number;
83
+ /**
84
+ * {@inheritDoc IIdempotencyStore.listPage}
85
+ *
86
+ * **Curseur SCAN** : au plus UN passage par appel (cold-path admin). Capacité
87
+ * réduite ASSUMÉE — pas de `total` (compter exigerait un SCAN complet du
88
+ * keyspace, précisément ce qu'on refuse), pas d'ordre global, et la page peut
89
+ * compter moins que `limit` (le filtre s'applique au batch scanné). Le client
90
+ * boucle tant que `hasNext` en repassant `nextCursor`.
91
+ *
92
+ * ⚠️ **`COUNT` n'est PAS un plafond** mais un indice d'effort : Redis peut
93
+ * rendre plus de clés que demandé (petit keyspace en listpack → tout arrive
94
+ * d'un coup). Sans précaution la page dépasserait `limit` et violerait
95
+ * `IPage`. D'où le **curseur composite** `"<consommé>:<curseurRedis>"` : on ne
96
+ * rend que `limit` éléments et on mémorise combien de clés du batch ont été
97
+ * consommées ; la page suivante rejoue le MÊME `SCAN` et reprend là. Bug
98
+ * réel, invisible contre un double — trouvé sur serveur Redis réel.
99
+ */
100
+ listPage(query: IIdempotencyListQuery): Promise<IPage<IIdempotencyKeyEntry>>;
101
+ begin(key: string, fingerprint: string): Promise<IdempotencyOutcome>;
102
+ complete(key: string, response: IdempotentResponse): Promise<void>;
103
+ abort(key: string): Promise<void>;
104
+ }
@@ -0,0 +1,165 @@
1
+ import { Injector } from "nodefony";
2
+ import type { IResolver } from "../interfaces/index.js";
3
+ import { HttpError, ContextType } from "@nodefony/http";
4
+ import Route, { ControllerConstructor } from "./Route.js";
5
+ import Controller from "./Controller.js";
6
+ import { type RouteActionMeta, type RedirectMeta } from "../decorators/routerDecorators.js";
7
+ /**
8
+ * Interface-marqueur du hook **per-request** d'un {@link Controller} : `initialize`
9
+ * est appelé par le {@link Resolver} à CHAQUE requête, avant l'action (hot path —
10
+ * jamais gardé/borné, contrairement au boot des services). Distinct du hook de boot
11
+ * `ServiceWithInit` (singleton, 1× au démarrage).
12
+ *
13
+ * @remarks Signature alignée sur l'appel réel `controller.initialize()` (sans arg) ;
14
+ * retour `Promise<this>` — le controller renvoie son instance.
15
+ */
16
+ export interface ControllerWithInitialize {
17
+ initialize(): Promise<this>;
18
+ }
19
+ /**
20
+ * Résout une route vers son couple controller/action et exécute l'action —
21
+ * UN Resolver est alloué par requête HTTP (et par connexion WS, réutilisé
22
+ * par message). **POJO volontaire** (V3.1) : n'étend PAS `Service` — le
23
+ * plumbing Service (Map de listeners trackés, spread d'options, lookups
24
+ * kernel/syslog) coûtait par requête sans aucun consommateur (jamais écouté,
25
+ * jamais loggé, jamais dans le container). Le DI per-request passe par
26
+ * `context.container` (le cache `"controller"` y survit au Resolver : un
27
+ * forward ou un 2ᵉ Resolver WS sur la MÊME connexion retrouve l'instance).
28
+ */
29
+ declare class Resolver implements IResolver {
30
+ injector?: Injector | null;
31
+ controller: ControllerConstructor | null;
32
+ actionName?: string;
33
+ action?: (...args: unknown[]) => unknown;
34
+ context: ContextType;
35
+ route: Route | null;
36
+ resolve: boolean;
37
+ variables: unknown[];
38
+ exception?: HttpError | Error | null;
39
+ acceptedProtocol: string | null;
40
+ bypassFirewall: boolean;
41
+ /**
42
+ * Query d'une invocation **par message** (pont WS-RPC `api.request`) — pendant
43
+ * de `cleanPathOverride` pour le `?…` du path invoqué. Le contexte WS étant
44
+ * PARTAGÉ par la connexion (sa `queryGet` = celle du handshake), la query
45
+ * per-invocation vit ici (le Resolver est per-invocation → zéro bleed entre
46
+ * requêtes concurrentes d'une même socket). `null` (hot path HTTP) = ignoré.
47
+ * Consommé par `@Query` (`_buildParamArgs`) et copié sur le controller
48
+ * per-request (`executeAction`).
49
+ */
50
+ queryOverride: Record<string, unknown> | null;
51
+ /**
52
+ * Méthode HTTP **logique** d'une invocation par le pont WS-RPC `api.request`
53
+ * quand c'est une MUTATION (POST/PUT/PATCH/DELETE). Posée par
54
+ * `Router.resolve(ctx, cleanPath, methodOverride)` → consommée par `match()`
55
+ * pour désambiguïser, sur le transport WEBSOCKET unique, la route logique
56
+ * visée (cf `Route.matchRequirements`). `null` (GET/HTTP) = match historique
57
+ * sur `context.method`.
58
+ */
59
+ methodOverride: string | null;
60
+ constructor(context: ContextType);
61
+ match(route: Route, context: ContextType, cleanPath?: string): ((string | null)[] & Record<string, unknown>) | null | undefined;
62
+ /**
63
+ * Snapshot per-requête des variables de route matchées (`{name}` → valeur,
64
+ * + wildcard `*` éventuel). Construit depuis les valeurs de CETTE requête
65
+ * (`this.variables`, posées par `match()`) zippées avec les noms de
66
+ * `route.variables`. Remplace l'ancien `Route.variablesMap` qui vivait sur
67
+ * l'instance `Route` partagée (statique) → écrasé par toute requête/connexion
68
+ * concurrente sur la même route (bleed inter-requêtes). Lu par
69
+ * `Context.setMetaData()` pour exposer `msg.nodefony.route.variablesMap`.
70
+ */
71
+ getMatchedParams(): Record<string, unknown>;
72
+ parsePathernController(name: string): void;
73
+ getAction(name: string): ((...args: unknown[]) => unknown) | null;
74
+ newController(context?: ContextType): Promise<Controller>;
75
+ /**
76
+ * Instancie la classe controller résolue (DI) + hooks de création : `module`
77
+ * (constante de classe, shadow d'instance posé 1× ici — plus de write par
78
+ * requête dans `executeAction`) puis `initialize()`. Pour un singleton,
79
+ * `initialize()` n'est donc appelé qu'UNE fois, à la création (sémantique
80
+ * boot) — le per-request y lit l'ALS s'il a besoin de la requête.
81
+ */
82
+ private _createController;
83
+ /**
84
+ * Exécute l'action résolue et retourne sa **valeur brute**, SANS la rendre sur
85
+ * le transport (pas de `returnController`/`send`). Découple « exécuter → valeur »
86
+ * de « rendre la valeur » : un appelant multi-transport (WS-RPC `invoke`, futur
87
+ * GraphQL) réutilise la MÊME action puis emballe le résultat à sa façon
88
+ * (`{ id, result }`, champ GraphQL…). Le pipeline HTTP/WS normal passe par
89
+ * {@link callController} (= `executeAction` + rendu).
90
+ *
91
+ * @param data - args supplémentaires (message WS brut legacy) concaténés aux variables de route.
92
+ * @param reload - force `newController()` (le container peut déjà porter un AUTRE controller).
93
+ * @returns la valeur retournée par l'action + son `RedirectMeta` éventuel.
94
+ */
95
+ executeAction(data?: unknown[], reload?: boolean, metaArg?: RouteActionMeta): Promise<{
96
+ result: unknown;
97
+ redirectMeta: RedirectMeta | undefined;
98
+ }>;
99
+ callController(data?: unknown[], reload?: boolean): Promise<unknown>;
100
+ /**
101
+ * Exécute l'action AVEC la porte d'idempotence mais SANS rendu
102
+ * (`returnController`) — le chemin du **pont `api.request`** (WS) : la valeur
103
+ * nue est enveloppée `{id, result}` par le peer, jamais rendue sur le
104
+ * transport. La méthode HTTP logique d'une mutation du pont voyage dans
105
+ * {@link methodOverride} ; sans porte ici, un rejeu `socket.mutate` (socket
106
+ * qui reconnecte) ré-exécuterait la mutation (doublon — vécu au banc duplex).
107
+ *
108
+ * @returns `{ result }` — la valeur retournée par l'action (ou la réponse
109
+ * mémorisée rejouée pour une clé d'idempotence déjà servie).
110
+ */
111
+ executeActionGuarded(data?: unknown[], reload?: boolean): Promise<{
112
+ result: unknown;
113
+ }>;
114
+ /**
115
+ * Applique la porte d'idempotence d'une action `@Idempotent` (mutations), via le
116
+ * helper partagé `idempotency.ts` (la MÊME sémantique que le data plane admin) et
117
+ * le service `idempotencyStore`. Conforme `draft-ietf-httpapi-idempotency-key-header`.
118
+ *
119
+ * Cycle (anti double-effet) : `evaluateIdempotency` rend un verdict neutre →
120
+ * - `reject` → `nodefonyError` (400 clé requise / 409 concurrent / 422 mismatch) ;
121
+ * - `replay` → réponse mémorisée rejouée SANS ré-exécuter l'action ;
122
+ * - `execute` → exécution directe (mode souple sans clé / store absent) ;
123
+ * - `guarded` → exécuter, puis `complete()` (succès, réponse rejouable) ou
124
+ * `abort()` (échec/403 — la clé reste réessayable, un échec ne se mémorise pas).
125
+ *
126
+ * No-op sur les méthodes sûres (GET…). La réponse mémorisée est
127
+ * le **résultat retourné** par l'action (`return data`) + son statut : une action
128
+ * qui pilote la response manuellement (`this.render`/stream) n'est pas rejouée
129
+ * fidèlement (le double-effet reste évité, mais le corps rejoué est vide).
130
+ *
131
+ * Retourne la forme BRUTE `{ result, redirectMeta }` (comme `executeAction`) :
132
+ * le rendu appartient à l'appelant — `callController` rend (`_handleRedirect`),
133
+ * le pont (`executeActionGuarded`) enveloppe la valeur nue.
134
+ */
135
+ private _callWithIdempotency;
136
+ /**
137
+ * Évalue l'exigence d'autorisation (`@IsGranted`) d'une action via le service
138
+ * `authorization` (par nom, 0 import security). Clauses en **AND**, attributs
139
+ * d'une clause en **OR**. Refus (ou moteur/identité absents) → 403 (Zero Trust,
140
+ * fail-closed). Cold path : n'est appelé que sur une route gardée.
141
+ *
142
+ * @throws nodefonyError 403 si l'accès est refusé.
143
+ */
144
+ private _enforceSecurity;
145
+ /**
146
+ * Résout un paramètre de route NOMMÉ (`@IsGranted(..., { subject: "id" })`) vers
147
+ * sa valeur, depuis `route.variables` (noms) + `this.variables` (valeurs déjà
148
+ * parsées). 0 alloc (indexOf + accès tableau).
149
+ */
150
+ private _resolveSubject;
151
+ private _buildParamArgs;
152
+ /**
153
+ * Applique `@HttpCode` + `@Header` depuis le snapshot figé de la route
154
+ * (P5) — plus aucune lecture `Reflect` ni `Object.entries` par requête.
155
+ * V4.3 : cible la response du CONTEXT (per-request : identique à
156
+ * `controller.response` ; singleton : la seule source correcte — l'instance
157
+ * partagée ne porte aucune response).
158
+ */
159
+ private _applyResponseMeta;
160
+ private _handleRedirect;
161
+ returnController(result: unknown): Promise<unknown>;
162
+ /** Statut courant de la réponse (0 si le transport n'en porte pas). */
163
+ private getResponseStatus;
164
+ }
165
+ export default Resolver;
@@ -0,0 +1,171 @@
1
+ import Controller from "./Controller.js";
2
+ import type { ControllerScope } from "./Controller.js";
3
+ import type { ContextType } from "@nodefony/http";
4
+ import type { IPage, IPageQuery } from "nodefony";
5
+ /**
6
+ * Contrat structurel du service de ressource consommé par un
7
+ * {@link ResourceController} — aligné sur `AbstractCrudService`
8
+ * (`@nodefony/orm-core`) SANS en dépendre : le framework ne connaît pas
9
+ * l'ORM, n'importe quel objet de cette forme convient (service DI, objet
10
+ * en mémoire, façade distante…). `create`/`updateOne`/`delete` sont
11
+ * optionnels : une ressource read-only n'expose que la lecture.
12
+ */
13
+ /**
14
+ * Options de lecture d'une liste — **pagination avant tout**.
15
+ *
16
+ * Elles font partie du contrat parce qu'une ressource sans borne est une fuite qui
17
+ * attend son heure : le jour où la table grossit, `find()` charge tout en mémoire.
18
+ * Miroir structurel de `RepositoryReadOptions` (`@nodefony/orm-core`), sans dépendre
19
+ * de l'ORM.
20
+ */
21
+ export interface IResourceReadOptions {
22
+ /** Nombre maximum d'enregistrements rendus. */
23
+ limit?: number;
24
+ /** Enregistrements sautés (⚠️ se dégrade sur les grandes tables — curseur à venir). */
25
+ offset?: number;
26
+ /**
27
+ * Tri, sous la forme `[[champ, sens], …]` — même écriture que `IPageQuery.order`
28
+ * (core) et que `RepositoryReadOptions` (orm-core).
29
+ *
30
+ * Typé plutôt que libre : une porte qui doit deviner la forme attendue finit
31
+ * par la caster, et le tri se perd en silence — la pagination devient alors
32
+ * fausse par intermittence, ce qui est pire qu'absente.
33
+ */
34
+ order?: Array<[string, "ASC" | "DESC"]>;
35
+ /** Associations à charger avec l'enregistrement. */
36
+ relations?: string[];
37
+ }
38
+ /**
39
+ * Requête de page telle qu'une porte la passe au service — `IPageQuery` du core,
40
+ * plus les critères que la sous-classe a EXPLICITEMENT décidé d'exposer.
41
+ *
42
+ * Miroir structurel de `PageQuery<T>` (orm-core) sans dépendre de l'ORM : le
43
+ * framework ne connaît pas `Criteria<T>`.
44
+ */
45
+ export interface IResourcePageQuery extends IPageQuery {
46
+ /** Critères de filtrage — jamais dérivés de la query string automatiquement. */
47
+ criteria?: Record<string, unknown>;
48
+ }
49
+ export interface IResourceService<T = unknown> {
50
+ find(criteria?: Record<string, unknown>, options?: IResourceReadOptions): Promise<T[]> | T[];
51
+ /**
52
+ * Lit par identifiant. `options.relations` charge les associations en une fois
53
+ * (`AbstractCrudService` le transmet au repository, qui sait les résoudre).
54
+ */
55
+ findById(id: string, options?: IResourceReadOptions): Promise<T | null> | T | null;
56
+ /**
57
+ * Rend une PAGE plutôt qu'un tableau nu — `AbstractCrudService` l'hérite déjà
58
+ * (`findPage` → `paginate`).
59
+ *
60
+ * Optionnel : une ressource read-only ou une façade en mémoire n'a pas à
61
+ * l'implémenter, `listPageResource` sait alors reconstituer la page à partir
62
+ * de `find`.
63
+ */
64
+ findPage?(page: IResourcePageQuery): Promise<IPage<T>> | IPage<T>;
65
+ create?(data: Partial<T>): Promise<T> | T;
66
+ updateOne?(criteria: Record<string, unknown>, data: Partial<T>): Promise<T | null> | T | null;
67
+ delete?(criteria: Record<string, unknown>): Promise<number> | number;
68
+ }
69
+ /**
70
+ * Controller de ressource **souverain** (V4.2 — POC API souveraine, Phase 2) :
71
+ * la logique métier est écrite UNE fois dans le service de ressource, les
72
+ * actions de la sous-classe ne sont que des portes (REST, WS-RPC `invoke`,
73
+ * GraphQL à venir) qui retournent la **valeur brute** — `returnController`
74
+ * l'auto-JSON en REST, le pont WS l'enveloppe (`{id,result}`), sans réécrire
75
+ * l'action par transport.
76
+ *
77
+ * **Stateless par construction** :
78
+ * - `static scope = "singleton"` : UNE instance partagée (cache Router, V4.3).
79
+ * L'état per-request n'existe PAS sur `this` — il arrive par les arguments
80
+ * décorés (`@Param`/`@Body`/`@Query`) et par les helpers hérités qui
81
+ * retrouvent la requête courante via l'ALS (V4.1).
82
+ * - le seul champ est `resource`, posé 1× au constructor (état de BOOT,
83
+ * immuable ensuite — sûr en concurrence).
84
+ * - règle absolue pour les sous-classes : **jamais `this.x = …` par requête**
85
+ * (data race silencieuse). Une sous-classe qui a besoin d'état per-request
86
+ * sur `this` doit rétrograder : `static scope = "request"`.
87
+ *
88
+ * Sécurité : aucun critère de requête n'est passé AUTOMATIQUEMENT au service
89
+ * (pas de `find(this.queryGet)` implicite) — exposer un filtrage est une
90
+ * décision EXPLICITE de la sous-classe (deny-by-default ; le scope de
91
+ * sécurité des données se branche au niveau service/criteria, P6).
92
+ *
93
+ * @example
94
+ * ```ts
95
+ * \@controller("/api/books")
96
+ * class BookController extends ResourceController<Book> {
97
+ * constructor(context: Context) {
98
+ * super("BookController", context, bookService);
99
+ * }
100
+ * \@route("books-list", { path: "", requirements: { methods: ["GET", "WEBSOCKET"] } })
101
+ * list() {
102
+ * return this.listResource();
103
+ * }
104
+ * \@route("books-get", { path: "/{id}", requirements: { methods: ["GET", "WEBSOCKET"] } })
105
+ * detail(\@Param("id") id: string) {
106
+ * return this.getResource(id);
107
+ * }
108
+ * }
109
+ * ```
110
+ */
111
+ declare class ResourceController<T = unknown> extends Controller {
112
+ /**
113
+ * Singleton PAR DÉFAUT : la classe est conçue stateless — premier client
114
+ * du scope V4.3. Une sous-classe peut rétrograder (`static scope =
115
+ * "request"`) si elle doit porter de l'état per-request sur `this`.
116
+ */
117
+ static scope: ControllerScope;
118
+ /**
119
+ * Service de ressource — état de BOOT (posé 1× ici, jamais réassigné).
120
+ * `protected` : les actions de la sous-classe y accèdent, les portes non.
121
+ */
122
+ protected resource: IResourceService<T> | null;
123
+ constructor(name: string, context: ContextType, resource?: IResourceService<T>);
124
+ /**
125
+ * Service de ressource garanti — 500 explicite si la sous-classe ne l'a
126
+ * pas fourni (erreur de câblage, pas une erreur client).
127
+ */
128
+ protected requireResource(): IResourceService<T>;
129
+ /**
130
+ * Liste la ressource. `criteria` est EXPLICITE (jamais dérivé de la query
131
+ * string automatiquement — deny-by-default).
132
+ */
133
+ protected listResource(criteria?: Record<string, unknown>, options?: IResourceReadOptions): Promise<T[]>;
134
+ /**
135
+ * Liste la ressource en rendant une **page** (`{ items, hasNext, total? }`)
136
+ * plutôt qu'un tableau nu.
137
+ *
138
+ * Pourquoi une page et pas un tableau : un tableau ne dit pas s'il en reste.
139
+ * Le client qui reçoit 25 lignes ne peut pas distinguer « c'est tout » de
140
+ * « demande la suite » — il redemande indéfiniment, ou s'arrête trop tôt.
141
+ *
142
+ * **Mode offset imposé** (`assertPageQuery`) : un client qui enverrait un
143
+ * `cursor` recevrait sinon la page 1 à chaque appel, en boucle et sans erreur.
144
+ *
145
+ * Si le service n'expose pas `findPage`, la page est reconstituée à partir de
146
+ * `find` en chargeant `limit + 1` lignes (même technique que `paginate`) : le
147
+ * `hasNext` reste exact, seul `total` manque — et son absence est lisible dans
148
+ * la réponse, le contrat `IPage` le donnant pour optionnel.
149
+ *
150
+ * @param page - bornes, tri et critères de la page demandée.
151
+ * @returns la page (`items` borné à `limit`).
152
+ * @throws PaginationModeError si la requête mélange offset et curseur.
153
+ */
154
+ protected listPageResource(page: IResourcePageQuery): Promise<IPage<T>>;
155
+ /**
156
+ * Lit une entité par id — `null` si absente (la porte décide du 404).
157
+ *
158
+ * `options.relations` charge les associations dans la foulée. La porte doit
159
+ * n'y laisser passer que des relations qu'elle a DÉCLARÉES : un `include`
160
+ * libre laisse le client nommer n'importe quelle association, donc lire des
161
+ * données qu'aucune route ne lui ouvre.
162
+ */
163
+ protected getResource(id: string, options?: IResourceReadOptions): Promise<T | null>;
164
+ /** Crée une entité — 501 si la ressource est read-only. */
165
+ protected createResource(data: Partial<T>): Promise<T>;
166
+ /** Met à jour une entité ciblée par critères — 501 si non supporté. */
167
+ protected updateResource(criteria: Record<string, unknown>, data: Partial<T>): Promise<T | null>;
168
+ /** Supprime par critères (nombre d'entités touchées) — 501 si non supporté. */
169
+ protected removeResource(criteria: Record<string, unknown>): Promise<number>;
170
+ }
171
+ export default ResourceController;