@nodefony/security 10.0.0-beta.2 → 10.0.0-beta.4

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 (249) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  2. package/dist/index.js +48 -16
  3. package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +62 -24
  4. package/dist/nodefony/command/security-oauth-doctor.js +68 -0
  5. package/dist/nodefony/command/security-secrets.js +52 -29
  6. package/dist/nodefony/command/security-user-add.js +9 -3
  7. package/dist/nodefony/command/security-user-delete.js +10 -7
  8. package/dist/nodefony/command/security-user-list.js +5 -3
  9. package/dist/nodefony/command/security-user-password.js +9 -14
  10. package/dist/nodefony/config/config.js +99 -8
  11. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  12. package/dist/nodefony/contracts/IAccessVoter.js +1 -2
  13. package/dist/nodefony/contracts/index.js +1 -2
  14. package/dist/nodefony/errors/AccessDeniedError.js +1 -2
  15. package/dist/nodefony/errors/ApiKeyError.js +3 -2
  16. package/dist/nodefony/errors/AuthenticationError.js +1 -2
  17. package/dist/nodefony/errors/CsrfError.js +1 -2
  18. package/dist/nodefony/errors/InsecureTransportError.js +23 -0
  19. package/dist/nodefony/errors/InvalidTargetError.js +1 -2
  20. package/dist/nodefony/errors/SsrfError.js +1 -2
  21. package/dist/nodefony/errors/ThrottledError.js +1 -2
  22. package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
  23. package/dist/nodefony/errors/WebAuthnError.js +1 -2
  24. package/dist/nodefony/errors/index.js +2 -1
  25. package/dist/nodefony/keycloak/keycloakAdmin.js +254 -0
  26. package/dist/nodefony/keycloak/keycloakRealm.js +259 -0
  27. package/dist/nodefony/keycloak/keycloakRealmInput.js +123 -0
  28. package/dist/nodefony/keycloak/scaffold.js +231 -0
  29. package/dist/nodefony/keycloak/security-keycloak-realm.js +130 -0
  30. package/dist/nodefony/scaffold/contribute.js +19 -0
  31. package/dist/nodefony/service/accessTokenVerifier.js +101 -34
  32. package/dist/nodefony/service/apiKeys.js +45 -5
  33. package/dist/nodefony/service/auditService.js +4 -5
  34. package/dist/nodefony/service/authFlow.js +118 -16
  35. package/dist/nodefony/service/authorization.js +1 -2
  36. package/dist/nodefony/service/cors.js +1 -2
  37. package/dist/nodefony/service/csrf.js +1 -2
  38. package/dist/nodefony/service/firewall.js +116 -25
  39. package/dist/nodefony/service/keycloak.js +208 -0
  40. package/dist/nodefony/service/oauth2.js +403 -28
  41. package/dist/nodefony/service/securityHeaders.js +1 -2
  42. package/dist/nodefony/service/tokenService.js +94 -27
  43. package/dist/nodefony/service/totp.js +55 -11
  44. package/dist/nodefony/service/webAuthn.js +24 -7
  45. package/dist/nodefony/service/webhooks.js +4 -5
  46. package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
  47. package/dist/nodefony/src/SecuredArea.js +15 -5
  48. package/dist/nodefony/src/accountBinding.js +96 -0
  49. package/dist/nodefony/src/admin/SecurityAdminApi.js +71 -42
  50. package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
  51. package/dist/nodefony/src/admin/adminAudit.js +1 -3
  52. package/dist/nodefony/src/admin/userRevocationCascade.js +148 -20
  53. package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
  54. package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
  55. package/dist/nodefony/src/audit/auditBridge.js +1 -2
  56. package/dist/nodefony/src/audit/auditFilters.js +1 -2
  57. package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
  58. package/dist/nodefony/src/audit/readAuditContext.js +1 -2
  59. package/dist/nodefony/src/audit/recordAudit.js +1 -2
  60. package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
  61. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
  62. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +14 -59
  63. package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
  64. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +80 -19
  65. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +92 -16
  66. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
  67. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +14 -5
  68. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +5 -12
  69. package/dist/nodefony/src/authenticator/bearer.js +1 -2
  70. package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
  71. package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
  72. package/dist/nodefony/src/boot/publicRoutesNotice.js +26 -0
  73. package/dist/nodefony/src/crypto/secretCipher.js +1 -2
  74. package/dist/nodefony/src/csp.js +1 -2
  75. package/dist/nodefony/src/csrfToken.js +1 -2
  76. package/dist/nodefony/src/entryPoint.js +38 -0
  77. package/dist/nodefony/src/net/ssrfGuard.js +1 -2
  78. package/dist/nodefony/src/net/transportFailure.js +57 -0
  79. package/dist/nodefony/src/oauth/clientCredentials.js +39 -0
  80. package/dist/nodefony/src/oauth/httpJson.js +3 -3
  81. package/dist/nodefony/src/oauth/metadata.js +28 -12
  82. package/dist/nodefony/src/oauth/oauth2Client.js +61 -35
  83. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +40 -8
  84. package/dist/nodefony/src/oauth/providerDiagnosis.js +282 -0
  85. package/dist/nodefony/src/oauth/providerRoles.js +78 -0
  86. package/dist/nodefony/src/oauth/providers/github.js +48 -12
  87. package/dist/nodefony/src/oauth/providers/oidc.js +90 -8
  88. package/dist/nodefony/src/openToSignup.js +46 -0
  89. package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
  90. package/dist/nodefony/src/realtime/frameAuthorizer.js +1 -2
  91. package/dist/nodefony/src/rolesWithin.js +57 -0
  92. package/dist/nodefony/src/sessionAuthentication.js +25 -0
  93. package/dist/nodefony/src/sessionIdentity.js +29 -3
  94. package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
  95. package/dist/nodefony/src/token/AnonymousToken.js +1 -2
  96. package/dist/nodefony/src/token/JwtKeystore.js +89 -48
  97. package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
  98. package/dist/nodefony/src/token/RemoteJwtVerifier.js +46 -16
  99. package/dist/nodefony/src/token/UserToken.js +1 -2
  100. package/dist/nodefony/src/token/jwtRuntime.js +1 -2
  101. package/dist/nodefony/src/token/scopeGrants.js +109 -0
  102. package/dist/nodefony/src/token/secretFile.js +60 -4
  103. package/dist/nodefony/src/token/tokenCriteria.js +1 -2
  104. package/dist/nodefony/src/token/tokenFilters.js +1 -2
  105. package/dist/nodefony/src/token/tokenSort.js +1 -2
  106. package/dist/nodefony/src/token/tokenStatus.js +1 -2
  107. package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
  108. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
  109. package/dist/nodefony/src/totp/totpCipher.js +1 -2
  110. package/dist/nodefony/src/totp/totpCrypto.js +1 -2
  111. package/dist/nodefony/src/totp/totpOperations.js +1 -2
  112. package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
  113. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +50 -0
  114. package/dist/nodefony/src/voter/RoleVoter.js +1 -2
  115. package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
  116. package/dist/nodefony/src/voter/voterRegistry.js +1 -2
  117. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
  118. package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
  119. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
  120. package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
  121. package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
  122. package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
  123. package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
  124. package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
  125. package/dist/nodefony/src/webhook/webhookSort.js +1 -2
  126. package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
  127. package/dist/types/index.d.ts +26 -6
  128. package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +16 -6
  129. package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
  130. package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
  131. package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
  132. package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
  133. package/dist/types/nodefony/config/config.d.ts +64 -1
  134. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
  135. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
  136. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
  137. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
  138. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
  139. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
  140. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
  141. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
  142. package/dist/types/nodefony/errors/ApiKeyError.d.ts +3 -1
  143. package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
  144. package/dist/types/nodefony/errors/index.d.ts +1 -0
  145. package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
  146. package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
  147. package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
  148. package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
  149. package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
  150. package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
  151. package/dist/types/nodefony/service/accessTokenVerifier.d.ts +35 -14
  152. package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
  153. package/dist/types/nodefony/service/authFlow.d.ts +35 -3
  154. package/dist/types/nodefony/service/firewall.d.ts +17 -3
  155. package/dist/types/nodefony/service/keycloak.d.ts +115 -0
  156. package/dist/types/nodefony/service/oauth2.d.ts +145 -9
  157. package/dist/types/nodefony/service/tokenService.d.ts +7 -1
  158. package/dist/types/nodefony/service/totp.d.ts +28 -4
  159. package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
  160. package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
  161. package/dist/types/nodefony/src/accountBinding.d.ts +82 -0
  162. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +72 -12
  163. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -26
  164. package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
  165. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +17 -1
  166. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
  167. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
  168. package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
  169. package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
  170. package/dist/types/nodefony/src/net/transportFailure.d.ts +22 -0
  171. package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
  172. package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
  173. package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
  174. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
  175. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
  176. package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
  177. package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
  178. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
  179. package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
  180. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +7 -1
  181. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
  182. package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
  183. package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
  184. package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -0
  185. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
  186. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
  187. package/dist/types/nodefony/src/token/scopeGrants.d.ts +77 -0
  188. package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
  189. package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
  190. package/docs/api-keys.md +182 -175
  191. package/docs/audit.md +141 -141
  192. package/docs/authenticators.md +129 -96
  193. package/docs/authorization.md +116 -69
  194. package/docs/cors.md +64 -64
  195. package/docs/csrf.md +77 -72
  196. package/docs/external-jwt.md +83 -11
  197. package/docs/firewall.md +119 -83
  198. package/docs/headers.md +82 -82
  199. package/docs/index.md +16 -8
  200. package/docs/keycloak.md +835 -0
  201. package/docs/login-page.md +361 -0
  202. package/docs/oauth2.md +291 -166
  203. package/docs/obtenir-un-jeton.md +131 -34
  204. package/docs/tokens.md +197 -140
  205. package/docs/totp.md +177 -136
  206. package/docs/webauthn.md +157 -154
  207. package/docs/webhooks.md +128 -125
  208. package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
  209. package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
  210. package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
  211. package/keycloak/themes/nodefony/account/theme.properties +14 -0
  212. package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
  213. package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
  214. package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
  215. package/keycloak/themes/nodefony/admin/theme.properties +12 -0
  216. package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
  217. package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
  218. package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
  219. package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
  220. package/keycloak/themes/nodefony/email/theme.properties +8 -0
  221. package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
  222. package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
  223. package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
  224. package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
  225. package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
  226. package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
  227. package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
  228. package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
  229. package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
  230. package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
  231. package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
  232. package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
  233. package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
  234. package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
  235. package/keycloak/themes/nodefony/login/template.ftl +423 -0
  236. package/keycloak/themes/nodefony/login/theme.properties +17 -0
  237. package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
  238. package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
  239. package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
  240. package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
  241. package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
  242. package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
  243. package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
  244. package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
  245. package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
  246. package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
  247. package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
  248. package/package.json +19 -17
  249. package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
package/docs/webhooks.md CHANGED
@@ -58,8 +58,8 @@ flowchart LR
58
58
  ```
59
59
 
60
60
  Deux frontières décident de tout : **`WebhookDispatcher.onAuditEvent()`**
61
- (`WebhookDispatcher.ts:132`) qui filtre sans jamais bloquer l'émetteur, et
62
- **`WebhookDispatcher.#process()`** (`WebhookDispatcher.ts:189`) qui signe, livre et classe l'issue.
61
+ (`WebhookDispatcher.ts#WebhookDispatcher.onAuditEvent`) qui filtre sans jamais bloquer l'émetteur, et
62
+ **`WebhookDispatcher.#process()`** (`WebhookDispatcher.ts#WebhookDispatcher.#process`) qui signe, livre et classe l'issue.
63
63
  Le détail de chaque étape est plus bas, dans **Architecture interne**.
64
64
 
65
65
  ### À quoi ça sert, concrètement
@@ -146,29 +146,29 @@ propre serveur**.
146
146
  Trois partis pris, tous vérifiables dans le code :
147
147
 
148
148
  1. **La source est le journal d'audit, pas un bus métier.** `WebhookService.#attachDispatcher()`
149
- (`webhooks.ts:198`) s'abonne au service `auditService` et à lui seul. Si l'audit est absent, le
149
+ (`webhooks.ts#WebhookService.#attachDispatcher`) s'abonne au service `auditService` et à lui seul. Si l'audit est absent, le
150
150
  dispatcher n'existe pas — le CRUD d'endpoints reste disponible, mais **rien ne part**. C'est une
151
151
  limite assumée : les webhooks notifient des **événements de sécurité**.
152
152
  2. **Le secret est chiffré, jamais haché.** Contrairement à une clé d'API (qu'on vérifie donc qu'on
153
153
  peut hacher), le serveur doit **relire** le secret pour signer chaque livraison → AES-256-GCM avec
154
- une clé dérivée HKDF propre au domaine webhook (`deriveWebhookKey()`, `webhookCipher.ts:30`).
154
+ une clé dérivée HKDF propre au domaine webhook (`deriveWebhookKey()`, `webhookCipher.ts#deriveWebhookKey`).
155
155
  3. **Le tiers ne peut pas nuire au framework.** File bornée, concurrence bornée, historique borné,
156
156
  abandon annoncé : le dispatcher est écrit pour qu'un endpoint mort coûte un log, pas une panne
157
- (`WebhookDispatcher.#enqueue()`, `WebhookDispatcher.ts:146`).
157
+ (`WebhookDispatcher.#enqueue()`, `WebhookDispatcher.ts#WebhookDispatcher.#enqueue`).
158
158
 
159
159
  ## 🚀 Démarrage rapide
160
160
 
161
161
  ### 1. Activer les webhooks et poser la clé de chiffrement
162
162
 
163
- Les webhooks sont **actifs par défaut** (`enabled: true` dans le schéma Zod, `security/nodefony/config/config.ts:785`).
163
+ Les webhooks sont **actifs par défaut** (`enabled: true` dans le schéma Zod, `security/nodefony/config/config.ts#tokenExchangeSchema`).
164
164
  La seule chose que tu dois vraiment fournir, c'est la **clé de chiffrement des secrets de signature** :
165
165
  sans elle, une clé éphémère est générée en dev (avec un WARNING), et en production les webhooks sont
166
166
  **désactivés** — un secret chiffré par une clé perdue au redémarrage serait illisible
167
- (`WebhookService.#resolveKey()`, `webhooks.ts:316`).
167
+ (`WebhookService.#resolveKey()`, `webhooks.ts#WebhookService.#resolveKey`).
168
168
 
169
169
  ```bash
170
170
  # Génère les clés du module security et guide le câblage en 3 fichiers.
171
- npx nodefony security:secrets --write # écrit NF_WEBHOOK_KEY dans .env.local
171
+ npx nodefony security:secrets --write # écrit NF_WEBHOOK_KEY dans .env
172
172
  ```
173
173
 
174
174
  ```typescript
@@ -203,14 +203,14 @@ export default defineConfig<typeof env>((ctx) => ({
203
203
 
204
204
  > [!WARNING]
205
205
  > `denyPrivateIps: false` **désactive tout le contrôle d'IP** : le garde retourne immédiatement sur
206
- > `allowPrivate`, sans résoudre le DNS ni comparer quoi que ce soit (`ssrfGuard.ts:153`). C'est un
206
+ > `allowPrivate`, sans résoudre le DNS ni comparer quoi que ce soit (`ssrfGuard.ts#IAssertPublicUrlOptions.allowPrivate`). C'est un
207
207
  > réglage de poste de développement, à ne jamais laisser fuiter en production.
208
208
 
209
209
  ### 2. Enregistrer un abonnement (API d'administration)
210
210
 
211
211
  Il n'y a **pas de déclaration d'endpoint en config** : un abonnement est une donnée, créée à
212
212
  l'exécution via le data plane admin `/nodefony/security/api/webhooks`, gardé
213
- `ROLE_NODEFONY_ADMIN` (`webhookAdminEndpoints()`, `WebhookAdminApi.ts:219`).
213
+ `ROLE_NODEFONY_ADMIN` (`webhookAdminEndpoints()`, `WebhookAdminApi.ts#webhookAdminEndpoints`).
214
214
 
215
215
  ```bash
216
216
  # Session admin (le BFF de login pose le cookie)
@@ -244,14 +244,16 @@ C'est la moitié que **tu** écris, côté destinataire. Voici la version courte
244
244
  vingtaine de lignes et fait le seul geste indispensable — **recalculer l'empreinte sur les octets
245
245
  reçus**.
246
246
 
247
- ```typescript
247
+ ```typescript ignore
248
248
  // nodefony/controller/HookMiniController.ts — récepteur minimal
249
249
  import { createHmac, timingSafeEqual } from "node:crypto";
250
250
  import { Buffer } from "node:buffer";
251
251
  // prettier-ignore
252
252
  import { Controller, controller, Post, Body, Headers, BypassFirewall } from "@nodefony/framework";
253
+ // NF_HOOK_SECRET se déclare dans env.ts (`envString`) : seul lecteur de l'environnement.
254
+ import { env } from "../../env";
253
255
 
254
- const SECRET = (process.env.NF_HOOK_SECRET ?? "").replace(/^whsec_/, "");
256
+ const SECRET = (env.NF_HOOK_SECRET ?? "").replace(/^whsec_/, "");
255
257
 
256
258
  @controller("/hooks")
257
259
  class HookMiniController extends Controller {
@@ -300,15 +302,16 @@ signature parmi **plusieurs** (le temps d'une rotation de secret), et **dédupli
300
302
  `webhook-id` avant d'agir — un réessai rejoue le même identifiant, et livrer deux fois une commande
301
303
  n'est pas la même chose que la livrer une fois.
302
304
 
303
- ```typescript
305
+ ```typescript ignore
304
306
  // nodefony/controller/HookController.ts — récepteur complet, compile tel quel
305
307
  import { createHmac, timingSafeEqual } from "node:crypto";
306
308
  import { Buffer } from "node:buffer";
307
309
  // prettier-ignore
308
310
  import { Controller, controller, Post, Body, Headers, BypassFirewall } from "@nodefony/framework";
311
+ import { env } from "../../env";
309
312
 
310
- /** Secret `whsec_…` donné par l'émetteur — via l'environnement, jamais en dur. */
311
- const SECRET = process.env.NF_HOOK_SECRET ?? "";
313
+ /** Secret `whsec_…` donné par l'émetteur — déclaré dans env.ts, jamais en dur. */
314
+ const SECRET = env.NF_HOOK_SECRET ?? "";
312
315
  /** Fenêtre anti-rejeu (s) : un message plus vieux est refusé. */
313
316
  const TOLERANCE_S = 300;
314
317
 
@@ -403,16 +406,16 @@ curl -sk -b /tmp/jar https://localhost:5152/nodefony/security/api/webhooks/wh_9X
403
406
 
404
407
  Un `attempt` supérieur à 0 signale que la trace enregistrée est celle d'une **issue finale après
405
408
  retries** : les tentatives intermédiaires ne sont pas tracées, seule l'issue passe par
406
- `recordDelivery()` (`WebhookDispatcher.ts:247`).
409
+ `recordDelivery()` (`WebhookDispatcher.ts#IWebhookDispatcherDeps.recordDelivery`).
407
410
 
408
411
  ## Quels événements partent en webhook ?
409
412
 
410
413
  Réponse honnête et vérifiable : **les actions du journal d'audit de sécurité, et rien d'autre**. Le
411
- dispatcher est branché sur `AuditService.subscribe()` (`auditService.ts:212`), appelé dans le
412
- fire-and-forget de `AuditService.record()` (`auditService.ts:187`). Aucun autre point d'émission
414
+ dispatcher est branché sur `AuditService.subscribe()` (`auditService.ts#AuditService.subscribe`), appelé dans le
415
+ fire-and-forget de `AuditService.record()` (`auditService.ts#AuditService.record`). Aucun autre point d'émission
413
416
  n'existe dans le code.
414
417
 
415
- Les catégories d'audit disponibles (`AuditCategory`, `IAuditEvent.ts:24`) donnent la surface réelle :
418
+ Les catégories d'audit disponibles (`AuditCategory`, `IAuditEvent.ts#AuditCategory`) donnent la surface réelle :
416
419
 
417
420
  | Catégorie | Ce qu'elle trace (exemples d'`action`) |
418
421
  | ---------- | -------------------------------------------------------------------------- |
@@ -430,12 +433,12 @@ Les catégories d'audit disponibles (`AuditCategory`, `IAuditEvent.ts:24`) donne
430
433
 
431
434
  > [!NOTE]
432
435
  > Trois catégories sont **réservées** au contrat sans émetteur à ce jour : `oauth`, `csrf`, `cors`
433
- > (`IAuditEvent.ts:16`). S'y abonner est accepté, mais ne livre jamais rien.
436
+ > (`IAuditEvent.ts#AuditCategory`). S'y abonner est accepté, mais ne livre jamais rien.
434
437
 
435
438
  ### La syntaxe d'abonnement
436
439
 
437
440
  Le champ `events` d'un endpoint accepte trois formes, résolues par `matchesSubscription()`
438
- (`WebhookDispatcher.ts:30`) :
441
+ (`WebhookDispatcher.ts#matchesSubscription`) :
439
442
 
440
443
  | Motif | Matche | Usage typique |
441
444
  | ----------------- | ----------------------------------------------------- | ------------------------------- |
@@ -445,7 +448,7 @@ Le champ `events` d'un endpoint accepte trois formes, résolues par `matchesSubs
445
448
 
446
449
  > [!CAUTION]
447
450
  > Les événements de catégorie `webhook` **ne déclenchent jamais de livraison**
448
- > (`WebhookDispatcher.ts:136`), même avec un abonnement `"*"`. C'est une garde anti-amplification :
451
+ > (`WebhookDispatcher.ts#WebhookDispatcher.onAuditEvent`), même avec un abonnement `"*"`. C'est une garde anti-amplification :
449
452
  > sans elle, un échec de livraison auditerait `webhook.disabled`, qui déclencherait une livraison,
450
453
  > qui échouerait… Un banc d'attaque le prouve (`webhookDispatch.attack.test.ts`).
451
454
 
@@ -463,8 +466,8 @@ base signée = {webhook-id}.{webhook-timestamp}.{corps JSON}
463
466
  signature = "v1," + base64( HMAC-SHA256( secret, base ) )
464
467
  ```
465
468
 
466
- `buildSignatureBase()` (`webhookSignature.ts:30`) construit la base,
467
- `signStandardWebhook()` (`webhookSignature.ts:47`) produit la valeur d'en-tête. Trois conséquences
469
+ `buildSignatureBase()` (`webhookSignature.ts#buildSignatureBase`) construit la base,
470
+ `signStandardWebhook()` (`webhookSignature.ts#signStandardWebhook`) produit la valeur d'en-tête. Trois conséquences
468
471
  qui comptent :
469
472
 
470
473
  - **L'identifiant du message est couvert** → un attaquant ne peut pas rejouer un corps valide sous
@@ -476,7 +479,7 @@ qui comptent :
476
479
 
477
480
  ### Les trois en-têtes posés
478
481
 
479
- `webhookSignatureHeaders()` (`webhookSignature.ts:60`) retourne :
482
+ `webhookSignatureHeaders()` (`webhookSignature.ts#webhookSignatureHeaders`) retourne :
480
483
 
481
484
  | En-tête | Contenu | Rôle côté récepteur |
482
485
  | ------------------- | --------------------------- | ------------------------------------ |
@@ -484,8 +487,8 @@ qui comptent :
484
487
  | `webhook-timestamp` | epoch **secondes** | fenêtre **anti-rejeu** |
485
488
  | `webhook-signature` | `v1,<base64(HMAC-SHA256)>` | preuve de l'émetteur |
486
489
 
487
- Le secret est un `whsec_<base64 de 256 bits>` généré par `generateSecret()` (`webhooks.ts:117`) ;
488
- `parseWebhookSecret()` (`webhookSignature.ts:22`) décode la partie base64 — le préfixe n'entre pas
490
+ Le secret est un `whsec_<base64 de 256 bits>` généré par `generateSecret()` (`webhooks.ts#generateSecret`) ;
491
+ `parseWebhookSecret()` (`webhookSignature.ts#parseWebhookSecret`) décode la partie base64 — le préfixe n'entre pas
489
492
  dans la clé HMAC, et un secret déjà sans préfixe est toléré.
490
493
 
491
494
  ### Le secret au repos — chiffré, pas haché
@@ -493,7 +496,7 @@ dans la clé HMAC, et un secret déjà sans préfixe est toléré.
493
496
  Une clé d'API se **hache** (on la vérifie, on ne la relit jamais). Un secret de signature doit être
494
497
  **relu à chaque livraison** pour recalculer le HMAC → il est chiffré en AES-256-GCM, avec une clé
495
498
  dérivée par HKDF-SHA256 (RFC 5869) sur un contexte **propre au domaine webhook**
496
- (`WEBHOOK_DERIVATION`, `webhookCipher.ts:21`).
499
+ (`WEBHOOK_DERIVATION`, `webhookCipher.ts#WEBHOOK_DERIVATION`).
497
500
 
498
501
  Ce cloisonnement n'est pas cosmétique : un blob webhook ne se déchiffre **pas** avec la clé TOTP, et
499
502
  réciproquement — un banc d'attaque vérifie cette confusion de domaine, ainsi que le refus des blobs
@@ -514,7 +517,7 @@ curl -sk -b /tmp/jar -X POST \
514
517
  # {"endpoint":{…}, "secret":"whsec_NOUVEAU…"}
515
518
  ```
516
519
 
517
- `WebhookService.rotateSecret()` (`webhooks.ts:560`) régénère et rechiffre. Comportement à connaître
520
+ `WebhookService.rotateSecret()` (`webhooks.ts#WebhookService.rotateSecret`) régénère et rechiffre. Comportement à connaître
518
521
  **avant** de cliquer :
519
522
 
520
523
  - l'ancien secret cesse d'être valide **immédiatement** — il n'y a pas de fenêtre de recouvrement
@@ -527,31 +530,31 @@ curl -sk -b /tmp/jar -X POST \
527
530
  ## 🛡️ Défenses SSRF — ce qu'une URL de destination ne peut pas être
528
531
 
529
532
  C'est la partie la plus attaquée de la brique, et celle qui porte le plus de tests
530
- (`webhookSsrf.attack.test.ts`). Le contrôle vit dans `assertPublicUrl()` (`ssrfGuard.ts:128`),
533
+ (`webhookSsrf.attack.test.ts`). Le contrôle vit dans `assertPublicUrl()` (`ssrfGuard.ts#assertPublicUrl`),
531
534
  appliqué **deux fois** : à l'enregistrement, et **à nouveau juste avant chaque livraison**.
532
535
 
533
536
  ### Ce qui est refusé
534
537
 
535
- | Ce que l'attaquant tente | Exemple | Défense |
536
- | --------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------ |
537
- | Schéma exotique (Gopher, Redis, `file`…) | `redis://1.1.1.1:6379/` | allowlist stricte `https:` (+ `http:` si `allowHttp`) — `ssrfGuard.ts:139` |
538
- | Identifiants embarqués pour masquer l'hôte réel | `https://trusted.com@169.254.169.254/` | refus de `username`/`password` dans l'URL (`SsrfError`, `ssrfGuard.ts:145`) |
539
- | IP littérale interne | `https://127.0.0.1/` | 15 plages IPv4 bloquées (`BLOCKED_V4`, `ssrfGuard.ts:22`) |
540
- | Métadonnées cloud (credentials IAM) | `http://169.254.169.254/` | plage `169.254.0.0/16` bloquée |
541
- | IPv6 interne, ULA, link-local, NAT64, 6to4 | `https://[fe80::1%25eth0]/` | 9 plages IPv6 bloquées (`BLOCKED_V6`, `ssrfGuard.ts:41`) |
542
- | IPv4-mapped IPv6 sous toutes ses notations | `::ffff:7f00:1`, `0:0:0:0:0:ffff:127.0.0.1` | rabattu nativement sur les règles IPv4 par `BlockList` (`isBlockedAddress()`, `ssrfGuard.ts:79`) |
543
- | IP encodée en dword/hex/octal dans le nom d'hôte | `http://2130706433/` | on passe l'IP **résolue** à `isBlockedAddress()`, jamais la chaîne d'hôte (`ssrfGuard.ts:171`) |
544
- | Nom DNS public qui **résout** vers du privé | `hook.evil.com → 10.0.0.5` | toutes les IP résolues sont contrôlées, une seule interne = refus |
545
- | **DNS rebinding** entre le contrôle et la connexion | — | l'IP validée est **pinnée** à la connexion TCP (`webhookDelivery.ts:75`) |
546
- | **Redirection** `302 → 169.254.169.254` | — | `node:http(s)` ne suit **jamais** les 3xx ; le 3xx est rendu tel quel |
538
+ | Ce que l'attaquant tente | Exemple | Défense |
539
+ | --------------------------------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
540
+ | Schéma exotique (Gopher, Redis, `file`…) | `redis://1.1.1.1:6379/` | allowlist stricte `https:` (+ `http:` si `allowHttp`) — `ssrfGuard.ts#IAssertPublicUrlOptions.allowHttp` |
541
+ | Identifiants embarqués pour masquer l'hôte réel | `https://trusted.com@169.254.169.254/` | refus de `username`/`password` dans l'URL (`SsrfError`, `ssrfGuard.ts#assertPublicUrl`) |
542
+ | IP littérale interne | `https://127.0.0.1/` | 15 plages IPv4 bloquées (`BLOCKED_V4`, `ssrfGuard.ts#BLOCKED_V4`) |
543
+ | Métadonnées cloud (credentials IAM) | `http://169.254.169.254/` | plage `169.254.0.0/16` bloquée |
544
+ | IPv6 interne, ULA, link-local, NAT64, 6to4 | `https://[fe80::1%25eth0]/` | 9 plages IPv6 bloquées (`BLOCKED_V6`, `ssrfGuard.ts#BLOCKED_V6`) |
545
+ | IPv4-mapped IPv6 sous toutes ses notations | `::ffff:7f00:1`, `0:0:0:0:0:ffff:127.0.0.1` | rabattu nativement sur les règles IPv4 par `BlockList` (`isBlockedAddress()`, `ssrfGuard.ts#isBlockedAddress`) |
546
+ | IP encodée en dword/hex/octal dans le nom d'hôte | `http://2130706433/` | on passe l'IP **résolue** à `isBlockedAddress()`, jamais la chaîne d'hôte (`ssrfGuard.ts#isBlockedAddress`) |
547
+ | Nom DNS public qui **résout** vers du privé | `hook.evil.com → 10.0.0.5` | toutes les IP résolues sont contrôlées, une seule interne = refus |
548
+ | **DNS rebinding** entre le contrôle et la connexion | — | l'IP validée est **pinnée** à la connexion TCP (`webhookDelivery.ts#deliverWebhook`) |
549
+ | **Redirection** `302 → 169.254.169.254` | — | `node:http(s)` ne suit **jamais** les 3xx ; le 3xx est rendu tel quel |
547
550
 
548
551
  ### Les deux subtilités qui font la différence
549
552
 
550
553
  **Le pin d'IP.** Valider puis se reconnecter, c'est laisser une fenêtre : le DNS peut répondre une IP
551
- publique au contrôle et une IP privée 50 ms plus tard. `deliverWebhook()` (`webhookDelivery.ts:52`)
554
+ publique au contrôle et une IP privée 50 ms plus tard. `deliverWebhook()` (`webhookDelivery.ts#deliverWebhook`)
552
555
  installe un `lookup` qui force la connexion vers l'IP **déjà validée**, tout en conservant le nom
553
556
  d'hôte pour le SNI/TLS et l'en-tête `Host`. Le second contrôle SSRF avant livraison —
554
- `resolveTarget()` (`WebhookDispatcher.ts:209`) — sert exactement à produire ces adresses ; s'il échoue, la livraison est
557
+ `resolveTarget()` (`WebhookDispatcher.ts#IWebhookDispatcherDeps.resolveTarget`) — sert exactement à produire ces adresses ; s'il échoue, la livraison est
555
558
  **abandonnée sans retry** (une cible devenue interne ne redeviendra pas légitime au réessai).
556
559
 
557
560
  **Le non-suivi des redirections.** `fetch()` suit les 3xx par défaut — ce qui annulerait tout le
@@ -601,10 +604,10 @@ sequenceDiagram
601
604
  `onAuditEvent()` est appelé **dans** la boucle de notification de `AuditService.record()` — trois
602
605
  gardes empêchent le journal d'audit de payer le prix des webhooks :
603
606
 
604
- 1. **Court-circuit à coût nul** — `endpointCount()` (`webhooks.ts:648`) lit la taille d'une `Map` :
607
+ 1. **Court-circuit à coût nul** — `endpointCount()` (`webhooks.ts#WebhookService.endpointCount`) lit la taille d'une `Map` :
605
608
  zéro endpoint = retour immédiat, aucune allocation (le cas dominant).
606
609
  2. **Travail lourd différé** — JSON, HMAC et réseau partent dans un `queueMicrotask` coalescé
607
- (`#schedulePump()`, `WebhookDispatcher.ts:163`), jamais dans la pile de l'appelant.
610
+ (`#schedulePump()`, `WebhookDispatcher.ts#WebhookDispatcher.#schedulePump`), jamais dans la pile de l'appelant.
608
611
  3. **Zéro E/S pour router** — `getSnapshot()` lit un **cache mémoire**, jamais le store : aucune
609
612
  requête n'est faite pour décider qui doit recevoir un événement. Le cache est chargé au boot
610
613
  (`#reloadSnapshot()`), tenu à jour par chaque écriture CRUD **du même pod**, et **rechargé quand
@@ -644,7 +647,7 @@ rafale d'événements ne déclenche **qu'une** relecture, et qu'un store en pann
644
647
 
645
648
  ### Politique de retry — ce qui est réessayé, et pendant combien de temps
646
649
 
647
- `classifyDelivery()` (`WebhookDispatcher.ts:45`) tranche en trois catégories :
650
+ `classifyDelivery()` (`WebhookDispatcher.ts#classifyDelivery`) tranche en trois catégories :
648
651
 
649
652
  | Issue de la tentative | Verdict | Pourquoi |
650
653
  | ---------------------------------------- | ------------------- | --------------------------------------------------------- |
@@ -654,8 +657,8 @@ rafale d'événements ne déclenche **qu'une** relecture, et qu'un store en pann
654
657
  | `3xx` | **échec définitif** | redirection non suivie = configuration cliente à corriger |
655
658
  | `4xx` (hors 408/429) | **échec définitif** | erreur cliente : réessayer ne changera rien |
656
659
 
657
- Le délai suit un **backoff exponentiel déterministe** — `backoffMs()` (`WebhookDispatcher.ts:55`) :
658
- `5 s × 2^tentative`, plafonné à 5 min (`MAX_BACKOFF_MS`, `WebhookDispatcher.ts:26`).
660
+ Le délai suit un **backoff exponentiel déterministe** — `backoffMs()` (`WebhookDispatcher.ts#backoffMs`) :
661
+ `5 s × 2^tentative`, plafonné à 5 min (`MAX_BACKOFF_MS`, `WebhookDispatcher.ts#MAX_BACKOFF_MS`).
659
662
 
660
663
  | Tentative | 0 | 1 | 2 | 3 | 4 | 5 | 6+ |
661
664
  | --------- | --- | ---- | ---- | ---- | ---- | ----- | ----------- |
@@ -663,7 +666,7 @@ Le délai suit un **backoff exponentiel déterministe** — `backoffMs()` (`Webh
663
666
 
664
667
  Avec le défaut `maxRetries: 5`, une livraison est tentée **6 fois** sur environ 4 min 15 avant
665
668
  abandon. Chaque retry **repasse par la file bornée** (`#scheduleRetry()`,
666
- `WebhookDispatcher.ts:264`) : un pic de retries ne peut pas contourner le plafond mémoire.
669
+ `WebhookDispatcher.ts#WebhookDispatcher.#scheduleRetry`) : un pic de retries ne peut pas contourner le plafond mémoire.
667
670
 
668
671
  > [!NOTE]
669
672
  > Le backoff est **déterministe, sans jitter**. Sur un seul pod c'est sans conséquence ; sur N pods
@@ -672,7 +675,7 @@ abandon. Chaque retry **repasse par la file bornée** (`#scheduleRetry()`,
672
675
 
673
676
  ### Auto-désactivation d'un endpoint mort
674
677
 
675
- Chaque issue finale passe par `WebhookService.markDelivery()` (`webhooks.ts:682`) : succès →
678
+ Chaque issue finale passe par `WebhookService.markDelivery()` (`webhooks.ts#WebhookService.markDelivery`) : succès →
676
679
  `failureCount = 0` ; échec → incrément. Au-delà de `autoDisableThreshold` (défaut **20**), l'endpoint
677
680
  est **désactivé** et un unique événement d'audit `webhook.disabled` est émis — **un par endpoint qui
678
681
  meurt**, jamais un par échec (le volume resterait ingérable). Mettre le seuil à `0` désactive
@@ -685,7 +688,7 @@ Le scénario vécu, du début à la fin :
685
688
  1. **Tentative 1** → `ECONNREFUSED`. Classé `retry` ; rien n'est encore écrit en base.
686
689
  2. **Tentatives 2 à 6** sur ~4 min. Toujours rien de persisté (seule l'issue finale l'est).
687
690
  3. **Abandon.** `markDelivery` écrit `lastDeliveryStatus: null`, `lastDeliveryError`, et incrémente
688
- `failureCount`. Une trace part dans l'historique RAM (`#recordDelivery()`, `webhooks.ts:612`).
691
+ `failureCount`. Une trace part dans l'historique RAM (`#recordDelivery()`, `webhooks.ts#WebhookService.#recordDelivery`).
689
692
  4. **L'événement est PERDU.** Il n'y a pas de file persistée : un webhook est **best-effort**. Rien
690
693
  ne sera rejoué quand le destinataire reviendra.
691
694
  5. **Après 20 échecs consécutifs**, l'endpoint passe `enabled: false` et cesse de consommer des
@@ -698,14 +701,14 @@ Le scénario vécu, du début à la fin :
698
701
 
699
702
  ### Arrêt propre
700
703
 
701
- `WebhookService.#shutdown()` (`webhooks.ts:234`) se désabonne de l'audit puis appelle
702
- `WebhookDispatcher.shutdown()` (`WebhookDispatcher.ts:280`) : admission stoppée, **tous les timers de
704
+ `WebhookService.#shutdown()` (`webhooks.ts#WebhookService.#shutdown`) se désabonne de l'audit puis appelle
705
+ `WebhookDispatcher.shutdown()` (`WebhookDispatcher.ts#WebhookDispatcher.shutdown`) : admission stoppée, **tous les timers de
703
706
  retry annulés**, file relâchée. Aucun timer orphelin ne retient le process — et les timers de retry
704
- sont `unref()` (`webhooks.ts:222`), donc ils n'empêchent jamais Node de sortir.
707
+ sont `unref()` (`webhooks.ts#WebhookService.#attachDispatcher`), donc ils n'empêchent jamais Node de sortir.
705
708
 
706
709
  ## ⚙️ Configuration
707
710
 
708
- Section `webhooks` du schéma Zod (`webhooksSchema`, `security/nodefony/config/config.ts:783`), lue via
711
+ Section `webhooks` du schéma Zod (`webhooksSchema`, `security/nodefony/config/config.ts#webhooksSchema`), lue via
709
712
  `use("@nodefony/security", { webhooks: … })`.
710
713
 
711
714
  | Option | Type | Défaut | Effet |
@@ -717,7 +720,7 @@ Section `webhooks` du schéma Zod (`webhooksSchema`, `security/nodefony/config/c
717
720
  | `timestampToleranceS` | `int > 0` | `300` | Fenêtre anti-rejeu **recommandée au récepteur** (voir la note ci-dessous). |
718
721
  | `maxRetries` | `int ≥ 0` | `5` | Réessais après la 1ʳᵉ tentative → 6 envois au total. |
719
722
  | `autoDisableThreshold` | `int ≥ 0` | `20` | Échecs consécutifs avant désactivation automatique. `0` = jamais. |
720
- | `deliveryTimeoutMs` | `int > 0` | `10000` | Délai dur d'une tentative ; au-delà, `req.destroy()` (`webhookDelivery.ts:136`). |
723
+ | `deliveryTimeoutMs` | `int > 0` | `10000` | Délai dur d'une tentative ; au-delà, `req.destroy()` (`webhookDelivery.ts#deliverWebhook`). |
721
724
  | `maxConcurrent` | `int > 0` | `8` | Livraisons simultanées : borne les sockets/FD qu'un endpoint lent peut immobiliser. |
722
725
  | `maxQueue` | `int > 0` | `1000` | File d'attente ; au-delà, **abandon** annoncé par log (best-effort, mémoire bornée). |
723
726
  | `denyPrivateIps` | `boolean` | `true` | Contrôle SSRF. `false` **saute entièrement** la résolution et le contrôle d'IP (dev only). |
@@ -725,14 +728,14 @@ Section `webhooks` du schéma Zod (`webhooksSchema`, `security/nodefony/config/c
725
728
 
726
729
  > [!NOTE]
727
730
  > `timestampToleranceS` est **transporté** dans la politique de livraison
728
- > (`getDeliveryPolicy()`, `webhooks.ts:663`) mais l'émetteur ne l'applique jamais : la fenêtre
731
+ > (`getDeliveryPolicy()`, `webhooks.ts#WebhookService.getDeliveryPolicy`) mais l'émetteur ne l'applique jamais : la fenêtre
729
732
  > anti-rejeu est par nature un contrôle du **récepteur**. Traite cette valeur comme la tolérance que
730
733
  > tu documentes à tes destinataires — c'est celle du récepteur qui protège.
731
734
 
732
735
  ## Entité de persistance — le registre d'endpoints
733
736
 
734
737
  Un endpoint est une **configuration durable**, pas un cache : il survit aux redémarrages et se
735
- partage entre pods. Les colonnes suivent `IWebhookEndpoint` (`IWebhookEndpoint.ts:10`), plat et
738
+ partage entre pods. Les colonnes suivent `IWebhookEndpoint` (`IWebhookEndpoint.ts#IWebhookEndpoint`), plat et
736
739
  « tout `| null` ».
737
740
 
738
741
  <!-- prettier-ignore -->
@@ -754,29 +757,29 @@ partage entre pods. Les colonnes suivent `IWebhookEndpoint` (`IWebhookEndpoint.t
754
757
  | `metadata` | extras applicatifs (jamais de secret) | `text mode:json` · `jsonb` · `json` | `Object` |
755
758
 
756
759
  Côté SQL, la table est décrite **une seule fois** en spec logique
757
- (`WEBHOOK_ENDPOINT_TABLE_SPEC`, `drizzle/nodefony/entity/webhookEndpointEntity.ts:28`) puis déclinée
760
+ (`WEBHOOK_ENDPOINT_TABLE_SPEC`, `drizzle/nodefony/entity/webhookEndpointEntity.ts#WEBHOOK_ENDPOINT_TABLE_SPEC`) puis déclinée
758
761
  par dialecte via le `colKit`. Côté documentaire, le schéma force `_id: String` — l'identifiant
759
762
  `wh_…` **est** la clé primaire, pas un `ObjectId` généré
760
- (`webhookEndpointSchema`, `mongoose/nodefony/entity/webhookEndpointEntity.ts:23`).
763
+ (`webhookEndpointSchema`, `mongoose/nodefony/entity/webhookEndpointEntity.ts#webhookEndpointSchema`).
761
764
 
762
765
  Ce qui **n'est pas** persisté : l'historique des livraisons. Il vit en RAM, borné à 20 entrées par
763
- endpoint (`MAX_DELIVERIES_PER_ENDPOINT`, `webhooks.ts:63`), corps de requête tronqué à 8 Ko, corps
764
- de réponse à 2 Ko (`RESPONSE_BODY_CAP`, `webhookDelivery.ts:21`) — et il est **par pod**. C'est de
766
+ endpoint (`MAX_DELIVERIES_PER_ENDPOINT`, `webhooks.ts#MAX_DELIVERIES_PER_ENDPOINT`), corps de requête tronqué à 8 Ko, corps
767
+ de réponse à 2 Ko (`RESPONSE_BODY_CAP`, `webhookDelivery.ts#RESPONSE_BODY_CAP`) — et il est **par pod**. C'est de
765
768
  l'observabilité éphémère de mise au point, pas un journal d'audit.
766
769
 
767
770
  ## Backends pris en charge — trois enregistrés, un exclu volontairement
768
771
 
769
772
  | Backend | Enregistrement | Durable | Partagé multi-pod | Usage |
770
773
  | ---------- | ------------------------------------------------------------ | :-----: | :---------------: | ------------------------------ |
771
- | `memory` | intégré, à l'import (`webhookStoreRegistry.ts:55`) | ❌ | ❌ | dev, tests |
774
+ | `memory` | intégré, à l'import (`webhookStoreRegistry.ts`) | ❌ | ❌ | dev, tests |
772
775
  | `drizzle` | auto-register de l'adapter (SQLite/PostgreSQL/MySQL/MariaDB) | ✅ | ✅ | production SQL |
773
776
  | `mongoose` | auto-register de l'adapter | ✅ | ✅ | production documentaire |
774
777
  | `redis` | **volontairement absent** | — | — | un registre n'est pas un cache |
775
778
 
776
779
  Redis n'est pas une omission : un endpoint est de la **configuration durable**, sa place n'est pas
777
- dans un magasin volatil (`IWebhookStore.ts:43`).
780
+ dans un magasin volatil (`IWebhookStore.ts#IWebhookStore`).
778
781
 
779
- La résolution est explicite et **annoncée**. `WebhookService.#resolveStore()` (`webhooks.ts:244`)
782
+ La résolution est explicite et **annoncée**. `WebhookService.#resolveStore()` (`webhooks.ts#WebhookService.#resolveStore`)
780
783
  privilégie un adapter déjà posé au container, puis résout `auto` d'après l'infra déclarée, et
781
784
  **enregistre sa décision** (visible dans Studio). Deux garde-fous :
782
785
 
@@ -786,32 +789,32 @@ privilégie un adapter déjà posé au container, puis résout `auto` d'après l
786
789
 
787
790
  ### Pagination du registre
788
791
 
789
- `WebhookService.listPage()` (`webhooks.ts:475`) délègue au store — la console n'a **jamais** tout le
792
+ `WebhookService.listPage()` (`webhooks.ts#WebhookService.listPage`) délègue au store — la console n'a **jamais** tout le
790
793
  registre en RAM. Ce contrat est vérifié par un **banc unique** rejoué sur tous les backends
791
794
  (`webhookPaginationContract.ts`) : mêmes 12 endpoints de seed, mêmes assertions.
792
795
 
793
796
  - Ordre par défaut : `createdAt` DESC, départagé par `id` ASC — sans ce tiebreaker, deux endpoints
794
797
  créés dans la même milliseconde pourraient changer de page et l'un ne jamais apparaître.
795
798
  - Tri demandable, mais **sur un vocabulaire déclaré** : `createdAt`, `updatedAt`, `url`, `enabled`,
796
- `failureCount`, `id` (`WEBHOOK_SORTABLE_FIELDS`, `webhookSort.ts:32`). Un champ hors liste est
799
+ `failureCount`, `id` (`WEBHOOK_SORTABLE_FIELDS`, `webhookSort.ts#WEBHOOK_SORTABLE_FIELDS`). Un champ hors liste est
797
800
  refusé, pas ignoré — et le store publie ce qu'il sait trier (`ISortableSource.sortableFields`,
798
- `IWebhookStore.ts:50`), plutôt que de laisser le front le deviner. Les colonnes **nullables** en
801
+ `IWebhookStore.ts#IWebhookStore.save`), plutôt que de laisser le front le deviner. Les colonnes **nullables** en
799
802
  sont volontairement absentes : PostgreSQL range les `NULL` en tête d'un `DESC` là où
800
803
  SQLite/MySQL/mémoire les rangent en queue — un tri dont l'ordre dépend de la base configurée ne
801
804
  vaut pas mieux qu'un tri absent.
802
805
  - Filtres appliqués **au store**, jamais après un chargement complet : `enabled`, `event`
803
806
  (appartenance au tableau JSON — « qui écoute `user.created` ? »), `failing` (au moins un échec
804
- consécutif courant — « qu'est-ce qui casse ? », `IWebhookStore.ts:35`), `q` (sous-chaîne
807
+ consécutif courant — « qu'est-ce qui casse ? », `IWebhookStore.ts#IWebhookListQuery.failing`), `q` (sous-chaîne
805
808
  insensible à la casse sur `url` **ou** `description`).
806
809
  - Mode unique **offset** : tous les backends d'endpoints savent le faire, aucune capacité n'est donc
807
- à déclarer (`MemoryWebhookStore.listPage()`, `MemoryWebhookStore.ts:90` ;
808
- `DrizzleWebhookStore.ts:185` ; `MongooseWebhookStore.ts:227`).
810
+ à déclarer (`MemoryWebhookStore.listPage()`, `MemoryWebhookStore.ts#MemoryWebhookStore.listPage` ;
811
+ `DrizzleWebhookStore.ts#DrizzleWebhookStore.listPage` ; `MongooseWebhookStore.ts#MongooseWebhookStore.listPage`).
809
812
  - **Les compteurs suivent la recherche.** `GET webhooks/stats` déclare `search`
810
- (`WebhookAdminApi.ts:307`) et descend le même `q` jusqu'au store : un terme sans correspondance
813
+ (`WebhookAdminApi.ts#webhookAdminEndpoints`) et descend le même `q` jusqu'au store : un terme sans correspondance
811
814
  vide les cartes autant que le tableau. Sans cela, la console afficherait « 12 endpoints » au-dessus
812
815
  d'une liste filtrée à 2 — un chiffre qui ne répond plus à la question posée à l'écran.
813
816
 
814
- `IWebhookStore.listAll()` (`IWebhookStore.ts:72`) existe toujours, mais il est **réservé au snapshot
817
+ `IWebhookStore.listAll()` (`IWebhookStore.ts#IWebhookStore.listAll`) existe toujours, mais il est **réservé au snapshot
815
818
  du dispatcher** : celui-ci doit connaître tous les abonnements pour ne rater aucune livraison. C'est
816
819
  un cold-path (boot + après écriture CRUD), jamais un chemin d'affichage.
817
820
 
@@ -822,19 +825,19 @@ un cold-path (boot + après écriture CRUD), jamais un chemin d'affichage.
822
825
  Résolu depuis le container (`container.get("webhooks")`), toutes les méthodes sont asynchrones sauf
823
826
  mention.
824
827
 
825
- | Méthode | Rôle | Ancrage |
826
- | ----------------------------- | ------------------------------------------------------------------ | ----------------- |
827
- | `register(input)` | Crée un endpoint (SSRF validé) → endpoint **+ secret en clair** | `webhooks.ts:441` |
828
- | `listPage(query)` | Page d'endpoints (vue publique, sans secret) | `webhooks.ts:475` |
829
- | `countEndpoints(query)` | `COUNT` natif ; `-1` si le backend ne sait pas compter | `webhooks.ts:489` |
830
- | `getEndpoint(id)` | Un endpoint (vue publique) ou `null` | `webhooks.ts:517` |
831
- | `update(id, patch)` | `url`/`events`/`enabled`/`description`/`metadata` ; URL re-validée | `webhooks.ts:528` |
832
- | `setEnabled(id, bool)` | Révocation douce | `webhooks.ts:549` |
833
- | `rotateSecret(id)` | Nouveau secret ; l'ancien meurt immédiatement | `webhooks.ts:560` |
834
- | `revealSecret(id)` | Secret en clair (action sensible, à auditer par l'appelant) | `webhooks.ts:579` |
835
- | `delete(id)` | Supprime ; `false` si absent | `webhooks.ts:587` |
836
- | `listDeliveries(id)` _(sync)_ | Historique RAM des dernières livraisons | `webhooks.ts:602` |
837
- | `isReady()` _(sync)_ | Activé **et** store **et** clé résolus | `webhooks.ts:414` |
828
+ | Méthode | Rôle | Ancrage |
829
+ | ----------------------------- | ------------------------------------------------------------------ | -------------------------------------------- |
830
+ | `register(input)` | Crée un endpoint (SSRF validé) → endpoint **+ secret en clair** | `webhooks.ts#WebhookService.register` |
831
+ | `listPage(query)` | Page d'endpoints (vue publique, sans secret) | `webhooks.ts#WebhookService.listPage` |
832
+ | `countEndpoints(query)` | `COUNT` natif ; `-1` si le backend ne sait pas compter | `webhooks.ts#WebhookService.countEndpoints` |
833
+ | `getEndpoint(id)` | Un endpoint (vue publique) ou `null` | `webhooks.ts#WebhookService.getEndpoint` |
834
+ | `update(id, patch)` | `url`/`events`/`enabled`/`description`/`metadata` ; URL re-validée | `webhooks.ts#IWebhookRegisterInput.metadata` |
835
+ | `setEnabled(id, bool)` | Révocation douce | `webhooks.ts#WebhookService.setEnabled` |
836
+ | `rotateSecret(id)` | Nouveau secret ; l'ancien meurt immédiatement | `webhooks.ts#WebhookService.rotateSecret` |
837
+ | `revealSecret(id)` | Secret en clair (action sensible, à auditer par l'appelant) | `webhooks.ts#WebhookService.revealSecret` |
838
+ | `delete(id)` | Supprime ; `false` si absent | `webhooks.ts#WebhookService.delete` |
839
+ | `listDeliveries(id)` _(sync)_ | Historique RAM des dernières livraisons | `webhooks.ts#WebhookService.listDeliveries` |
840
+ | `isReady()` _(sync)_ | Activé **et** store **et** clé résolus | `webhooks.ts#WebhookService.isReady` |
838
841
 
839
842
  Types et briques réutilisables exportés par `@nodefony/security` : `IWebhookEndpoint`,
840
843
  `WebhookEndpointSummary`, `IWebhookStore`, `IWebhookListQuery`, `MemoryWebhookStore`,
@@ -861,19 +864,19 @@ porte d'idempotence sur les mutations.
861
864
 
862
865
  Deux détails de conception qui se voient à l'usage :
863
866
 
864
- - **`reveal` est un `POST`, pas un `GET`** (`WebhookAdminApi.ts:569`) : un secret n'a rien à faire
867
+ - **`reveal` est un `POST`, pas un `GET`** (`WebhookAdminApi.ts#webhookAdminEndpoints`) : un secret n'a rien à faire
865
868
  dans une URL, donc ni dans un journal d'accès, ni dans un `Referer`. Le `POST` impose en prime la
866
869
  protection CSRF.
867
870
  - **La lecture est défensive, jamais `503`** : webhooks désactivés → la console affiche un badge
868
871
  honnête (`enabled: false`) et une liste vide, plutôt qu'une erreur. Les **mutations**, elles,
869
872
  rendent bien `503` si le service n'est pas prêt.
870
873
  - **Le listing est borné** : `limit` par défaut 50, plafond dur **200**
871
- (`parseWebhookListQuery()`, `WebhookAdminApi.ts:161`) — un client ne peut pas demander « tout ».
874
+ (`parseWebhookListQuery()`, `WebhookAdminApi.ts#parseWebhookListQuery`) — un client ne peut pas demander « tout ».
872
875
 
873
876
  ## 🧩 Extension — brancher son propre registre
874
877
 
875
878
  Le registre de stores est un simple `Map` nom → fabrique (`registerWebhookStore()`,
876
- `webhookStoreRegistry.ts:35`). Implémenter `IWebhookStore` (6 méthodes) suffit ; aucun couplage au
879
+ `webhookStoreRegistry.ts#registerWebhookStore`). Implémenter `IWebhookStore` (6 méthodes) suffit ; aucun couplage au
877
880
  cœur.
878
881
 
879
882
  ```typescript ignore
@@ -892,16 +895,16 @@ de filtres et de bornes sont alors prouvés, pas supposés.
892
895
 
893
896
  ## 📜 Normes appliquées
894
897
 
895
- | Domaine | Norme / référence | Ancrage |
896
- | ----------------------- | ------------------------------------- | ------------------------------------------------------------------ |
897
- | HMAC | RFC 2104 (via `node:crypto`) | `signStandardWebhook()` (`webhookSignature.ts:47`) |
898
- | Schéma de signature | Standard Webhooks v1 | `webhookSignatureHeaders()` (`webhookSignature.ts:60`) |
899
- | Dérivation de clé | RFC 5869 (HKDF-SHA256) | `deriveWebhookKey()` (`webhookCipher.ts:30`) |
900
- | Chiffrement au repos | AES-256-GCM (chiffrement authentifié) | `secretCipher.ts:77` |
901
- | SSRF | OWASP A10:2021, CAPEC-664 | `assertPublicUrl()` (`ssrfGuard.ts:128`) |
902
- | Plages non routables | RFC 1918, 6598, 3927, 7526 | `BLOCKED_V4` (`ssrfGuard.ts:22`), `BLOCKED_V6` (`ssrfGuard.ts:41`) |
903
- | Réessai sur `429`/`408` | RFC 6585, RFC 9110 | `classifyDelivery()` (`WebhookDispatcher.ts:45`) |
904
- | Comparaison de secret | temps constant | `timingSafeEqual` côté récepteur (`WebhookSinkController.ts:94`) |
898
+ | Domaine | Norme / référence | Ancrage |
899
+ | ----------------------- | ------------------------------------- | ---------------------------------------------------------------------------------- |
900
+ | HMAC | RFC 2104 (via `node:crypto`) | `signStandardWebhook()` (`webhookSignature.ts#signStandardWebhook`) |
901
+ | Schéma de signature | Standard Webhooks v1 | `webhookSignatureHeaders()` (`webhookSignature.ts#webhookSignatureHeaders`) |
902
+ | Dérivation de clé | RFC 5869 (HKDF-SHA256) | `deriveWebhookKey()` (`webhookCipher.ts#deriveWebhookKey`) |
903
+ | Chiffrement au repos | AES-256-GCM (chiffrement authentifié) | `secretCipher.ts#encryptSecret` |
904
+ | SSRF | OWASP A10:2021, CAPEC-664 | `assertPublicUrl()` (`ssrfGuard.ts#assertPublicUrl`) |
905
+ | Plages non routables | RFC 1918, 6598, 3927, 7526 | `BLOCKED_V4` (`ssrfGuard.ts#BLOCKED_V4`), `BLOCKED_V6` (`ssrfGuard.ts#BLOCKED_V6`) |
906
+ | Réessai sur `429`/`408` | RFC 6585, RFC 9110 | `classifyDelivery()` (`WebhookDispatcher.ts#classifyDelivery`) |
907
+ | Comparaison de secret | temps constant | `timingSafeEqual` côté récepteur (`WebhookSinkController.ts#verifySignature`) |
905
908
 
906
909
  ## ⚡ Performance & mémoire
907
910
 
@@ -911,12 +914,12 @@ qu'il y en a.
911
914
  <!-- prettier-ignore -->
912
915
  | Mécanisme | Borne | Ancrage |
913
916
  | --- | --- | --- |
914
- | Court-circuit hot-path | 0 allocation si `endpointCount() == 0` | `WebhookDispatcher.ts:137` |
915
- | File d'attente | `maxQueue` (1000) puis **abandon** + log | `WebhookDispatcher.ts:149` |
916
- | Sockets / FD simultanés | `maxConcurrent` (8) | `WebhookDispatcher.#pump()` (`WebhookDispatcher.ts:173`) |
917
- | Durée d'une tentative | 10 s puis `req.destroy()` | `webhookDelivery.ts:136` |
918
- | Historique par endpoint | 20 entrées, corps requête 8 Ko, réponse 2 Ko | `webhooks.ts:63` · `webhookDelivery.ts:21` |
919
- | Allocations paresseuses | file, `Set` de timers, historique : `null` tant qu'inutilisés | `WebhookDispatcher.ts:114` |
917
+ | Court-circuit hot-path | 0 allocation si `endpointCount() == 0` | `WebhookDispatcher.ts#IWebhookDispatcherDeps.endpointCount` |
918
+ | File d'attente | `maxQueue` (1000) puis **abandon** + log | `WebhookDispatcher.ts#WebhookDispatcher.#enqueue` |
919
+ | Sockets / FD simultanés | `maxConcurrent` (8) | `WebhookDispatcher.#pump()` (`WebhookDispatcher.ts#WebhookDispatcher.#pump`) |
920
+ | Durée d'une tentative | 10 s puis `req.destroy()` | `webhookDelivery.ts#deliverWebhook` |
921
+ | Historique par endpoint | 20 entrées, corps requête 8 Ko, réponse 2 Ko | `webhooks.ts#MAX_RECORDED_BODY` · `webhookDelivery.ts#RESPONSE_BODY_CAP` |
922
+ | Allocations paresseuses | file, `Set` de timers, historique : `null` tant qu'inutilisés | `WebhookDispatcher.ts#WebhookDispatcher.#queue` |
920
923
 
921
924
  La preuve n'est pas déclarative : un banc d'attaque envoie **5000 événements vers un endpoint mort**
922
925
  et vérifie que 4000 livraisons sont abandonnées (file plafonnée) et que le pic de connexions
@@ -924,12 +927,12 @@ simultanées ne dépasse jamais 8 (`webhookDispatch.attack.test.ts`).
924
927
 
925
928
  Deux propriétés complètent le tableau : les timers de retry sont `unref()` (ils n'empêchent jamais
926
929
  le process de sortir), et l'arrêt annule tout (`WebhookDispatcher.shutdown()`,
927
- `WebhookDispatcher.ts:280`) — aucun listener ni timer orphelin.
930
+ `WebhookDispatcher.ts#WebhookDispatcher.shutdown`) — aucun listener ni timer orphelin.
928
931
 
929
932
  ## 📡 Observabilité — Studio
930
933
 
931
934
  L'écran **Webhooks** (`/nodefony/webhooks`, `Webhooks.tsx`) est la console de la brique. Il consomme
932
- exactement le data plane décrit plus haut (`WEBHOOKS_ENDPOINT`, `webhooksModel.ts:112`) :
935
+ exactement le data plane décrit plus haut (`WEBHOOKS_ENDPOINT`, `webhooksModel.ts#WEBHOOKS_ENDPOINT`) :
933
936
 
934
937
  - **table paginée côté serveur** — URL, abonnements, état, dernière livraison, compteur d'échecs ;
935
938
  - **formulaire de création/édition** avec validation d'URL avant envoi (`WebhookFormModal.tsx`) ;
@@ -938,13 +941,13 @@ exactement le data plane décrit plus haut (`WEBHOOKS_ENDPOINT`, `webhooksModel.
938
941
  - **panneau des livraisons récentes** — ce qui a été envoyé et ce que le destinataire a répondu
939
942
  (`DeliveriesPanel.tsx`) ;
940
943
  - **badge « où on écrit »** : `memory` ou `orm`, dérivé du nom de classe réel du store
941
- (`webhookStoreDriver()`, `WebhookAdminApi.ts:113`) — un store tiers inconnu affiche `null` plutôt
944
+ (`webhookStoreDriver()`, `WebhookAdminApi.ts#webhookStoreDriver`) — un store tiers inconnu affiche `null` plutôt
942
945
  qu'un driver inventé.
943
946
 
944
947
  En développement, le module `test` fournit un **récepteur local** à demeure — le remplaçant
945
948
  non-jetable de webhook.site. Il capture les en-têtes de signature, vérifie le HMAC si on lui passe le
946
949
  secret, et sait **simuler des pannes** pour observer retries et auto-désactivation
947
- (`WebhookSinkController.ts:99`) :
950
+ (`WebhookSinkController.ts#WebhookSinkController`) :
948
951
 
949
952
  | Route | Ce qu'elle fait |
950
953
  | ------------------------------------------------- | ------------------------------------------------------ |
@@ -956,21 +959,21 @@ secret, et sait **simuler des pannes** pour observer retries et auto-désactivat
956
959
 
957
960
  ## ⚠️ Pièges (symptôme → cause → correction)
958
961
 
959
- | Symptôme | Cause (dans le code) | Correction |
960
- | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
961
- | Boot : « webhooks désactivés » en production | Aucune `encryptionKey` — fail-safe (`webhooks.ts:299`) | `npx nodefony security:secrets` puis câbler `NF_WEBHOOK_KEY` |
962
- | Après redémarrage, les signatures ne valident plus | Clé **éphémère** de dev : les secrets stockés sont illisibles | Poser une `encryptionKey` stable ; tourner les secrets des endpoints |
963
- | `422` à la création d'un endpoint | `assertPublicUrl()` refuse la cible (IP interne, schéma, userinfo) | Viser une URL publique en `https://` ; en dev, `denyPrivateIps: false` |
964
- | Rien n'arrive alors que l'endpoint est actif | Aucun `auditService` → dispatcher inactif (`webhooks.ts:191`) | Vérifier que l'audit est activé ; le CRUD seul ne livre rien |
965
- | Un événement « métier » n'arrive jamais | La source est le **journal d'audit**, pas un bus applicatif | S'abonner à une action d'audit existante |
966
- | Signature invalide côté récepteur | Corps re-sérialisé avant le HMAC, ou en-tête `webhook-id`/`-timestamp` ignoré | Lire le corps **brut** ; signer `{id}.{timestamp}.{body}` |
967
- | Le récepteur voit deux fois le même événement | Un retry rejoue le **même** `webhook-id` | Dédupliquer par `webhook-id` côté récepteur |
968
- | Endpoint passé `enabled: false` tout seul | 20 échecs consécutifs → auto-désactivation (`webhooks.ts:565`) | Réparer la destination, puis `PATCH … {"enabled":true}` |
969
- | Livraisons « abandonnées » dans les logs | File pleine (`maxQueue`) — best-effort assumé | Augmenter `maxQueue`/`maxConcurrent`, ou réduire le volume souscrit |
970
- | Un `302` vers l'interne n'est pas suivi | **Voulu** : `node:http(s)` ne suit jamais les 3xx | Rien à corriger — configurer l'URL finale côté destinataire |
971
- | Le secret a été perdu | Il n'est montré qu'à la création/rotation | `POST …/reveal` (audité) ou rotation + redéploiement chez le tiers |
972
- | Après un redémarrage, plus aucun endpoint | `store: "memory"` — registre volatil et par pod | Déclarer une infra durable (`store: "auto"` suffit alors) |
973
- | Multi-pod : un endpoint créé ne livre pas tout de suite depuis les autres pods | Le snapshot est relu après `webhooks.snapshotTtlS` (30 s par défaut, `webhooks.ts:406`), pas immédiatement | Attendre au plus `snapshotTtlS`, ou le baisser |
962
+ | Symptôme | Cause (dans le code) | Correction |
963
+ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- |
964
+ | Boot : « webhooks désactivés » en production | Aucune `encryptionKey` — fail-safe (`webhooks.ts#WebhookService.#resolveStore`) | `npx nodefony security:secrets` puis câbler `NF_WEBHOOK_KEY` |
965
+ | Après redémarrage, les signatures ne valident plus | Clé **éphémère** de dev : les secrets stockés sont illisibles | Poser une `encryptionKey` stable ; tourner les secrets des endpoints |
966
+ | `422` à la création d'un endpoint | `assertPublicUrl()` refuse la cible (IP interne, schéma, userinfo) | Viser une URL publique en `https://` ; en dev, `denyPrivateIps: false` |
967
+ | Rien n'arrive alors que l'endpoint est actif | Aucun `auditService` → dispatcher inactif (`webhooks.ts#WebhookService.#attachDispatcher`) | Vérifier que l'audit est activé ; le CRUD seul ne livre rien |
968
+ | Un événement « métier » n'arrive jamais | La source est le **journal d'audit**, pas un bus applicatif | S'abonner à une action d'audit existante |
969
+ | Signature invalide côté récepteur | Corps re-sérialisé avant le HMAC, ou en-tête `webhook-id`/`-timestamp` ignoré | Lire le corps **brut** ; signer `{id}.{timestamp}.{body}` |
970
+ | Le récepteur voit deux fois le même événement | Un retry rejoue le **même** `webhook-id` | Dédupliquer par `webhook-id` côté récepteur |
971
+ | Endpoint passé `enabled: false` tout seul | 20 échecs consécutifs → auto-désactivation (`webhooks.ts#WebhookService.rotateSecret`) | Réparer la destination, puis `PATCH … {"enabled":true}` |
972
+ | Livraisons « abandonnées » dans les logs | File pleine (`maxQueue`) — best-effort assumé | Augmenter `maxQueue`/`maxConcurrent`, ou réduire le volume souscrit |
973
+ | Un `302` vers l'interne n'est pas suivi | **Voulu** : `node:http(s)` ne suit jamais les 3xx | Rien à corriger — configurer l'URL finale côté destinataire |
974
+ | Le secret a été perdu | Il n'est montré qu'à la création/rotation | `POST …/reveal` (audité) ou rotation + redéploiement chez le tiers |
975
+ | Après un redémarrage, plus aucun endpoint | `store: "memory"` — registre volatil et par pod | Déclarer une infra durable (`store: "auto"` suffit alors) |
976
+ | Multi-pod : un endpoint créé ne livre pas tout de suite depuis les autres pods | Le snapshot est relu après `webhooks.snapshotTtlS` (30 s par défaut, `webhooks.ts#WebhookService.#touchSnapshot`), pas immédiatement | Attendre au plus `snapshotTtlS`, ou le baisser |
974
977
 
975
978
  ## 🧪 Tests & couverture
976
979