@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,97 @@
1
+ import { extractActionScopes, resolveActionMeta } from "../decorators/routerDecorators.js";
2
+ import router_default from "../service/router.js";
3
+ import AdminApiController from "../controller/AdminApiController.js";
4
+ //#region nodefony/src/PlaygroundAdminApi.ts
5
+ /** Normalise `requirements.methods` (string | string[]) → tableau majuscule. */
6
+ function methodsOf(route) {
7
+ const m = route.requirements?.methods ?? route.method;
8
+ if (Array.isArray(m)) return m.map((x) => String(x).toUpperCase());
9
+ if (typeof m === "string") return m.split(",").map((s) => s.trim().toUpperCase()).filter(Boolean);
10
+ return ["ANY"];
11
+ }
12
+ /** Défauts de la route sans la clé interne `controller` (jamais un input user). */
13
+ function publicDefaults(route) {
14
+ const out = {};
15
+ for (const [k, v] of Object.entries(route.defaults)) if (k !== "controller") out[k] = v;
16
+ return out;
17
+ }
18
+ /** Sérialise une route en action playground (métadonnées figées, JSON-safe). */
19
+ function serializeAction(route) {
20
+ const meta = resolveActionMeta(route);
21
+ const methods = methodsOf(route);
22
+ const scopes = route.controller && route.classMethod ? extractActionScopes(route.controller, route.classMethod) : [];
23
+ return {
24
+ route: route.name,
25
+ path: route.path ?? null,
26
+ methods,
27
+ duplex: methods.includes("WEBSOCKET"),
28
+ action: route.classMethod ?? null,
29
+ variables: [...route.variables],
30
+ defaults: publicDefaults(route),
31
+ params: meta.paramsMeta?.map((p) => ({
32
+ source: p.source,
33
+ key: p.key ?? null,
34
+ index: p.index,
35
+ stream: p.stream === true
36
+ })).sort((a, b) => a.index - b.index) ?? [],
37
+ guards: {
38
+ security: meta.security ? { clauses: meta.security.clauses.map((c) => ({
39
+ anyOf: [...c.anyOf],
40
+ subjectParam: c.subjectParam ?? null
41
+ })) } : null,
42
+ scopes,
43
+ idempotent: meta.idempotent ? { required: meta.idempotent.required } : null,
44
+ csrfProtect: meta.csrfProtect,
45
+ csrfExempt: meta.csrfExempt,
46
+ session: meta.sessionIntent,
47
+ bypassFirewall: route.bypassFirewall === true
48
+ }
49
+ };
50
+ }
51
+ /**
52
+ * Construit le snapshot playground : toutes les routes à controller, groupées
53
+ * par classe de controller, triées (module puis nom, actions par path).
54
+ *
55
+ * Exclut les routes du **pont admin** (`AdminApiController.dispatch`) : le data
56
+ * plane a déjà son catalogue (`GET /nodefony/framework/api/admin`) et ses ~50
57
+ * routes techniques noieraient les controllers applicatifs.
58
+ *
59
+ * @returns la liste des controllers jouables, prête pour la page Studio.
60
+ */
61
+ function buildPlaygroundSnapshot() {
62
+ const byController = /* @__PURE__ */ new Map();
63
+ for (const route of router_default.routes) {
64
+ const ctor = route.controller;
65
+ if (!ctor || !route.classMethod) continue;
66
+ if (ctor === AdminApiController) continue;
67
+ let group = byController.get(ctor);
68
+ if (!group) {
69
+ group = {
70
+ name: ctor.name ?? "Controller",
71
+ module: route.module?.name ?? null,
72
+ actions: []
73
+ };
74
+ byController.set(ctor, group);
75
+ }
76
+ group.actions.push(serializeAction(route));
77
+ }
78
+ const controllers = [...byController.values()];
79
+ for (const c of controllers) c.actions.sort((a, b) => (a.path ?? "").localeCompare(b.path ?? ""));
80
+ controllers.sort((a, b) => (a.module ?? "~").localeCompare(b.module ?? "~") || a.name.localeCompare(b.name));
81
+ return { controllers };
82
+ }
83
+ /**
84
+ * Endpoints playground à greffer au producteur `framework` (dev uniquement —
85
+ * cf gating dans `createFrameworkAdminApi`).
86
+ *
87
+ * @returns `GET /nodefony/framework/api/playground/routes`.
88
+ */
89
+ function createPlaygroundEndpoints() {
90
+ return [{
91
+ path: "playground/routes",
92
+ summary: "Playground (dev) — controllers + actions + transports + params + guards, form-ready",
93
+ handler: () => buildPlaygroundSnapshot()
94
+ }];
95
+ }
96
+ //#endregion
97
+ export { buildPlaygroundSnapshot, createPlaygroundEndpoints };
@@ -0,0 +1,254 @@
1
+ import { assertPageQuery } from "nodefony";
2
+ //#region nodefony/src/RedisIdempotencyStore.ts
3
+ /** Préfixe namespacé des clés d'idempotence dans Redis. */
4
+ /**
5
+ * Préfixe HISTORIQUE des clés d'idempotence. Utilisé tel quel par une application
6
+ * sans cloison ; sinon le service Redis y insère le nom de l'application (cf
7
+ * `RedisService.keyPrefix`).
8
+ */
9
+ const KEY_BASE = "nf:idem";
10
+ /**
11
+ * Bail par défaut d'une entrée *in-flight* : 60 s (au-delà = exécution réputée
12
+ * abandonnée → la clé redevient réservable). Porté par le TTL natif (`PX`).
13
+ */
14
+ const DEFAULT_LEASE_MS = 6e4;
15
+ /**
16
+ * Rétention par défaut d'une réponse mémorisée : 10 min (un rejeu plausible
17
+ * reste dans cette fenêtre). Porté par le TTL natif (`PX`).
18
+ */
19
+ const DEFAULT_TTL_MS = 6e5;
20
+ /**
21
+ * Encode le curseur composite `"<consommé>:<curseurRedis>"` — cf
22
+ * {@link RedisIdempotencyStore.listPage} pour le pourquoi.
23
+ */
24
+ function encodeCursor(scanCursor, skip) {
25
+ return `${skip}:${scanCursor}`;
26
+ }
27
+ /** Inverse d'{@link encodeCursor} — tolère un curseur absent, vide ou malformé. */
28
+ function decodeCursor(cursor) {
29
+ if (!cursor) return {
30
+ scanCursor: "0",
31
+ skip: 0
32
+ };
33
+ const sep = cursor.indexOf(":");
34
+ if (sep === -1) return {
35
+ scanCursor: cursor,
36
+ skip: 0
37
+ };
38
+ const skip = Number.parseInt(cursor.slice(0, sep), 10);
39
+ return {
40
+ scanCursor: cursor.slice(sep + 1) || "0",
41
+ skip: Number.isFinite(skip) && skip > 0 ? skip : 0
42
+ };
43
+ }
44
+ /**
45
+ * Store d'idempotence **Redis** (node-redis v6) — implémentation distribuée
46
+ * d'{@link IIdempotencyStore} pour le cluster (dédup des mutations rejouées
47
+ * PARTAGÉE cross-pod, là où l'impl mémoire par défaut reste affine à un pod).
48
+ *
49
+ * **Pourquoi Redis est le bon backing** (modèle Stripe `Idempotency-Key`) :
50
+ * - `SET key … NX PX` = **réservation atomique côté serveur** → le `409`
51
+ * in-flight marche VRAIMENT entre pods (deux requêtes concurrentes sur deux
52
+ * pods : un seul `SET NX` gagne, l'autre voit l'entrée → conflit) ;
53
+ * - **TTL natif** (`PX`) sur le bail in-flight ET la réponse mémorisée → `gc()`
54
+ * superflu (zéro balayage applicatif, ≠ Drizzle).
55
+ *
56
+ * **Placement** : vit dans `@nodefony/framework` (le consommateur du contrat),
57
+ * PAS dans `@nodefony/redis` — calqué sur `RedisBackplane` (`@nodefony/realtime`)
58
+ * qui possède son adaptateur Redis et résout le service `redis` par nom (couplage
59
+ * structurel, 0 dépendance directe → 0 cycle). Le contrat `IIdempotencyStore`
60
+ * vit au CORE (`nodefony`), consommé en `import type`.
61
+ *
62
+ * **Modèle de clés** (préfixe `nf:idem`, cloisonné par application) : `<prefix>:<key>` = string JSON de
63
+ * l'{@link Entry}. La `<key>` est DÉJÀ scopée à l'identité par l'appelant
64
+ * (`evaluateIdempotency` compose `[identity, clientKey]`) → anti-IDOR garanti en
65
+ * amont ; le store reste agnostique au scope.
66
+ *
67
+ * **Empreinte préservée à la complétion** : `complete()` ne reçoit pas le
68
+ * fingerprint → il **relit** l'entrée *in-flight* pour reporter son `f` dans
69
+ * l'entrée *done*. Sans ça, un rejeu de la clé avec un AUTRE payload après
70
+ * complétion ne serait pas détecté (`mismatch` 422 perdu, draft §2.7).
71
+ *
72
+ * **Dégradation gracieuse** : si la connexion `main` n'est pas (ou plus) ouverte
73
+ * (boot/shutdown), `begin` renvoie `fresh` (la mutation s'exécute, **sans**
74
+ * dédup) et `complete`/`abort` sont des no-op — l'idempotence est temporairement
75
+ * inactive plutôt que de bloquer la mutation (fail-soft sur la dispo, comme le
76
+ * session/token store Redis). Trade-off assumé : un rejeu pendant une coupure
77
+ * Redis peut ré-exécuter (le client rejoue alors sa clé au rétablissement).
78
+ */
79
+ var RedisIdempotencyStore = class {
80
+ #resolveClient;
81
+ /** Fournit le préfixe cloisonné par application (lazy : le service arrive au boot). */
82
+ #resolvePrefix;
83
+ /** Préfixe mémoïsé — il est lu à chaque clé. */
84
+ #prefixCache = null;
85
+ #leaseMs;
86
+ #ttlMs;
87
+ /** Compteur LOCAL best-effort d'entrées réservées par CE pod (cf {@link size}). */
88
+ #pending = 0;
89
+ /**
90
+ * @param resolveClient - résolveur **lazy** du client Redis (l'ordre de boot
91
+ * n'est pas garanti à la construction ; `null` = connexion indisponible).
92
+ * @param leaseMs - bail d'une entrée *in-flight* (ms).
93
+ * @param ttlMs - rétention d'une réponse mémorisée (ms).
94
+ */
95
+ constructor(resolveClient, leaseMs = DEFAULT_LEASE_MS, ttlMs = DEFAULT_TTL_MS, resolvePrefix = () => KEY_BASE) {
96
+ this.#resolveClient = resolveClient;
97
+ this.#resolvePrefix = resolvePrefix;
98
+ this.#leaseMs = leaseMs;
99
+ this.#ttlMs = ttlMs;
100
+ }
101
+ /**
102
+ * Approximation **per-pod, best-effort** : compteur local des réservations
103
+ * faites par CE pod, non décrémenté si le bail expire sans `complete`/`abort`,
104
+ * et désaligné cross-pod (un `begin` sur un pod, un `complete` sur un autre).
105
+ * La vérité cluster passe par `redis-cli` (`SCAN nf:idem:*`/`DBSIZE`), jamais
106
+ * ce getter (un `SCAN` à chaque lecture serait cher). Borné à ≥ 0.
107
+ */
108
+ get size() {
109
+ return this.#pending < 0 ? 0 : this.#pending;
110
+ }
111
+ /**
112
+ * {@inheritDoc IIdempotencyStore.listPage}
113
+ *
114
+ * **Curseur SCAN** : au plus UN passage par appel (cold-path admin). Capacité
115
+ * réduite ASSUMÉE — pas de `total` (compter exigerait un SCAN complet du
116
+ * keyspace, précisément ce qu'on refuse), pas d'ordre global, et la page peut
117
+ * compter moins que `limit` (le filtre s'applique au batch scanné). Le client
118
+ * boucle tant que `hasNext` en repassant `nextCursor`.
119
+ *
120
+ * ⚠️ **`COUNT` n'est PAS un plafond** mais un indice d'effort : Redis peut
121
+ * rendre plus de clés que demandé (petit keyspace en listpack → tout arrive
122
+ * d'un coup). Sans précaution la page dépasserait `limit` et violerait
123
+ * `IPage`. D'où le **curseur composite** `"<consommé>:<curseurRedis>"` : on ne
124
+ * rend que `limit` éléments et on mémorise combien de clés du batch ont été
125
+ * consommées ; la page suivante rejoue le MÊME `SCAN` et reprend là. Bug
126
+ * réel, invisible contre un double — trouvé sur serveur Redis réel.
127
+ */
128
+ async listPage(query) {
129
+ assertPageQuery(query, "cursor");
130
+ const limit = Math.max(1, Math.floor(query.limit));
131
+ const client = this.#client();
132
+ if (!client) return {
133
+ items: [],
134
+ limit,
135
+ hasNext: false,
136
+ nextCursor: null
137
+ };
138
+ const { scanCursor, skip } = decodeCursor(query.cursor);
139
+ const match = query.q !== void 0 && query.q.length > 0 ? `${this.#prefix()}:${query.q}*` : `${this.#prefix()}:*`;
140
+ const res = await client.scan(scanCursor, {
141
+ MATCH: match,
142
+ COUNT: limit
143
+ });
144
+ const next = String(res.cursor);
145
+ const prefixLen = this.#prefix().length + 1;
146
+ const items = [];
147
+ let consumed = 0;
148
+ for (const key of res.keys.slice(skip)) {
149
+ if (items.length >= limit) break;
150
+ consumed += 1;
151
+ const entry = this.#parse(await client.get(key));
152
+ if (entry === null) continue;
153
+ const state = entry.s === "if" ? "in-flight" : "done";
154
+ if (query.state !== void 0 && state !== query.state) continue;
155
+ const ttl = await client.pTTL(key);
156
+ items.push({
157
+ key: key.slice(prefixLen),
158
+ state,
159
+ expiresAtMs: ttl > 0 ? Date.now() + ttl : 0,
160
+ hasResponse: entry.s === "d"
161
+ });
162
+ }
163
+ const nextCursor = skip + consumed < res.keys.length ? encodeCursor(scanCursor, skip + consumed) : next === "0" ? null : encodeCursor(next, 0);
164
+ return {
165
+ items,
166
+ limit,
167
+ hasNext: nextCursor !== null,
168
+ nextCursor
169
+ };
170
+ }
171
+ #client() {
172
+ return this.#resolveClient();
173
+ }
174
+ /** Préfixe effectif des clés, cloisonné par application (mémoïsé). */
175
+ #prefix() {
176
+ if (this.#prefixCache === null) this.#prefixCache = this.#resolvePrefix();
177
+ return this.#prefixCache;
178
+ }
179
+ #key(key) {
180
+ return `${this.#prefix()}:${key}`;
181
+ }
182
+ /** Parse défensif d'une valeur de clé ; `null` si absente/corrompue. */
183
+ #parse(raw) {
184
+ if (raw === null) return null;
185
+ try {
186
+ const v = JSON.parse(raw);
187
+ return v && (v.s === "if" || v.s === "d") ? v : null;
188
+ } catch {
189
+ return null;
190
+ }
191
+ }
192
+ async begin(key, fingerprint) {
193
+ const client = this.#client();
194
+ if (!client) return { state: "fresh" };
195
+ const k = this.#key(key);
196
+ const reservation = JSON.stringify({
197
+ s: "if",
198
+ f: fingerprint
199
+ });
200
+ if (await client.set(k, reservation, {
201
+ NX: true,
202
+ PX: this.#leaseMs
203
+ }) !== null) {
204
+ this.#pending++;
205
+ return { state: "fresh" };
206
+ }
207
+ const existing = this.#parse(await client.get(k));
208
+ if (existing === null) {
209
+ if (await client.set(k, reservation, {
210
+ NX: true,
211
+ PX: this.#leaseMs
212
+ }) !== null) {
213
+ this.#pending++;
214
+ return { state: "fresh" };
215
+ }
216
+ return { state: "in-flight" };
217
+ }
218
+ if (existing.f !== fingerprint) return { state: "mismatch" };
219
+ if (existing.s === "d") return {
220
+ state: "replayed",
221
+ response: existing.r
222
+ };
223
+ return { state: "in-flight" };
224
+ }
225
+ async complete(key, response) {
226
+ const client = this.#client();
227
+ if (!client) return;
228
+ const k = this.#key(key);
229
+ const existing = this.#parse(await client.get(k));
230
+ if (existing === null || existing.s !== "if") {
231
+ this.#dec();
232
+ return;
233
+ }
234
+ const done = JSON.stringify({
235
+ s: "d",
236
+ f: existing.f,
237
+ r: response
238
+ });
239
+ await client.set(k, done, { PX: this.#ttlMs });
240
+ this.#dec();
241
+ }
242
+ async abort(key) {
243
+ const client = this.#client();
244
+ if (!client) return;
245
+ await client.del(this.#key(key));
246
+ this.#dec();
247
+ }
248
+ /** Décrémente le compteur local borné à 0. */
249
+ #dec() {
250
+ if (this.#pending > 0) this.#pending--;
251
+ }
252
+ };
253
+ //#endregion
254
+ export { RedisIdempotencyStore };