@nodefony/security 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 (258) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +182 -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 +151 -0
  6. package/dist/nodefony/command/security-secrets.js +158 -0
  7. package/dist/nodefony/command/security-token.js +335 -0
  8. package/dist/nodefony/command/security-user-add.js +131 -0
  9. package/dist/nodefony/command/security-user-delete.js +102 -0
  10. package/dist/nodefony/command/security-user-list.js +77 -0
  11. package/dist/nodefony/config/config.js +366 -0
  12. package/dist/nodefony/config/defineModuleConfig.js +35 -0
  13. package/dist/nodefony/contracts/IAccessVoter.js +13 -0
  14. package/dist/nodefony/contracts/IApiKey.js +1 -0
  15. package/dist/nodefony/contracts/IAuditEvent.js +1 -0
  16. package/dist/nodefony/contracts/IAuditStore.js +1 -0
  17. package/dist/nodefony/contracts/IAuthenticator.js +1 -0
  18. package/dist/nodefony/contracts/IAuthorizationService.js +1 -0
  19. package/dist/nodefony/contracts/IFirewall.js +1 -0
  20. package/dist/nodefony/contracts/IFirewallDescription.js +1 -0
  21. package/dist/nodefony/contracts/IJwtKeystore.js +1 -0
  22. package/dist/nodefony/contracts/IOAuthProvider.js +1 -0
  23. package/dist/nodefony/contracts/ISecuredArea.js +1 -0
  24. package/dist/nodefony/contracts/IToken.js +1 -0
  25. package/dist/nodefony/contracts/ITokenStore.js +1 -0
  26. package/dist/nodefony/contracts/ITotpSecret.js +1 -0
  27. package/dist/nodefony/contracts/ITotpSecretStore.js +1 -0
  28. package/dist/nodefony/contracts/IWebAuthnCredential.js +1 -0
  29. package/dist/nodefony/contracts/IWebAuthnCredentialStore.js +1 -0
  30. package/dist/nodefony/contracts/IWebhookEndpoint.js +1 -0
  31. package/dist/nodefony/contracts/IWebhookStore.js +1 -0
  32. package/dist/nodefony/contracts/index.js +2 -0
  33. package/dist/nodefony/errors/AccessDeniedError.js +14 -0
  34. package/dist/nodefony/errors/ApiKeyError.js +21 -0
  35. package/dist/nodefony/errors/AuthenticationError.js +14 -0
  36. package/dist/nodefony/errors/CsrfError.js +23 -0
  37. package/dist/nodefony/errors/InvalidTargetError.js +39 -0
  38. package/dist/nodefony/errors/SsrfError.js +17 -0
  39. package/dist/nodefony/errors/ThrottledError.js +21 -0
  40. package/dist/nodefony/errors/UnverifiableTokenError.js +42 -0
  41. package/dist/nodefony/errors/WebAuthnError.js +21 -0
  42. package/dist/nodefony/errors/index.js +9 -0
  43. package/dist/nodefony/service/accessTokenVerifier.js +77 -0
  44. package/dist/nodefony/service/apiKeys.js +310 -0
  45. package/dist/nodefony/service/auditService.js +145 -0
  46. package/dist/nodefony/service/authFlow.js +332 -0
  47. package/dist/nodefony/service/authorization.js +95 -0
  48. package/dist/nodefony/service/cors.js +81 -0
  49. package/dist/nodefony/service/csrf.js +97 -0
  50. package/dist/nodefony/service/firewall.js +699 -0
  51. package/dist/nodefony/service/oauth2.js +153 -0
  52. package/dist/nodefony/service/securityHeaders.js +80 -0
  53. package/dist/nodefony/service/tokenService.js +486 -0
  54. package/dist/nodefony/service/totp.js +209 -0
  55. package/dist/nodefony/service/webAuthn.js +343 -0
  56. package/dist/nodefony/service/webhooks.js +539 -0
  57. package/dist/nodefony/src/RoleHierarchyWalker.js +77 -0
  58. package/dist/nodefony/src/SecuredArea.js +51 -0
  59. package/dist/nodefony/src/admin/SecurityAdminApi.js +495 -0
  60. package/dist/nodefony/src/admin/WebhookAdminApi.js +378 -0
  61. package/dist/nodefony/src/admin/adminAudit.js +37 -0
  62. package/dist/nodefony/src/admin/userRevocationCascade.js +40 -0
  63. package/dist/nodefony/src/apikey/apiKeyFormat.js +107 -0
  64. package/dist/nodefony/src/audit/MemoryAuditStore.js +121 -0
  65. package/dist/nodefony/src/audit/auditBridge.js +82 -0
  66. package/dist/nodefony/src/audit/auditFilters.js +60 -0
  67. package/dist/nodefony/src/audit/auditStoreRegistry.js +25 -0
  68. package/dist/nodefony/src/audit/readAuditContext.js +24 -0
  69. package/dist/nodefony/src/audit/recordAudit.js +16 -0
  70. package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +36 -0
  71. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +164 -0
  72. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +224 -0
  73. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +174 -0
  74. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +176 -0
  75. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +92 -0
  76. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +95 -0
  77. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +63 -0
  78. package/dist/nodefony/src/authenticator/bearer.js +2 -0
  79. package/dist/nodefony/src/authenticator/externalSubject.js +36 -0
  80. package/dist/nodefony/src/authenticator/peekIssuer.js +56 -0
  81. package/dist/nodefony/src/crypto/secretCipher.js +79 -0
  82. package/dist/nodefony/src/csp.js +54 -0
  83. package/dist/nodefony/src/csrfToken.js +65 -0
  84. package/dist/nodefony/src/net/ssrfGuard.js +130 -0
  85. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +37 -0
  86. package/dist/nodefony/src/oauth/providers/github.js +65 -0
  87. package/dist/nodefony/src/oauth/providers/oidc.js +48 -0
  88. package/dist/nodefony/src/realtime/UserRealtimeToken.js +94 -0
  89. package/dist/nodefony/src/realtime/frameAuthorizer.js +279 -0
  90. package/dist/nodefony/src/realtime/realtimeContracts.js +1 -0
  91. package/dist/nodefony/src/sessionIdentity.js +35 -0
  92. package/dist/nodefony/src/throttle/LoginThrottler.js +97 -0
  93. package/dist/nodefony/src/token/AnonymousToken.js +40 -0
  94. package/dist/nodefony/src/token/JwtKeystore.js +160 -0
  95. package/dist/nodefony/src/token/MemoryTokenStore.js +236 -0
  96. package/dist/nodefony/src/token/RemoteJwtVerifier.js +231 -0
  97. package/dist/nodefony/src/token/UserToken.js +67 -0
  98. package/dist/nodefony/src/token/jwtRuntime.js +19 -0
  99. package/dist/nodefony/src/token/secretFile.js +134 -0
  100. package/dist/nodefony/src/token/tokenCriteria.js +35 -0
  101. package/dist/nodefony/src/token/tokenFilters.js +72 -0
  102. package/dist/nodefony/src/token/tokenSort.js +40 -0
  103. package/dist/nodefony/src/token/tokenStatus.js +35 -0
  104. package/dist/nodefony/src/token/tokenStoreRegistry.js +25 -0
  105. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +97 -0
  106. package/dist/nodefony/src/totp/totpCipher.js +30 -0
  107. package/dist/nodefony/src/totp/totpCrypto.js +226 -0
  108. package/dist/nodefony/src/totp/totpOperations.js +129 -0
  109. package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +18 -0
  110. package/dist/nodefony/src/voter/RoleVoter.js +32 -0
  111. package/dist/nodefony/src/voter/ScopeVoter.js +52 -0
  112. package/dist/nodefony/src/voter/voterRegistry.js +20 -0
  113. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +121 -0
  114. package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +18 -0
  115. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +87 -0
  116. package/dist/nodefony/src/webhook/WebhookDispatcher.js +208 -0
  117. package/dist/nodefony/src/webhook/webhookCipher.js +27 -0
  118. package/dist/nodefony/src/webhook/webhookDelivery.js +102 -0
  119. package/dist/nodefony/src/webhook/webhookFilters.js +56 -0
  120. package/dist/nodefony/src/webhook/webhookSignature.js +51 -0
  121. package/dist/nodefony/src/webhook/webhookSort.js +48 -0
  122. package/dist/nodefony/src/webhook/webhookStoreRegistry.js +18 -0
  123. package/dist/types/index.d.ts +157 -0
  124. package/dist/types/nodefony/command/security-secrets.d.ts +24 -0
  125. package/dist/types/nodefony/command/security-token.d.ts +44 -0
  126. package/dist/types/nodefony/command/security-user-add.d.ts +28 -0
  127. package/dist/types/nodefony/command/security-user-delete.d.ts +25 -0
  128. package/dist/types/nodefony/command/security-user-list.d.ts +28 -0
  129. package/dist/types/nodefony/config/config.d.ts +295 -0
  130. package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
  131. package/dist/types/nodefony/contracts/IAccessVoter.d.ts +23 -0
  132. package/dist/types/nodefony/contracts/IApiKey.d.ts +75 -0
  133. package/dist/types/nodefony/contracts/IAuditEvent.d.ts +94 -0
  134. package/dist/types/nodefony/contracts/IAuditStore.d.ts +80 -0
  135. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +66 -0
  136. package/dist/types/nodefony/contracts/IAuthorizationService.d.ts +28 -0
  137. package/dist/types/nodefony/contracts/IFirewall.d.ts +64 -0
  138. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +120 -0
  139. package/dist/types/nodefony/contracts/IJwtKeystore.d.ts +40 -0
  140. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +51 -0
  141. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +57 -0
  142. package/dist/types/nodefony/contracts/IToken.d.ts +41 -0
  143. package/dist/types/nodefony/contracts/ITokenStore.d.ts +240 -0
  144. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +41 -0
  145. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +88 -0
  146. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +56 -0
  147. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +118 -0
  148. package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +82 -0
  149. package/dist/types/nodefony/contracts/IWebhookStore.d.ts +85 -0
  150. package/dist/types/nodefony/contracts/index.d.ts +9 -0
  151. package/dist/types/nodefony/errors/AccessDeniedError.d.ts +10 -0
  152. package/dist/types/nodefony/errors/ApiKeyError.d.ts +17 -0
  153. package/dist/types/nodefony/errors/AuthenticationError.d.ts +10 -0
  154. package/dist/types/nodefony/errors/CsrfError.d.ts +19 -0
  155. package/dist/types/nodefony/errors/InvalidTargetError.d.ts +34 -0
  156. package/dist/types/nodefony/errors/SsrfError.d.ts +13 -0
  157. package/dist/types/nodefony/errors/ThrottledError.d.ts +16 -0
  158. package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +37 -0
  159. package/dist/types/nodefony/errors/WebAuthnError.d.ts +17 -0
  160. package/dist/types/nodefony/errors/index.d.ts +8 -0
  161. package/dist/types/nodefony/service/accessTokenVerifier.d.ts +29 -0
  162. package/dist/types/nodefony/service/apiKeys.d.ts +103 -0
  163. package/dist/types/nodefony/service/auditService.d.ts +30 -0
  164. package/dist/types/nodefony/service/authFlow.d.ts +123 -0
  165. package/dist/types/nodefony/service/authorization.d.ts +33 -0
  166. package/dist/types/nodefony/service/cors.d.ts +48 -0
  167. package/dist/types/nodefony/service/csrf.d.ts +57 -0
  168. package/dist/types/nodefony/service/firewall.d.ts +148 -0
  169. package/dist/types/nodefony/service/oauth2.d.ts +66 -0
  170. package/dist/types/nodefony/service/securityHeaders.d.ts +66 -0
  171. package/dist/types/nodefony/service/tokenService.d.ts +103 -0
  172. package/dist/types/nodefony/service/totp.d.ts +58 -0
  173. package/dist/types/nodefony/service/webAuthn.d.ts +123 -0
  174. package/dist/types/nodefony/service/webhooks.d.ts +160 -0
  175. package/dist/types/nodefony/src/RoleHierarchyWalker.d.ts +21 -0
  176. package/dist/types/nodefony/src/SecuredArea.d.ts +31 -0
  177. package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +82 -0
  178. package/dist/types/nodefony/src/admin/WebhookAdminApi.d.ts +30 -0
  179. package/dist/types/nodefony/src/admin/adminAudit.d.ts +27 -0
  180. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +31 -0
  181. package/dist/types/nodefony/src/apikey/apiKeyFormat.d.ts +43 -0
  182. package/dist/types/nodefony/src/audit/MemoryAuditStore.d.ts +33 -0
  183. package/dist/types/nodefony/src/audit/auditBridge.d.ts +49 -0
  184. package/dist/types/nodefony/src/audit/auditFilters.d.ts +56 -0
  185. package/dist/types/nodefony/src/audit/auditStoreRegistry.d.ts +37 -0
  186. package/dist/types/nodefony/src/audit/readAuditContext.d.ts +17 -0
  187. package/dist/types/nodefony/src/audit/recordAudit.d.ts +13 -0
  188. package/dist/types/nodefony/src/authenticator/AnonymousAuthenticator.d.ts +26 -0
  189. package/dist/types/nodefony/src/authenticator/ApiKeyAuthenticator.d.ts +74 -0
  190. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +132 -0
  191. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +78 -0
  192. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +69 -0
  193. package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +70 -0
  194. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +53 -0
  195. package/dist/types/nodefony/src/authenticator/authenticatorRegistry.d.ts +39 -0
  196. package/dist/types/nodefony/src/authenticator/bearer.d.ts +22 -0
  197. package/dist/types/nodefony/src/authenticator/externalSubject.d.ts +27 -0
  198. package/dist/types/nodefony/src/authenticator/peekIssuer.d.ts +31 -0
  199. package/dist/types/nodefony/src/crypto/secretCipher.d.ts +31 -0
  200. package/dist/types/nodefony/src/csp.d.ts +39 -0
  201. package/dist/types/nodefony/src/csrfToken.d.ts +36 -0
  202. package/dist/types/nodefony/src/net/ssrfGuard.d.ts +43 -0
  203. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +45 -0
  204. package/dist/types/nodefony/src/oauth/providers/github.d.ts +9 -0
  205. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +35 -0
  206. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +62 -0
  207. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +171 -0
  208. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +139 -0
  209. package/dist/types/nodefony/src/sessionIdentity.d.ts +20 -0
  210. package/dist/types/nodefony/src/throttle/LoginThrottler.d.ts +68 -0
  211. package/dist/types/nodefony/src/token/AnonymousToken.d.ts +23 -0
  212. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +43 -0
  213. package/dist/types/nodefony/src/token/MemoryTokenStore.d.ts +66 -0
  214. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +149 -0
  215. package/dist/types/nodefony/src/token/UserToken.d.ts +41 -0
  216. package/dist/types/nodefony/src/token/jwtRuntime.d.ts +28 -0
  217. package/dist/types/nodefony/src/token/secretFile.d.ts +70 -0
  218. package/dist/types/nodefony/src/token/tokenCriteria.d.ts +20 -0
  219. package/dist/types/nodefony/src/token/tokenFilters.d.ts +76 -0
  220. package/dist/types/nodefony/src/token/tokenSort.d.ts +33 -0
  221. package/dist/types/nodefony/src/token/tokenStatus.d.ts +38 -0
  222. package/dist/types/nodefony/src/token/tokenStoreRegistry.d.ts +38 -0
  223. package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +43 -0
  224. package/dist/types/nodefony/src/totp/totpCipher.d.ts +9 -0
  225. package/dist/types/nodefony/src/totp/totpCrypto.d.ts +164 -0
  226. package/dist/types/nodefony/src/totp/totpOperations.d.ts +73 -0
  227. package/dist/types/nodefony/src/totp/totpSecretStoreRegistry.d.ts +27 -0
  228. package/dist/types/nodefony/src/voter/RoleVoter.d.ts +25 -0
  229. package/dist/types/nodefony/src/voter/ScopeVoter.d.ts +30 -0
  230. package/dist/types/nodefony/src/voter/voterRegistry.d.ts +33 -0
  231. package/dist/types/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.d.ts +39 -0
  232. package/dist/types/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.d.ts +26 -0
  233. package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +37 -0
  234. package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +69 -0
  235. package/dist/types/nodefony/src/webhook/webhookCipher.d.ts +8 -0
  236. package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +28 -0
  237. package/dist/types/nodefony/src/webhook/webhookFilters.d.ts +64 -0
  238. package/dist/types/nodefony/src/webhook/webhookSignature.d.ts +20 -0
  239. package/dist/types/nodefony/src/webhook/webhookSort.d.ts +39 -0
  240. package/dist/types/nodefony/src/webhook/webhookStoreRegistry.d.ts +31 -0
  241. package/docs/api-keys.md +691 -0
  242. package/docs/audit.md +751 -0
  243. package/docs/authenticators.md +487 -0
  244. package/docs/authorization.md +497 -0
  245. package/docs/cors.md +497 -0
  246. package/docs/csrf.md +392 -0
  247. package/docs/external-jwt.md +181 -0
  248. package/docs/firewall.md +546 -0
  249. package/docs/headers.md +616 -0
  250. package/docs/index.md +207 -0
  251. package/docs/lexique.md +190 -0
  252. package/docs/oauth2.md +575 -0
  253. package/docs/obtenir-un-jeton.md +225 -0
  254. package/docs/tokens.md +520 -0
  255. package/docs/totp.md +804 -0
  256. package/docs/webauthn.md +733 -0
  257. package/docs/webhooks.md +1016 -0
  258. package/package.json +83 -0
@@ -0,0 +1,366 @@
1
+ import { z } from "zod";
2
+ //#region nodefony/config/config.ts
3
+ /**
4
+ * ════════════════════════════════════════════════════════════════════════════
5
+ * @nodefony/security — CONFIGURATION DU MODULE (schéma Zod = source unique)
6
+ *
7
+ * ⭐ TL;DR : CE SCHÉMA EST LA CONFIG. Chaque `.default(...)` = la valeur d'usine ;
8
+ * changer un défaut du module = ÉDITER ICI (et nulle part ailleurs). L'app, elle,
9
+ * surcharge via `use("@nodefony/...", { … })` dans SON `nodefony.config.ts`.
10
+ * ════════════════════════════════════════════════════════════════════════════
11
+ *
12
+ * RÈGLE D'OR (ADR-0006) : ce fichier porte le **schéma Zod commenté** (type +
13
+ * validation + défaut + doc) ET matérialise les défauts via `parse({})`. Aucune
14
+ * valeur n'est re-tapée ailleurs. Le builder (`defineModuleConfig.ts` →
15
+ * `defineSecurityConfig` : parse + détection de conflits de zones + freeze)
16
+ * importe le schéma D'ICI (nœud bas : ce fichier n'importe que `zod`).
17
+ *
18
+ * Principes (≠ copie Symfony) :
19
+ * - **Groupé par préoccupation** : une section = un sujet sécu (firewall, cors,
20
+ * csrf, headers, rateLimit, jwt, apiKeys, webhooks, audit, studio).
21
+ * - **Tout est désactivable** : chaque défense porte `enabled` → Studio l'allume/
22
+ * l'éteint à chaud (overlay runtime, sans redéploiement).
23
+ * - **Auto-documenté + introspectable** : chaque champ porte `.describe()` →
24
+ * `securityConfigJsonSchema()` produit un JSON Schema dont Studio dérive un
25
+ * formulaire d'édition (zéro UI hardcodée).
26
+ * - **Défauts SÛRS** : Zero Trust, CORS strict (jamais `*`+credentials), Studio OFF.
27
+ *
28
+ * OÙ SURCHARGER (précédence croissante — cf ADR-0006) :
29
+ * • App (typé) : `use("@nodefony/security", { … })` dans `nodefony.config.ts` ;
30
+ * • Par environnement : la fonction `(ctx) => …` de `nodefony.config.ts` (`ctx.isProd`…) ;
31
+ * • Déploiement/Docker : `NF__SECURITY__<CHEMIN>=valeur` (override env générique) ;
32
+ * • Studio (à chaud) : chaque section porte `enabled` → activable/désactivable.
33
+ */
34
+ const encoderSchema = z.strictObject({
35
+ type: z.enum(["argon2id", "bcrypt"]).default("argon2id").describe("Algorithme de hash du mot de passe. Argon2id (RFC 9106) = défaut : memory-hard, résiste au parallélisme GPU/ASIC. bcrypt = legacy supporté (limite 72 octets)."),
36
+ memoryKiB: z.number().int().min(19456).default(19456).describe("Argon2id : mémoire par hash (KiB). 19456 = 19 MiB, minimum OWASP."),
37
+ timeCost: z.number().int().min(2).default(3).describe("Argon2id : passes d'itération. Défaut 3 (> minimum OWASP t=2 ; RFC 9106 « uniformly safe ») — renchérit l'attaquant sans augmenter la RAM par hash (anti-DoS). Bench cible 50-100 ms/hash."),
38
+ parallelism: z.number().int().min(1).default(1).describe("Argon2id : lanes parallèles. Défaut 1 (OWASP)."),
39
+ rounds: z.number().int().min(10).max(15).default(12).describe("bcrypt : coût (10–15). Ignoré par argon2id.")
40
+ });
41
+ const areaSchema = z.strictObject({
42
+ pattern: z.string().describe("Pattern d'URL (RegExp) capturé par la zone."),
43
+ security: z.boolean().default(true).describe("Zone protégée (Zero Trust). false = publique explicite."),
44
+ stateless: z.boolean().default(false).describe("Stratégie d'identité AU-DESSUS du protocole (HTTP reste stateless par nature). false (défaut) : la zone PEUT tenir un registre serveur — la session n'est créée qu'AU LOGIN (jamais pour un anonyme, zéro alloc), cookie opaque révocable (BFF). true : aucun registre — chaque requête porte sa preuve complète (JWT/clé API), la session est ignorée même si un cookie est présent. ⚠️ Une zone destinée à un appelant NON-NAVIGATEUR (service partenaire, script, agent) doit être `true` : laissée à `false`, elle marche à l'essai — un navigateur ou un `curl -c` renvoie le cookie posé — puis échoue chez le vrai client, qui ne stocke rien et repart anonyme à chaque requête. Le symptôme est un 401 intermittent en production, que rien dans le code ne montre."),
45
+ mode: z.enum(["first", "all"]).default("first").describe("Chaîne d'authenticators : 'first' = le premier qui reconnaît la requête authentifie (ex. cookie OU bearer) ; 'all' = tous doivent passer (ex. mtls+jwt zone admin)."),
46
+ authenticators: z.array(z.string()).default([]).describe("Authenticators de la zone (sémantique selon `mode`). Validés au boot contre le registre."),
47
+ entryPoint: z.string().optional().describe("Route de login/redirect si non authentifié."),
48
+ host: z.string().optional().describe("Domaine/vhost de la zone (ex. admin.exemple.com). Omis = tous domaines."),
49
+ resource: z.string().optional().describe("URI canonique de la ressource protégée — l'AUDIENCE qu'un jeton doit porter pour être accepté ici (RFC 8707 §2). Obligatoire dès que la zone liste un authenticator de jetons TIERS (`external-jwt`), qui refuse de démarrer sans elle. C'est ce qui empêche un jeton valide, délivré au même porteur pour un AUTRE service, d'être rejoué sur celui-ci. Elle s'ÉCRIT et ne se dérive pas du `Host` : derrière un relais, ce que le processus croit être son adresse n'est pas ce que le client a demandé, et l'audience doit être exactement celle que le serveur d'autorisation a inscrite dans le jeton. Ex. « https://api.example.com/mcp »."),
50
+ realtime: z.boolean().default(true).describe("Zone valable AUSSI pour les frames WebSocket (api.request + subscribe), pas seulement HTTP. Défaut `true` (Zero Trust : une zone protégée ferme TOUS ses transports — un opt-IN laisserait le WS anonyme par omission = fail-open). `false` = opt-out explicite pour une zone strictement HTTP. Le verrou WS consulte la MÊME zone que HTTP — invariant : `api.request {path}` n'accorde jamais plus que `GET {path}`.")
51
+ });
52
+ const corsSchema = z.strictObject({
53
+ enabled: z.boolean().default(true).describe("Active la gestion CORS."),
54
+ origins: z.array(z.string()).default([]).describe("Whitelist d'origines. JAMAIS '*' avec credentials."),
55
+ credentials: z.boolean().default(false).describe("Autorise les cookies cross-origin (exige une whitelist d'origines)."),
56
+ methods: z.array(z.string()).default([
57
+ "GET",
58
+ "POST",
59
+ "PUT",
60
+ "PATCH",
61
+ "DELETE",
62
+ "OPTIONS"
63
+ ]),
64
+ allowedHeaders: z.array(z.string()).default([
65
+ "Authorization",
66
+ "Content-Type",
67
+ "X-Requested-With"
68
+ ]),
69
+ exposedHeaders: z.array(z.string()).default([]),
70
+ maxAgeS: z.number().int().default(600).describe("Cache préflight (secondes).")
71
+ }).describe("Cross-Origin Resource Sharing.").refine((c) => !(c.credentials && c.origins.includes("*")), { message: "CORS: credentials=true est INCOMPATIBLE avec origins:['*'] (le navigateur le refuse, OWASP). Lister les origines explicitement." });
72
+ const csrfSchema = z.strictObject({
73
+ enabled: z.boolean().default(true).describe("Défense CSRF par défaut (Fetch Metadata + SameSite + Origin)."),
74
+ fetchMetadata: z.boolean().default(true).describe("Défense PRIMAIRE : rejette les mutations cross-site via Sec-Fetch-Site (tamponné par le navigateur, infalsifiable — modèle Go 1.25 CrossOriginProtection)."),
75
+ sameSite: z.enum([
76
+ "Strict",
77
+ "Lax",
78
+ "None"
79
+ ]).default("Lax"),
80
+ checkOrigin: z.boolean().default(true).describe("Fallback : compare Origin/Referer aux origines de l'app sur les méthodes mutantes (vieux navigateurs sans Sec-Fetch-*)."),
81
+ strictSameSite: z.boolean().default(false).describe("Politique sur Sec-Fetch-Site: same-site (un SOUS-DOMAINE de la même famille déclenche la mutation). false (défaut) = tolérant (sous-domaines de confiance) ; true = ne tolérer QUE same-origin + none (déploiement multi-tenant / sous-domaine non maîtrisé). ⚠️ distinct de l'attribut cookie `sameSite` ci-dessus."),
82
+ trustedOrigins: z.array(z.string()).default([]).describe("Origines ALIAS légitimes de l'app (façades multi-domaine, ex. ['https://app.example.org']) — autorisées MÊME en cross-site, sur Fetch Metadata ET fallback. Distinct de `cors.origins` (qui, lui, ouvre AUSSI la lecture CORS des réponses) : un simple alias de domaine ne doit pas exposer les réponses au JS tiers. Match exact d'origine (scheme://host[:port])."),
83
+ secret: z.string().min(16).optional().describe("Secret HMAC du token synchronizer (`@CsrfProtect`, défense en profondeur opt-in). PROD : fixer via env (≥16 car.) — DOIT être PARTAGÉ entre process (cluster) sinon les tokens d'un pod sont rejetés par un autre. DEV : si absent, un secret éphémère est généré au boot (re-généré à chaque restart → invalide les tokens en cours). Sans valeur, `@CsrfProtect` fonctionne en dev mais N'EST PAS sûr en cluster.")
84
+ }).describe("Cross-Site Request Forgery — Fetch Metadata d'abord (OWASP 2025) ; token synchronizer = opt-in @CsrfProtect.");
85
+ const headersSchema = z.strictObject({
86
+ enabled: z.boolean().default(true).describe("En-têtes de sécurité HTTP (natif, sans la lib helmet)."),
87
+ hsts: z.boolean().default(true).meta({
88
+ reserved: true,
89
+ description: "INERTE ICI — l'en-tête est posé par @nodefony/http à l'entrée brute (couvre statics, erreurs et serveur nu ; une seule source par en-tête). Pour le régler : `http.securityHeaders.strictTransportSecurity` (`null` pour ne pas émettre l'en-tête)."
90
+ }),
91
+ hstsMaxAgeS: z.number().int().default(31536e3).meta({
92
+ reserved: true,
93
+ description: "INERTE ICI — pour régler la durée : `http.securityHeaders.strictTransportSecurity.maxAge`."
94
+ }),
95
+ csp: z.string().default("default-src 'self'; script-src 'self' 'nonce-{{nonce}}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://www.gravatar.com https://*.googleusercontent.com https://avatars.githubusercontent.com; font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'").describe("Content-Security-Policy « secure-but-usable ». Seul `script-src` est strict (self + `{{nonce}}` substitué par requête si `cspNonces`) = défense XSS ; le reste couvre les besoins réels (CSS-in-JS, img/font inline, blobs, fetch/WS same-origin, workers) + durcissements (object 'none', base-uri/form-action 'self')."),
96
+ cspNonces: z.boolean().default(true).describe("Nonce CSP par requête (bloque l'inline non signé)."),
97
+ frameguard: z.enum(["deny", "sameorigin"]).default("deny").meta({
98
+ reserved: true,
99
+ description: "INERTE ICI — l'en-tête anti-clickjacking est posé par @nodefony/http (il couvre aussi les statics et les erreurs). Pour le régler : `http.securityHeaders.frameOptions` (`DENY` | `SAMEORIGIN` | `null`)."
100
+ }),
101
+ noSniff: z.boolean().default(true).meta({
102
+ reserved: true,
103
+ description: "INERTE ICI — posé par @nodefony/http (couvre aussi statics et erreurs). Pour le régler : `http.securityHeaders.contentTypeOptions` (`nosniff`, ou `null` pour désactiver)."
104
+ }),
105
+ referrerPolicy: z.enum([
106
+ "no-referrer",
107
+ "no-referrer-when-downgrade",
108
+ "same-origin",
109
+ "origin",
110
+ "strict-origin",
111
+ "origin-when-cross-origin",
112
+ "strict-origin-when-cross-origin",
113
+ "unsafe-url"
114
+ ]).default("no-referrer").describe("Referrer-Policy (W3C, ensemble fini → complété + validé). Posé par security (applicatif)."),
115
+ hidePoweredBy: z.boolean().default(true).meta({
116
+ reserved: true,
117
+ description: "INERTE — sans objet sous Nodefony, qui n'émet jamais de X-Powered-By (contrairement à Express). L'en-tête `Server` est géré par @nodefony/http. Rien à activer : il n'y a rien à retirer."
118
+ }),
119
+ coop: z.enum([
120
+ "same-origin",
121
+ "same-origin-allow-popups",
122
+ "unsafe-none"
123
+ ]).optional().describe("Cross-Origin-Opener-Policy (isolation Spectre)."),
124
+ coep: z.enum([
125
+ "require-corp",
126
+ "credentialless",
127
+ "unsafe-none"
128
+ ]).optional().describe("Cross-Origin-Embedder-Policy (require-corp casse les ressources tierces non-CORP)."),
129
+ corp: z.enum([
130
+ "same-origin",
131
+ "same-site",
132
+ "cross-origin"
133
+ ]).optional().describe("Cross-Origin-Resource-Policy."),
134
+ originAgentCluster: z.boolean().optional().describe("Origin-Agent-Cluster (isolation mémoire par origine)."),
135
+ permissionsPolicy: z.string().optional().describe("Permissions-Policy (désactive caméra/micro/géo… des API navigateur).")
136
+ }).describe("En-têtes HTTP de sécurité (HSTS, CSP+nonces, frameguard, noSniff… + avancés optionnels).");
137
+ const rateLimitSchema = z.strictObject({
138
+ enabled: z.boolean().default(true).describe("Throttling de login (NIST SP 800-63B) : backoff progressif par identifiant, jamais de lockout dur."),
139
+ freeAttempts: z.number().int().min(1).default(3).describe("Échecs consécutifs tolérés sans délai (fautes de frappe)."),
140
+ baseDelayS: z.number().int().min(1).default(1).describe("Délai initial (s) après freeAttempts — double à chaque échec suivant."),
141
+ capDelayS: z.number().int().min(1).default(900).describe("Plafond du délai (s) — 900 = 15 min."),
142
+ maxTracked: z.number().int().min(100).default(1e4).describe("Borne du nombre d'identifiants suivis en mémoire (anti-fuite, éviction des plus anciens).")
143
+ }).describe("Throttling de login (backoff progressif NIST).");
144
+ const jwtSchema = z.strictObject({
145
+ enabled: z.boolean().default(true),
146
+ alg: z.enum(["EdDSA", "RS256"]).default("EdDSA"),
147
+ accessTtlS: z.number().int().default(900).describe("TTL access token (15 min)."),
148
+ refreshTtlS: z.number().int().default(604800).describe("TTL refresh token (7 jours)."),
149
+ rotateRefresh: z.boolean().default(true).describe("Rotation du refresh token (OWASP)."),
150
+ jwks: z.boolean().default(true).describe("Publie le jeu de clés publiques sur `/.well-known/jwks.json` et les métadonnées d'émetteur sur `/.well-known/oauth-authorization-server` (RFC 8414) — de quoi rendre cette app DÉCOUVRABLE par un tiers qui vérifie ses jetons. Sans `issuer` en URL https, rien n'est publié (avertissement au boot). Le `kid` (rotation) est posé indépendamment de ce drapeau."),
151
+ audiences: z.array(z.string()).default([]).describe("Audiences acceptées à la vérification ET **demandables à l'émission** (claim `aud`, RFC 8707) — une liste BLANCHE de ressources, pas une simple valeur par défaut. Un client nomme sa cible par le paramètre `resource` du grant ; absente de cette liste, la demande est REFUSÉE (`invalid_target`, RFC 8707 §2) au lieu d'être servie avec l'audience par défaut — sans quoi tout porteur d'un identifiant valide obtiendrait un jeton pour la ressource de son choix. La PREMIÈRE entrée est l'audience par défaut (jeton demandé sans `resource`). Vide = l'audience de l'app (= `issuer`). Déclarer ici l'URI canonique d'une ressource protégée (porte MCP, API interne) est ce qui la rend joignable avec un jeton émis par cette application. La validation d'audience est OBLIGATOIRE côté resource (RFC 9700)."),
152
+ issuer: z.string().optional().describe("Émetteur (claim `iss`, RFC 7519). Omis = `\"nodefony\"` — suffisant tant que l'app émet ET vérifie ses propres jetons, mais PAS publiable (RFC 8414 §2 exige une URL https) : sans valeur explicite, aucune découverte n'est offerte. Ne se DEVINE pas — derrière un relais, `Host`/`X-Forwarded-*` viennent du client. STABLE : gravé dans chaque jeton déjà émis, le changer les invalide."),
153
+ keystore: z.strictObject({
154
+ keySetJson: z.string().optional().describe("JWK Set (clé(s) privée(s) Ed25519) injecté par l'app depuis son env — SECRET, jamais loggé. Présent = source `env` (prod cloud)."),
155
+ dir: z.string().optional().describe("Dossier de persistance `keyset.json` (chmod 600), généré si absent — opt-in dev/VPS. Sans `keySetJson` ni `dir` = clé ÉPHÉMÈRE en mémoire + warning (refresh non durables).")
156
+ }).default(() => ({})).describe("Source du matériel de signature JWT (priorité) : env (`keySetJson`) → fichier (`dir`) → mémoire+warn. SecretProvider (Vault/KMS) = P16.")
157
+ }).describe("JWT — réservé API service↔service / agents (le web/navigateur utilise la session BFF).");
158
+ const tokenStoreSchema = z.strictObject({
159
+ store: z.string().default("auto").describe("Store de jetons (refresh/PAT/denylist) : auto [défaut] (infra database → drizzle/mongoose ; sinon sqlite local si drizzle chargé ; sinon repli memory volatil ; `NF_STORE` passe AVANT tout cela et force le backend pour toutes les briques `auto`)|memory|drizzle|mongoose|redis. Pluggable (`registerTokenStore`). Memory = dev/tests (volatil, non partagé). Vocabulaire unifié : données = `store`."),
160
+ gcIntervalS: z.number().int().min(0).default(600).describe("Intervalle de purge des jetons expirés (s). 0 = désactivé. CHAQUE process purge son store (un store local DOIT être purgé par process)."),
161
+ gcJitter: z.boolean().default(true).describe("Étale le gc d'un délai aléatoire (0..interval) par process — évite les balayages simultanés sur un store partagé en cluster."),
162
+ retentionRevokedDays: z.number().int().min(0).default(30).describe("Rétention (jours) d'un PAT révoqué SANS expiration (fenêtre d'audit) avant purge.")
163
+ }).describe("Store de jetons serveur (refresh, PAT, denylist `jti`, seuil de révocation par porteur) + maintenance gc.");
164
+ const passkeysSchema = z.strictObject({
165
+ enabled: z.boolean().default(true).describe("Active WebAuthn/passkeys (MFA phishing-resistant, NIST AAL2)."),
166
+ rpId: z.string().optional().describe("Relying Party ID (domaine). Omis = domaine de l'app au boot."),
167
+ rpName: z.string().optional().describe("Nom lisible de la Relying Party affiché dans l'invite OS/navigateur. Omis = nom de l'app."),
168
+ origins: z.array(z.string()).default([]).describe("Origines autorisées aux ceremonies. Vide = origine de l'app."),
169
+ userVerification: z.enum([
170
+ "required",
171
+ "preferred",
172
+ "discouraged"
173
+ ]).default("preferred").describe("Vérification utilisateur (biométrie/PIN) exigée par l'authenticator. Défaut: preferred."),
174
+ residentKey: z.enum([
175
+ "required",
176
+ "preferred",
177
+ "discouraged"
178
+ ]).default("preferred").describe("Credential découvrable (passkey usernameless : login sans saisir l'identifiant). Défaut: preferred. 'discouraged' EMPÊCHE le login : la cérémonie anonyme ne cible aucun credential (anti-énumération), elle compte sur la découverte par l'authenticator — un WARNING le rappelle au boot."),
179
+ authenticatorAttachment: z.enum([
180
+ "platform",
181
+ "cross-platform",
182
+ "any"
183
+ ]).default("platform").describe("Type d'authentificateur à l'enregistrement. 'platform' = biométrie intégrée (Touch ID/Windows Hello, PAS de QR) [défaut] ; 'cross-platform' = clé externe/téléphone (YubiKey, QR) ; 'any' = les deux (le navigateur propose, QR possible)."),
184
+ attestation: z.enum([
185
+ "none",
186
+ "direct",
187
+ "enterprise"
188
+ ]).default("none").describe("Conveyance d'attestation demandée au navigateur. Défaut 'none' (passkeys grand public — vie privée). 'direct'/'enterprise' font TRANSMETTRE l'attestation, mais Nodefony ne la confronte à AUCUNE liste de modèles : ni métadonnées FIDO (MDS), ni certificats racines fabricant, et l'AAGUID n'est pas conservé. Le réglage ne suffit donc PAS à tenir un AAL3 régulé — il le prépare. Un WARNING au boot le rappelle si la valeur n'est pas 'none'."),
189
+ timeoutMs: z.number().int().positive().default(6e4).describe("Délai (ms) laissé à l'utilisateur pour compléter une ceremony (navigator.credentials.*). Défaut: 60000."),
190
+ maxPerUser: z.number().int().positive().default(20).describe("Plafond de passkeys enregistrées par utilisateur (409 au-delà). Borne `allowCredentials`/`excludeCredentials`, chargés ENTIERS à chaque cérémonie : sans plafond, un compte qui enrôle en masse fait grossir la réponse du défi de login — déclenchable par un anonyme qui poste son identifiant — et dépasse le `maxCredentialCountInList` des authenticators CTAP2. Défaut: 20 (large pour un humain : appareils + clés physiques + renouvellements)."),
191
+ challengeTtlS: z.number().int().positive().default(300).meta({
192
+ reserved: true,
193
+ description: "RÉSERVÉ — non câblé. Durée de vie (s) prévue du challenge serveur (anti-replay). Aujourd'hui le challenge suit la session ; ce TTL dédié n'est lu par aucun authenticator. Défaut: 300 (5 min)."
194
+ }),
195
+ store: z.string().default("auto").describe("Backend de stockage des credentials : auto [défaut] (infra database déclarée → drizzle/mongoose ; sinon sqlite local si drizzle chargé ; sinon repli memory volatil ; `NF_STORE` passe AVANT tout cela et force le backend pour toutes les briques `auto`)|memory|drizzle|mongoose|redis. Pluggable (`registerWebAuthnStore`). 'memory' = dev/tests (volatil, perdu au redémarrage — les passkeys enregistrés deviennent inutilisables). Persistance = adapter durable auto-enregistré par le module chargé.")
196
+ }).describe("Passkeys (WebAuthn L3 / FIDO2) — synced par défaut.");
197
+ const totpSchema = z.strictObject({
198
+ enabled: z.boolean().default(true).describe("Active le 2FA TOTP (RFC 6238, codes à usage unique d'une app d'authentification : Google Authenticator, Authy…)."),
199
+ issuer: z.string().optional().describe("Émetteur affiché dans l'app d'authentification (label du QR). Omis = nom de l'app."),
200
+ algorithm: z.enum([
201
+ "SHA1",
202
+ "SHA256",
203
+ "SHA512"
204
+ ]).default("SHA1").describe("Fonction HMAC du code. SHA1 = compat maximale (Google Authenticator) [défaut]."),
205
+ digits: z.number().int().min(6).max(8).default(6).describe("Nombre de chiffres du code (RFC 4226 §5.3 : 6 minimum)."),
206
+ period: z.number().int().positive().default(30).describe("Période d'un code en secondes (RFC 6238 §5.2 : 30 recommandé)."),
207
+ window: z.number().int().nonnegative().default(1).describe("Tolérance de dérive d'horloge (± N pas). RFC 6238 §5.2 : « at most one time step » → 1 [défaut]. Plus = surface d'attaque accrue."),
208
+ recoveryCodes: z.number().int().positive().default(10).describe("Nombre de codes de récupération générés à l'activation (NIST SP 800-63B « look-up secrets », usage unique, affichés 1×)."),
209
+ encryptionKey: z.string().optional().describe("Clé de chiffrement AES-256-GCM du secret TOTP au repos (fournie par l'app depuis env, ≥ 32 octets après décodage). Omise = clé éphémère dérivée + WARNING (dev mono-process) ; en prod multi-pod, REQUISE (un secret 2FA déchiffrable doit l'être par tous les pods, et jamais en clair)."),
210
+ store: z.string().default("auto").describe("Backend de stockage du secret : auto [défaut, forçable par `NF_STORE`] | memory | drizzle — plus tout backend ajouté par `registerTotpStore` (la liste qui fait foi est celle de l'écran Stores de Studio, pas ce texte). ⚠️ Contrairement aux sessions, aux jetons et aux passkeys, **seul @nodefony/drizzle fournit un store TOTP** : @nodefony/mongoose et @nodefony/redis n'en enregistrent pas. Sur une infra Mongo, `auto` ne trouve donc pas de backend durable et se replie sur `memory` (repli tracé au boot, et WARNING en production) : les secrets 2FA seraient perdus au redémarrage, verrouillant les utilisateurs hors de leur second facteur. Charger @nodefony/drizzle — même en sqlite local, à côté de Mongo — donne la persistance.")
211
+ }).describe("2FA TOTP (RFC 6238) — second facteur step-up au login. Secret chiffré au repos, codes de récupération hachés.");
212
+ const remoteJwtAlgSchema = z.enum([
213
+ "RS256",
214
+ "RS384",
215
+ "RS512",
216
+ "PS256",
217
+ "PS384",
218
+ "PS512",
219
+ "ES256",
220
+ "ES384",
221
+ "ES512",
222
+ "EdDSA",
223
+ "Ed25519"
224
+ ]);
225
+ const trustedIssuerSchema = z.strictObject({
226
+ issuer: z.string().min(1).describe("Identifiant de l'émetteur (claim `iss`), en https, sans requête ni fragment (RFC 8414 §2) — ex. « https://auth.example.com/realms/mon-royaume ». C'est une ALLOWLIST : un jeton dont l'`iss` n'est pas ici est refusé avant toute requête sortante."),
227
+ jwksUri: z.string().optional().describe("Jeu de clés publiques de l'émetteur. Omis = découvert par les points bien connus RFC 8414/OpenID (3 URL essayées dans l'ordre normatif). Déclaré = aucune découverte (émetteur sans métadonnées, ou démarrage à froid sans requête)."),
228
+ algorithms: z.array(remoteJwtAlgSchema).min(1).default([
229
+ "RS256",
230
+ "ES256",
231
+ "EdDSA"
232
+ ]).describe("Algorithmes de signature acceptés — allowlist SERVEUR (RFC 8725 §3.1) : l'algorithme n'est jamais déduit de l'en-tête du jeton. Restreindre à ce que l'émetteur utilise réellement."),
233
+ typ: z.string().optional().describe("Valeur exigée de l'en-tête `typ` — « at+jwt » pour un émetteur conforme RFC 9068. Non exigé par défaut : le parc réel est inégal, et la séparation entre jetons est déjà portée par l'audience, qui n'est pas facultative."),
234
+ requiredClaims: z.array(z.string()).default([]).describe("Claims dont la PRÉSENCE est exigée, en plus de `iss`/`aud` (toujours obligatoires). Ex. `[\"sub\"]` pour refuser un jeton sans sujet identifiable."),
235
+ subjectMapping: z.enum(["prefixed", "subject"]).default("prefixed").describe("Comment le sujet (`sub`) de CET émetteur devient un identifiant dans l'espace de noms local. « prefixed » (défaut) : l'identifiant cherché est « <issuer>#<sub> » — deux émetteurs ne peuvent pas se disputer un compte, et surtout aucun sujet étranger ne peut tomber par hasard sur un identifiant local existant. « subject » : le `sub` est cherché TEL QUEL, l'ancien comportement — à ne déclarer que si l'on maîtrise l'espace de noms de cet émetteur, typiquement parce qu'il EST cette application (jetons maison) ou parce que ses sujets sont déjà des identifiants locaux. 🔴 Le défaut n'est pas un réglage de confort : un annuaire où l'utilisateur choisit son identifiant permet de présenter `sub: \"admin\"`, et « subject » rattache alors le porteur au compte local du même nom. `sub` n'est unique que dans l'espace de son émetteur (OIDC Core §2) — l'identité est la paire, jamais le sujet seul.")
236
+ });
237
+ const resourceServerSchema = z.strictObject({
238
+ issuers: z.array(trustedIssuerSchema).default([]).describe("Émetteurs dont l'application accepte les jetons. VIDE (défaut) = aucun jeton tiers n'est vérifiable, et le service `accessTokenVerifier` n'est pas posé — une porte protégée refusera alors de servir plutôt que d'accepter des porteurs qu'elle ne sait pas lire. Un seul réglage commande le rôle : pas de drapeau `enabled` qui permettrait « activé sans émetteur »."),
239
+ subjectPolicy: z.enum(["require", "ephemeral"]).default("require").describe("Comment le sujet d'un jeton tiers (`sub`) devient un utilisateur de CETTE application. « require » (défaut) : le sujet doit correspondre à un compte local (`loadUserByIdentifier`) — absent, désactivé ou verrouillé, l'accès est refusé. « ephemeral » : aucun compte local n'est exigé ni créé, l'appelant vit le temps de la requête avec les rôles de `ephemeralRoles` — c'est le mode de l'appelant PUREMENT machine (agent, service). Le défaut est « require » parce qu'un jeton d'un annuaire d'entreprise vaut pour des milliers de comptes : l'accepter d'office ferait de cet annuaire l'unique autorité d'accès à l'application, et supprimerait la seconde décision — locale — qui est la raison d'être du pare-feu."),
240
+ ephemeralRoles: z.array(z.string().min(1)).default([]).describe("Rôles accordés à un appelant accepté en mode « ephemeral ». VIDE par défaut, et ce n'est pas une omission : sans rôle, l'appelant ne passe aucun `@IsGranted` et n'est autorisé que par ses SCOPES (`@RequireScope`), qui viennent du jeton et sont bornés par le serveur d'autorisation. Y écrire un rôle revient à accorder un pouvoir local à quiconque détient un jeton valide pour cette ressource — à faire sciemment, jamais par confort."),
241
+ timeoutMs: z.number().int().min(100).default(5e3).describe("Délai maximal d'une requête vers l'émetteur (découverte + clés)."),
242
+ cooldownMs: z.number().int().min(0).default(3e4).describe("Fenêtre pendant laquelle le jeu de clés n'est PAS redemandé après un succès. Protège l'émetteur d'un flot de jetons portant des `kid` inconnus."),
243
+ cacheMaxAgeMs: z.number().int().min(0).default(6e5).describe("Âge maximal du jeu de clés en cache (10 min) — au-delà, rafraîchi à la prochaine vérification. Borne le délai de prise en compte d'une rotation de clés."),
244
+ clockToleranceS: z.number().int().min(0).max(300).default(5).describe("Tolérance d'horloge sur `exp`/`nbf` (s). Couvre la dérive entre l'émetteur et cette machine, sans prolonger utilement un jeton expiré.")
245
+ }).describe("Rôle SERVEUR DE RESSOURCE — accepter des jetons émis par un serveur d'autorisation TIERS (Keycloak, Auth0, Entra, agents). Distinct de `jwt`, qui décrit les jetons que Nodefony émet lui-même. Alimente le service `accessTokenVerifier` (contrat `IAccessTokenVerifier` du cœur), consommé par toute porte protégée — serveur MCP aujourd'hui, portes agentiques ensuite.");
246
+ const tokenExchangeSchema = z.strictObject({ enabled: z.boolean().default(false).describe("Délégation RFC 8693 (agent agit on-behalf-of un user, chaîne `act` auditable). Slot P12 — non implémenté.") }).describe("Token Exchange (RFC 8693) — délégation agents/services.");
247
+ const apiKeysSchema = z.strictObject({
248
+ enabled: z.boolean().default(true),
249
+ prefix: z.string().min(1).max(12).regex(/^[a-z0-9]+$/, "préfixe = minuscules/chiffres (charset base64url)").default("nf").describe("Marque des clés émises. Format : `<prefix>_<pubid><secret><crc>` — UN SEUL séparateur `_`, le reste est positionnel (8 + 43 + 6 caractères), car le charset base64url contient lui-même `_` et `-` : découper une clé sur les `_` la casse. Ex. « nf » → `nf_a1b2c3d4XXXX…z9z9z9`, dont `nf_a1b2c3d4` est l'identifiant public affichable."),
250
+ defaultExpiryDays: z.number().int().positive().nullable().default(90).describe("Expiration par défaut (null = jamais)."),
251
+ lastUsedThrottleS: z.number().int().nonnegative().default(60).describe("Coalescence d'écriture de `lastUsedAt` (s) — n'écrit pas le store à chaque requête (règle perf). 0 = écrit à chaque usage."),
252
+ maxPerSubject: z.number().int().positive().default(100).describe("Plafond de clés actives par porteur (anti-abus du store). Création au-delà → 409."),
253
+ allowedScopes: z.array(z.string().min(1)).nullable().default(null).describe("Catalogue de scopes autorisés à la création (null = libre). L'autorisation réelle reste les rôles frais ∩ scopes à l'usage.")
254
+ }).describe("Clés API (PAT style GitHub/Claude) — hashées au repos, affichées 1×.");
255
+ const webhooksSchema = z.strictObject({
256
+ enabled: z.boolean().default(true),
257
+ signAlg: z.enum(["sha256"]).default("sha256").meta({
258
+ reserved: true,
259
+ description: "INERTE — l'algorithme de signature est fixé à HMAC-SHA256 (`webhookSignature.ts` `signStandardWebhook`). La seule valeur admise (`sha256`) est déjà le comportement réel ; le slot Ed25519 (Standard Webhooks v1a) n'est pas implémenté."
260
+ }),
261
+ timestampToleranceS: z.number().int().positive().default(300).meta({
262
+ reserved: true,
263
+ description: "INERTE côté ÉMETTEUR — Nodefony estampille `webhook-timestamp` mais la fenêtre anti-rejeu est appliquée par le RÉCEPTEUR (Standard Webhooks). Le framework n'a pas de vérificateur récepteur : ce réglage ne borne rien ici."
264
+ }),
265
+ denyPrivateIps: z.boolean().default(true).describe("Bloque SSRF (IP privées / métadonnées cloud)."),
266
+ snapshotTtlS: z.number().int().positive().default(30).describe("Fraîcheur (secondes) du cache d'endpoints qui sert au routage des livraisons. Un pod ne voit PAS les endpoints créés par un autre pod : le store est partagé, pas le cache. Passé ce délai, le premier événement d'audit déclenche une relecture en arrière-plan (aucun timer, aucun coût sans trafic) — donc la propagation entre pods est bornée par cette valeur, jamais immédiate. Baisser pour propager plus vite (plus de lectures du store), monter si les endpoints changent rarement."),
267
+ maxRetries: z.number().int().nonnegative().default(5).describe("Tentatives de livraison avant abandon."),
268
+ autoDisableThreshold: z.number().int().nonnegative().default(20).describe("Échecs consécutifs avant désactivation auto d'un endpoint (façon GitHub). 0 = jamais."),
269
+ deliveryTimeoutMs: z.number().int().positive().default(1e4).describe("Délai max d'une tentative de livraison (ms)."),
270
+ maxConcurrent: z.number().int().positive().default(8).describe("Livraisons simultanées max (pool) — borne la charge sortante : un endpoint lent/mort ne peut pas saturer sockets/FD."),
271
+ maxQueue: z.number().int().positive().default(1e3).describe("File d'attente max des livraisons. Au-delà : DROP + log (webhook = best-effort, jamais de croissance mémoire illimitée)."),
272
+ allowHttp: z.boolean().default(false).describe("Autorise http:// (dev only). Prod : https:// obligatoire."),
273
+ store: z.string().default("auto").describe("Backend des endpoints : auto (suit l'infra database déclarée, repli memory) | memory (dev) | drizzle | mongoose. Câblé via registerWebhookStore()."),
274
+ encryptionKey: z.string().optional().describe("Clé de chiffrement des secrets de signature au repos (HKDF→AES-256-GCM). Prod : OBLIGATOIRE (absente = webhooks OFF). Dev : clé éphémère + warn.")
275
+ }).describe("Webhooks sortants signés (Standard Webhooks).");
276
+ const auditSchema = z.strictObject({
277
+ enabled: z.boolean().default(true),
278
+ store: z.string().default("auto").describe("Store du journal (résolu via `auditStoreRegistry`). `auto` = suit l'infra database déclarée, repli memory (défaut) ; `memory` = per-pod, volatile, borné ; `drizzle` = persistant + partagé multi-pod (auto-register par l'adapter). Vocabulaire unifié : données = `store`."),
279
+ immutable: z.boolean().default(true).meta({
280
+ reserved: true,
281
+ description: "INERTE — l'immuabilité du journal vient du contrat `IAuditStore` (aucune méthode `update`/`delete`), pas de ce drapeau. Le passer à `false` n'ouvre aucune mutation."
282
+ }),
283
+ stream: z.boolean().default(true).meta({
284
+ reserved: true,
285
+ description: "INERTE — la diffusion realtime dépend de la présence d'abonnés WS (canal `nodefony:audit`), pas de ce drapeau. Le journal est diffusé dès qu'un client s'abonne."
286
+ }),
287
+ retentionDays: z.number().int().default(365)
288
+ }).describe("Journal d'audit sécurité (login, accès refusé, clés, webhooks).");
289
+ const studioSchema = z.strictObject({
290
+ enabled: z.boolean().default(false).describe("Console admin Studio. OFF par défaut (surtout en prod)."),
291
+ exposure: z.enum([
292
+ "localhost",
293
+ "private",
294
+ "public"
295
+ ]).default("localhost").describe("Portée réseau autorisée."),
296
+ allowedIps: z.array(z.string()).default([]).describe("Whitelist CIDR (deny par défaut si exposure=public)."),
297
+ requireMfa: z.boolean().default(false).describe("MFA obligatoire pour l'accès admin. Défaut false : l'enforcement MFA n'est PAS encore câblé (à venir, P6) — un défaut true mentirait (Studio l'afficherait « requis » sans aucun effet). La déclaration des authenticators de la zone admin vit dans l'aire data plane (portée par le framework), pas ici : studioSchema ne durcit que l'EXPOSITION réseau."),
298
+ auditAllActions: z.boolean().default(true).meta({
299
+ reserved: true,
300
+ description: "INERTE — l'audit des mutations admin est posé point par point par `SecurityAdminApi` (certaines mutations émettent, pas toutes) ; ce drapeau ne pilote aucune couverture globale."
301
+ })
302
+ }).describe("Sécurité de la console Studio — durcissement exposition publique.");
303
+ const oauthProviderSchema = z.strictObject({
304
+ clientId: z.string().min(1).describe("Identifiant client OAuth délivré par le fournisseur."),
305
+ clientSecret: z.string().min(1).describe("Secret client OAuth — SECRET, fourni par env, jamais loggé."),
306
+ redirectUri: z.string().min(1).describe("URL de callback EXACTE (RFC 9700 : exact string matching) enregistrée chez le fournisseur — doit pointer sur .../oauth2/<provider>/callback."),
307
+ issuer: z.string().optional().describe("Émetteur OIDC self-hosted (Keycloak : URL du realm, ex. https://kc.example/realms/app). REQUIS pour keycloak ; ignoré par les fournisseurs à endpoints fixes (Google/GitHub)."),
308
+ scopes: z.array(z.string()).default([]).describe("Scopes demandés. Vide = défauts du fournisseur (Google: openid/profile/email ; GitHub: read:user/user:email)."),
309
+ successRedirect: z.string().optional().describe("Redirection succès — surcharge le global pour CE fournisseur."),
310
+ failureRedirect: z.string().optional().describe("Redirection échec — surcharge le global pour CE fournisseur."),
311
+ defaultRoles: z.array(z.string()).optional().describe("Rôles du Shadow User à la création — surcharge le global pour CE fournisseur.")
312
+ }).describe("Fournisseur OAuth/OIDC (secrets via env).");
313
+ const oauth2Schema = z.strictObject({
314
+ enabled: z.boolean().default(true).describe("Active le social login (les routes ne montent que si ≥1 provider)."),
315
+ defaultRoles: z.array(z.string()).default(["ROLE_USER"]).describe("Rôles du Shadow User à la CRÉATION uniquement (OAuth = authentification, pas autorisation : les rôles ne sont jamais réécrits ensuite, la base fait foi)."),
316
+ allowSignup: z.boolean().default(true).describe("true = crée une ligne locale au 1er login (JIT, Shadow User). false = un compte préexistant lié est requis (fail-closed)."),
317
+ successRedirect: z.string().default("/").describe("Redirection après login réussi."),
318
+ failureRedirect: z.string().default("/login").describe("Redirection après échec (state invalide, refus utilisateur...)."),
319
+ providers: z.record(z.string(), oauthProviderSchema).default({}).describe("Fournisseurs activés par nom (doit correspondre au registre : builtins google/keycloak/github ; +50 via arctic en enregistrant une fabrique).")
320
+ }).describe("Social login OAuth 2.0 (arctic) — Authorization Code + PKCE, session BFF.");
321
+ /**
322
+ * Règle d'autorisation d'un **namespace de canaux WebSocket** (subscribe/inbound)
323
+ * par préfixe. Contraintes cumulatives ; un champ absent = pas de contrainte sur
324
+ * cet axe. Étend OU surcharge les défauts système (placée AVANT eux à l'évaluation).
325
+ */
326
+ const realtimeChannelRuleSchema = z.strictObject({
327
+ pattern: z.string().describe("Préfixe de canal (match `startsWith`) : ex. 'orm:' couvre nodefony:orm:health/nodefony:orm:flow. Placé AVANT les défauts système → permet de surcharger (assouplir/durcir) un namespace réservé."),
328
+ authenticated: z.boolean().optional().describe("Exige une connexion authentifiée (token non anonyme)."),
329
+ roles: z.array(z.string()).optional().describe("Un de ces rôles suffit (évalué AVEC la hiérarchie de rôles)."),
330
+ scopes: z.array(z.string()).optional().describe("Un de ces scopes suffit (axe API : JWT/clé API ; une session BFF n'en porte pas).")
331
+ }).describe("Politique d'autorisation d'un namespace de canaux WS.");
332
+ const securityConfigSchema = z.strictObject({
333
+ encoders: z.record(z.string(), encoderSchema).default(() => ({ default: encoderSchema.parse({ type: "argon2id" }) })).describe("Chaîne d'encodeurs de mot de passe. 1re entrée = principal (hash à la création), suivantes = legacy en lecture seule (migration au login). Défaut : un argon2id sûr — le firewall en dérive le service `passwordEncoder`."),
334
+ roleHierarchy: z.record(z.string(), z.array(z.string())).default({}).describe("Hiérarchie de rôles (ROLE_ADMIN: ['ROLE_USER'])."),
335
+ areas: z.record(z.string(), areaSchema).default({}).describe("Zones sécurisées (firewall) par nom."),
336
+ realtimeChannels: z.array(realtimeChannelRuleSchema).default([]).describe("Politiques d'autorisation par préfixe sur le temps réel. Couvre les canaux WS (subscribe/inbound) **ET les actions RPC** : le nom de méthode d'une frame entrante est soumis aux mêmes règles de préfixe — une règle `admin:` garde donc aussi l'action `admin:purge`. Étend/surcharge les défauts système (syslog:/orm:/node:/dashboard:/debugbar:/realtime:/cluster: + :health/:stats = ROLE_ADMIN). Ce qui n'est couvert par aucune règle reste libre, sauf gating `@RealtimeChannel` côté controller."),
337
+ cors: corsSchema.default(() => corsSchema.parse({})),
338
+ csrf: csrfSchema.default(() => csrfSchema.parse({})),
339
+ headers: headersSchema.default(() => headersSchema.parse({})),
340
+ rateLimit: rateLimitSchema.default(() => rateLimitSchema.parse({})),
341
+ jwt: jwtSchema.default(() => jwtSchema.parse({})),
342
+ tokenStore: tokenStoreSchema.default(() => tokenStoreSchema.parse({})),
343
+ passkeys: passkeysSchema.default(() => passkeysSchema.parse({})),
344
+ totp: totpSchema.default(() => totpSchema.parse({})),
345
+ resourceServer: resourceServerSchema.default(() => resourceServerSchema.parse({})),
346
+ tokenExchange: tokenExchangeSchema.default(() => tokenExchangeSchema.parse({})).meta({
347
+ reserved: true,
348
+ description: "RÉSERVÉ — Token Exchange RFC 8693 (délégation agents/MCP). Slot P12, aucun champ encore lu par le runtime."
349
+ }),
350
+ oauth2: oauth2Schema.default(() => oauth2Schema.parse({})),
351
+ apiKeys: apiKeysSchema.default(() => apiKeysSchema.parse({})),
352
+ webhooks: webhooksSchema.default(() => webhooksSchema.parse({})),
353
+ audit: auditSchema.default(() => auditSchema.parse({})),
354
+ studio: studioSchema.default(() => studioSchema.parse({})).meta({
355
+ reserved: true,
356
+ description: "RÉSERVÉ — durcissement de l'exposition réseau de la console Studio. Section entière non câblée (l'enforcement viendra avec Studio, P14) : aucun champ n'est lu par le runtime aujourd'hui."
357
+ })
358
+ });
359
+ /**
360
+ * Défauts du module, matérialisés depuis le schéma (source unique — tous les
361
+ * `.default()` : Zero Trust, CORS strict, Studio OFF). Toujours valides par
362
+ * construction ; passés au `super(..., config)` du Module class.
363
+ */
364
+ const config = securityConfigSchema.parse({});
365
+ //#endregion
366
+ export { config as default, securityConfigSchema };
@@ -0,0 +1,35 @@
1
+ import { securityConfigSchema } from "./config.js";
2
+ import { BootConfigurationError, parseModuleConfig } from "nodefony";
3
+ import { z } from "zod";
4
+ //#region nodefony/config/defineModuleConfig.ts
5
+ /**
6
+ * Valide + normalise + gèle la configuration de sécurité.
7
+ *
8
+ * @param config - configuration brute de l'app (sections omises = défauts sûrs).
9
+ * @returns config gelée prête pour le firewall.
10
+ * @throws ZodError si invalide ; Error si deux zones partagent un pattern.
11
+ */
12
+ function defineSecurityConfig(config = {}) {
13
+ const validated = parseModuleConfig(securityConfigSchema, config, "@nodefony/security");
14
+ detectConflicts(validated.areas);
15
+ return Object.freeze(validated);
16
+ }
17
+ /**
18
+ * JSON Schema introspectable de la config sécurité — **Studio génère son
19
+ * formulaire d'édition depuis ça** (labels/types/défauts/descriptions),
20
+ * sans UI hardcodée. Surface du data plane `/nodefony/security/api/config/schema`.
21
+ */
22
+ function securityConfigJsonSchema() {
23
+ return z.toJSONSchema(securityConfigSchema);
24
+ }
25
+ /** Refuse deux zones avec le même pattern (ambiguïté de match, détectée au boot). */
26
+ function detectConflicts(areas) {
27
+ const seen = /* @__PURE__ */ new Map();
28
+ for (const [name, area] of Object.entries(areas)) {
29
+ const prev = seen.get(area.pattern);
30
+ if (prev) throw new BootConfigurationError(`[@nodefony/security] zones "${prev}" et "${name}" partagent le motif "${area.pattern}" — deux zones qui matchent la même route rendent la règle appliquée imprévisible. Renomme le motif de l'une des deux.`);
31
+ seen.set(area.pattern, name);
32
+ }
33
+ }
34
+ //#endregion
35
+ export { defineSecurityConfig, securityConfigJsonSchema };
@@ -0,0 +1,13 @@
1
+ //#region nodefony/contracts/IAccessVoter.ts
2
+ /**
3
+ * Vote d'un {@link IAccessVoter}. Stratégie firewall = affirmative + DENY veto :
4
+ * un seul DENY bloque ; sinon un GRANT suffit ; tous ABSTAIN → DENY (Zero Trust).
5
+ */
6
+ let VoterVote = /* @__PURE__ */ function(VoterVote) {
7
+ VoterVote["GRANT"] = "GRANT";
8
+ VoterVote["DENY"] = "DENY";
9
+ VoterVote["ABSTAIN"] = "ABSTAIN";
10
+ return VoterVote;
11
+ }({});
12
+ //#endregion
13
+ export { VoterVote };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import { VoterVote } from "./IAccessVoter.js";
2
+ export { VoterVote };
@@ -0,0 +1,14 @@
1
+ import { nodefonyError } from "nodefony";
2
+ //#region nodefony/errors/AccessDeniedError.ts
3
+ /**
4
+ * Accès refusé — `code = 403`. Levée par l'autorisation (un `@IsGranted` non
5
+ * satisfait, un voter DENY) : l'utilisateur EST authentifié mais n'a pas le droit.
6
+ * À distinguer d'{@link AuthenticationError} (401 = pas authentifié).
7
+ */
8
+ var AccessDeniedError = class extends nodefonyError {
9
+ constructor(message = "Access denied") {
10
+ super(message, 403);
11
+ }
12
+ };
13
+ //#endregion
14
+ export { AccessDeniedError, AccessDeniedError as default };
@@ -0,0 +1,21 @@
1
+ import { nodefonyError } from "nodefony";
2
+ //#region nodefony/errors/ApiKeyError.ts
3
+ /**
4
+ * Erreur de **gestion** d'une clé API (création/révocation) — porte un `code`
5
+ * HTTP que l'adaptateur framework mappe par duck-typing (il n'importe jamais les
6
+ * classes de `@nodefony/security`).
7
+ *
8
+ * - `400` — entrée invalide (nom vide, scope hors catalogue) ;
9
+ * - `409` — plafond `apiKeys.maxPerSubject` atteint ;
10
+ * - `503` — store de jetons indisponible (clés activées mais non provisionnées).
11
+ *
12
+ * Distincte de l'**authentification** d'une clé présentée (→ `AuthenticationError`
13
+ * 401, message uniforme anti-énumération).
14
+ */
15
+ var ApiKeyError = class extends nodefonyError {
16
+ constructor(message, code) {
17
+ super(message, code);
18
+ }
19
+ };
20
+ //#endregion
21
+ export { ApiKeyError, ApiKeyError as default };
@@ -0,0 +1,14 @@
1
+ import { nodefonyError } from "nodefony";
2
+ //#region nodefony/errors/AuthenticationError.ts
3
+ /**
4
+ * Échec d'authentification — `code = 401`. Levée par un {@link IAuthenticator}
5
+ * quand le credential est absent/invalide, ou par le firewall en Zero Trust
6
+ * (zone protégée + visiteur anonyme + route sans `@Anonymous`).
7
+ */
8
+ var AuthenticationError = class extends nodefonyError {
9
+ constructor(message = "Authentication required") {
10
+ super(message, 401);
11
+ }
12
+ };
13
+ //#endregion
14
+ export { AuthenticationError, AuthenticationError as default };
@@ -0,0 +1,23 @@
1
+ import { nodefonyError } from "nodefony";
2
+ //#region nodefony/errors/CsrfError.ts
3
+ /**
4
+ * Mutation cross-site bloquée — `code = 403` (RFC 9110 §15.5.4 : le serveur a
5
+ * compris la requête mais refuse de l'honorer).
6
+ *
7
+ * Levée par {@link Csrf} sur une méthode state-changing (POST/PUT/PATCH/DELETE)
8
+ * dont la provenance est tierce : `Sec-Fetch-Site: cross-site` (défense primaire
9
+ * Fetch Metadata, W3C — infalsifiable par un script attaquant) ou, à défaut de
10
+ * Fetch Metadata, un `Origin`/`Referer` étranger aux origines de l'app (fallback).
11
+ *
12
+ * Le message reste GÉNÉRIQUE : la politique CSRF (en-têtes inspectés, whitelist)
13
+ * ne fuite jamais au client. Distincte du 401 (qui es-tu ?) et de l'AccessDenied
14
+ * (autorisé mais rôle insuffisant) : ici l'identité importe peu, c'est la
15
+ * PROVENANCE de la requête qui est rejetée.
16
+ */
17
+ var CsrfError = class extends nodefonyError {
18
+ constructor(message = "Cross-site request blocked") {
19
+ super(message, 403);
20
+ }
21
+ };
22
+ //#endregion
23
+ export { CsrfError, CsrfError as default };