@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.
- package/LICENSE +544 -0
- package/README.md +50 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +211 -0
- package/dist/nodefony/config/config.js +61 -0
- package/dist/nodefony/config/defineModuleConfig.js +36 -0
- package/dist/nodefony/controller/AdminApiController.js +163 -0
- package/dist/nodefony/controller/ApiKeyController.js +151 -0
- package/dist/nodefony/controller/BenchController.js +132 -0
- package/dist/nodefony/controller/IssuerMetadataController.js +148 -0
- package/dist/nodefony/controller/OAuth2Controller.js +133 -0
- package/dist/nodefony/controller/ProtectedResourceMetadataController.js +221 -0
- package/dist/nodefony/controller/SessionAuthController.js +141 -0
- package/dist/nodefony/controller/TokenAuthController.js +113 -0
- package/dist/nodefony/controller/TotpController.js +129 -0
- package/dist/nodefony/controller/WebAuthnController.js +242 -0
- package/dist/nodefony/controller/oauthAuthority.js +74 -0
- package/dist/nodefony/decorators/routerDecorators.js +967 -0
- package/dist/nodefony/interfaces/IAdminBroker.js +1 -0
- package/dist/nodefony/interfaces/IController.js +1 -0
- package/dist/nodefony/interfaces/IIdempotencyStore.js +1 -0
- package/dist/nodefony/interfaces/IResolver.js +1 -0
- package/dist/nodefony/interfaces/IRoute.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/service/AdminBroker.js +106 -0
- package/dist/nodefony/service/Eta.js +68 -0
- package/dist/nodefony/service/IdempotencyStore.js +136 -0
- package/dist/nodefony/service/router.js +243 -0
- package/dist/nodefony/src/Controller.js +515 -0
- package/dist/nodefony/src/FrameworkAdminApi.js +268 -0
- package/dist/nodefony/src/KernelAdminApi.js +1243 -0
- package/dist/nodefony/src/PlaygroundAdminApi.js +97 -0
- package/dist/nodefony/src/RedisIdempotencyStore.js +254 -0
- package/dist/nodefony/src/Resolver.js +416 -0
- package/dist/nodefony/src/ResourceController.js +148 -0
- package/dist/nodefony/src/Route.js +476 -0
- package/dist/nodefony/src/SyslogAdminApi.js +466 -0
- package/dist/nodefony/src/Template.js +15 -0
- package/dist/nodefony/src/configMutation.js +186 -0
- package/dist/nodefony/src/docsReader.js +929 -0
- package/dist/nodefony/src/idempotency.js +137 -0
- package/dist/nodefony/src/idempotencyGc.js +32 -0
- package/dist/nodefony/src/idempotencyStoreRegistry.js +36 -0
- package/dist/nodefony/src/scopeCatalog.js +40 -0
- package/dist/nodefony/src/syslogFilters.js +51 -0
- package/dist/types/index.d.ts +96 -0
- package/dist/types/nodefony/config/config.d.ts +41 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
- package/dist/types/nodefony/controller/AdminApiController.d.ts +70 -0
- package/dist/types/nodefony/controller/ApiKeyController.d.ts +49 -0
- package/dist/types/nodefony/controller/BenchController.d.ts +45 -0
- package/dist/types/nodefony/controller/IssuerMetadataController.d.ts +86 -0
- package/dist/types/nodefony/controller/OAuth2Controller.d.ts +81 -0
- package/dist/types/nodefony/controller/ProtectedResourceMetadataController.d.ts +147 -0
- package/dist/types/nodefony/controller/SessionAuthController.d.ts +74 -0
- package/dist/types/nodefony/controller/TokenAuthController.d.ts +56 -0
- package/dist/types/nodefony/controller/TotpController.d.ts +42 -0
- package/dist/types/nodefony/controller/WebAuthnController.d.ts +123 -0
- package/dist/types/nodefony/controller/oauthAuthority.d.ts +54 -0
- package/dist/types/nodefony/decorators/routerDecorators.d.ts +632 -0
- package/dist/types/nodefony/interfaces/IAdminBroker.d.ts +79 -0
- package/dist/types/nodefony/interfaces/IController.d.ts +38 -0
- package/dist/types/nodefony/interfaces/IIdempotencyStore.d.ts +1 -0
- package/dist/types/nodefony/interfaces/IResolver.d.ts +25 -0
- package/dist/types/nodefony/interfaces/IRoute.d.ts +31 -0
- package/dist/types/nodefony/interfaces/index.d.ts +5 -0
- package/dist/types/nodefony/service/AdminBroker.d.ts +37 -0
- package/dist/types/nodefony/service/Eta.d.ts +25 -0
- package/dist/types/nodefony/service/IdempotencyStore.d.ts +45 -0
- package/dist/types/nodefony/service/router.d.ts +53 -0
- package/dist/types/nodefony/src/Controller.d.ts +193 -0
- package/dist/types/nodefony/src/FrameworkAdminApi.d.ts +35 -0
- package/dist/types/nodefony/src/KernelAdminApi.d.ts +71 -0
- package/dist/types/nodefony/src/PlaygroundAdminApi.d.ts +98 -0
- package/dist/types/nodefony/src/RedisIdempotencyStore.d.ts +104 -0
- package/dist/types/nodefony/src/Resolver.d.ts +165 -0
- package/dist/types/nodefony/src/ResourceController.d.ts +171 -0
- package/dist/types/nodefony/src/Route.d.ts +192 -0
- package/dist/types/nodefony/src/SyslogAdminApi.d.ts +38 -0
- package/dist/types/nodefony/src/Template.d.ts +8 -0
- package/dist/types/nodefony/src/configMutation.d.ts +109 -0
- package/dist/types/nodefony/src/docsReader.d.ts +369 -0
- package/dist/types/nodefony/src/idempotency.d.ts +96 -0
- package/dist/types/nodefony/src/idempotencyGc.d.ts +30 -0
- package/dist/types/nodefony/src/idempotencyStoreRegistry.d.ts +59 -0
- package/dist/types/nodefony/src/scopeCatalog.d.ts +26 -0
- package/dist/types/nodefony/src/syslogFilters.d.ts +52 -0
- package/docs/admin.md +451 -0
- package/docs/controller.md +645 -0
- package/docs/decorateurs.md +845 -0
- package/docs/idempotence.md +741 -0
- package/docs/index.md +151 -0
- package/docs/routing.md +648 -0
- package/docs/templates.md +380 -0
- 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;
|