@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,129 @@
1
+ import { decryptSecret, encryptSecret } from "../crypto/secretCipher.js";
2
+ import "./totpCipher.js";
3
+ import { base32Encode, buildOtpauthUri, generateRecoveryCodes, generateTotpSecret, hashRecoveryCode, matchRecoveryCode, verifyTotp } from "./totpCrypto.js";
4
+ //#region nodefony/src/totp/totpOperations.ts
5
+ /**
6
+ * Démarre l'enrôlement : génère un secret aléatoire, le **chiffre** au repos et
7
+ * l'enregistre en attente de confirmation (`confirmedAt: null`). Retourne le
8
+ * secret en clair (base32 + URI) — **seul moment** où il est exposé. Idempotent :
9
+ * un nouvel appel écrase un enrôlement non confirmé (re-scan du QR).
10
+ */
11
+ async function beginTotpEnrollment(deps, userId, account) {
12
+ const secret = generateTotpSecret();
13
+ const secretBase32 = base32Encode(secret);
14
+ const now = deps.now();
15
+ const record = {
16
+ userId,
17
+ secretEnc: encryptSecret(secret, deps.key),
18
+ algorithm: deps.algorithm,
19
+ digits: deps.digits,
20
+ period: deps.period,
21
+ recoveryCodes: [],
22
+ confirmedAt: null,
23
+ lastUsedStep: null,
24
+ createdAt: now,
25
+ lastUsedAt: null
26
+ };
27
+ await deps.store.save(record);
28
+ return {
29
+ secretBase32,
30
+ otpauthUri: buildOtpauthUri({
31
+ issuer: deps.issuer,
32
+ account,
33
+ secretBase32,
34
+ algorithm: deps.algorithm,
35
+ digits: deps.digits,
36
+ period: deps.period
37
+ })
38
+ };
39
+ }
40
+ /**
41
+ * Confirme l'enrôlement : vérifie un 1ᵉʳ code généré par l'app, **active** le 2FA
42
+ * et génère les codes de récupération (retournés clairs 1×, hachés au repos). Le
43
+ * step de confirmation est marqué consommé (anti-rejeu). Lève si aucun enrôlement
44
+ * n'est en cours, s'il est déjà confirmé, ou si le code est invalide (reste pending).
45
+ */
46
+ async function confirmTotpEnrollment(deps, userId, code) {
47
+ const record = await deps.store.findByUser(userId);
48
+ if (!record) throw new Error("totp: aucun enrôlement en cours");
49
+ if (record.confirmedAt !== null) throw new Error("totp: 2FA déjà activé");
50
+ const secret = decryptSecret(record.secretEnc, deps.key);
51
+ const res = verifyTotp(code, secret, {
52
+ epochMs: deps.now(),
53
+ step: deps.period,
54
+ digits: deps.digits,
55
+ algorithm: deps.algorithm,
56
+ window: deps.window
57
+ });
58
+ if (!res.valid) throw new Error("totp: code de confirmation invalide");
59
+ const recoveryCodes = generateRecoveryCodes(deps.recoveryCodesCount);
60
+ const now = deps.now();
61
+ await deps.store.update(userId, {
62
+ confirmedAt: now,
63
+ recoveryCodes: recoveryCodes.map(hashRecoveryCode),
64
+ lastUsedStep: res.step,
65
+ lastUsedAt: now
66
+ });
67
+ return { recoveryCodes };
68
+ }
69
+ /**
70
+ * Vérifie un second facteur au login : d'abord un code TOTP (fenêtre ±window,
71
+ * **anti-rejeu** via `lastUsedStep`), à défaut un code de récupération (consommé,
72
+ * usage unique). Retourne `ok: false` si le 2FA n'est pas activé ou si rien ne
73
+ * correspond — **jamais d'exception** (chemin d'authentification).
74
+ */
75
+ async function verifyTotpLogin(deps, userId, code) {
76
+ const record = await deps.store.findByUser(userId);
77
+ if (!record || record.confirmedAt === null) return { ok: false };
78
+ const secret = decryptSecret(record.secretEnc, deps.key);
79
+ const res = verifyTotp(code, secret, {
80
+ epochMs: deps.now(),
81
+ step: deps.period,
82
+ digits: deps.digits,
83
+ algorithm: deps.algorithm,
84
+ window: deps.window
85
+ });
86
+ if (res.valid && res.step !== void 0) {
87
+ if (record.lastUsedStep !== null && res.step <= record.lastUsedStep) return { ok: false };
88
+ await deps.store.update(userId, {
89
+ lastUsedStep: res.step,
90
+ lastUsedAt: deps.now()
91
+ });
92
+ return {
93
+ ok: true,
94
+ method: "totp"
95
+ };
96
+ }
97
+ const idx = matchRecoveryCode(code, record.recoveryCodes);
98
+ if (idx >= 0) {
99
+ await deps.store.update(userId, {
100
+ recoveryCodes: record.recoveryCodes.filter((_, i) => i !== idx),
101
+ lastUsedAt: deps.now()
102
+ });
103
+ return {
104
+ ok: true,
105
+ method: "recovery"
106
+ };
107
+ }
108
+ return { ok: false };
109
+ }
110
+ /** Désactive le 2FA (retire le secret et les codes de récupération). */
111
+ function disableTotp(deps, userId) {
112
+ return deps.store.delete(userId);
113
+ }
114
+ /** État 2FA d'un utilisateur (absent / pending / activé + codes restants). */
115
+ async function totpStatus(deps, userId) {
116
+ const record = await deps.store.findByUser(userId);
117
+ if (!record) return {
118
+ enabled: false,
119
+ pending: false,
120
+ recoveryCodesRemaining: 0
121
+ };
122
+ return {
123
+ enabled: record.confirmedAt !== null,
124
+ pending: record.confirmedAt === null,
125
+ recoveryCodesRemaining: record.recoveryCodes.length
126
+ };
127
+ }
128
+ //#endregion
129
+ export { beginTotpEnrollment, confirmTotpEnrollment, disableTotp, totpStatus, verifyTotpLogin };
@@ -0,0 +1,18 @@
1
+ import { MemoryTotpSecretStore } from "./MemoryTotpSecretStore.js";
2
+ //#region nodefony/src/totp/totpSecretStoreRegistry.ts
3
+ const factories = /* @__PURE__ */ new Map();
4
+ /** Enregistre (ou remplace) la fabrique d'un store de secrets TOTP. */
5
+ function registerTotpStore(name, factory) {
6
+ factories.set(name, factory);
7
+ }
8
+ /** Fabrique d'un store par nom, ou `undefined` si inconnu. */
9
+ function getTotpStoreFactory(name) {
10
+ return factories.get(name);
11
+ }
12
+ /** Noms enregistrés (validation boot, introspection Studio, tests). */
13
+ function listTotpStores() {
14
+ return [...factories.keys()];
15
+ }
16
+ registerTotpStore("memory", () => new MemoryTotpSecretStore());
17
+ //#endregion
18
+ export { getTotpStoreFactory, listTotpStores, registerTotpStore };
@@ -0,0 +1,32 @@
1
+ import { RoleHierarchyWalker } from "../RoleHierarchyWalker.js";
2
+ import "../../contracts/IAccessVoter.js";
3
+ //#region nodefony/src/voter/RoleVoter.ts
4
+ /**
5
+ * Voter built-in **niveau A** — résout les attributs `ROLE_*` via la hiérarchie
6
+ * de rôles ({@link RoleHierarchyWalker}).
7
+ *
8
+ * Vote `GRANT` si l'utilisateur possède le rôle (hiérarchie résolue : `ROLE_ADMIN`
9
+ * hérite `ROLE_USER`), **`ABSTAIN` sinon** — jamais `DENY` : l'absence d'un rôle
10
+ * ne doit pas opposer son veto aux autres axes (scope, ownership). C'est le
11
+ * `default DENY` de l'`AuthorizationService` (tous ABSTAIN → refus) qui ferme la
12
+ * porte, pas ce voter.
13
+ *
14
+ * Lit la hiérarchie depuis le container (`roleHierarchy`, posée par le firewall
15
+ * au boot) en lazy — un walker vide gère quand même les rôles plats.
16
+ */
17
+ var RoleVoter = class {
18
+ container;
19
+ #walker = null;
20
+ constructor(container) {
21
+ this.container = container;
22
+ }
23
+ supports(attribute) {
24
+ return attribute.startsWith("ROLE_");
25
+ }
26
+ vote(token, attribute) {
27
+ const vote = (this.#walker ??= this.container.get("roleHierarchy") ?? new RoleHierarchyWalker()).hasRole(token.getRoles(), attribute) ? "GRANT" : "ABSTAIN";
28
+ return Promise.resolve(vote);
29
+ }
30
+ };
31
+ //#endregion
32
+ export { RoleVoter, RoleVoter as default };
@@ -0,0 +1,52 @@
1
+ import "../../contracts/IAccessVoter.js";
2
+ //#region nodefony/src/voter/ScopeVoter.ts
3
+ /**
4
+ * Types de jetons **non scopables** — identité humaine interactive (session BFF
5
+ * web/Studio/WS, login mot de passe) ou visiteur anonyme. Un scope `api:action`
6
+ * ne bride JAMAIS un humain : il **downscope** un jeton MACHINE délégué (clé API,
7
+ * JWT d'agent, OAuth). Tout type ABSENT de cette liste — présent (`apikey`/`jwt`/
8
+ * `oauth2`) ou futur (`mtls`, `agent`…) — est considéré **scopable**, donc soumis
9
+ * au filtre : **fail-closed côté machine** (un nouveau type délégué est bridé par
10
+ * défaut, jamais ouvert par oubli).
11
+ *
12
+ * `type` est porté à l'identique par `IToken` (HTTP) ET `IRealtimeToken` (WS, pont
13
+ * `api.request`) → ce voter reste transport-agnostique sans rien ajouter au contrat.
14
+ */
15
+ const NON_SCOPABLE_TOKEN_TYPES = /* @__PURE__ */ new Set([
16
+ "session",
17
+ "userpassword",
18
+ "anonymous"
19
+ ]);
20
+ /**
21
+ * Voter built-in **axe SCOPE** (P6.8) — applique les scopes `api:action` déclarés
22
+ * par `@RequireScope`. Frère du {@link RoleVoter} sur l'autre axe : les rôles
23
+ * disent QUI tu es, les scopes disent ce qu'une CLÉ déléguée a le droit de faire.
24
+ *
25
+ * `supports()` ne capte QUE la forme conventionnée `api:action` (un `:`, jamais
26
+ * `ROLE_*`) → aucune collision avec le `RoleVoter` ni un voter métier (`doc.edit`).
27
+ *
28
+ * Vote :
29
+ * - **jeton non scopable** (humain/anonyme) → `GRANT` : le scope est un no-op,
30
+ * l'autorisation de l'humain est portée par ses rôles (`@IsGranted`), pas par
31
+ * un downscoping de clé ;
32
+ * - **jeton scopable** (clé API / JWT / OAuth) → `GRANT` si le scope exact est
33
+ * présent, sinon **`ABSTAIN`** (jamais `DENY` : l'absence d'un scope ne doit pas
34
+ * opposer un veto aux autres attributs OR d'une clause — c'est le default-DENY
35
+ * de l'`AuthorizationService`, tous ABSTAIN → refus, qui ferme la porte ;
36
+ * posture identique au `RoleVoter`).
37
+ *
38
+ * Pur : aucune dépendance (ni container, ni I/O) — il ne lit que le jeton déjà
39
+ * résolu au handshake/à l'authentification. Instancié UNE fois au boot.
40
+ */
41
+ var ScopeVoter = class {
42
+ /** Capte les attributs scope `api:action` — ni `ROLE_*`, ni attribut métier sans `:`. */
43
+ supports(attribute) {
44
+ return attribute.includes(":") && !attribute.startsWith("ROLE_");
45
+ }
46
+ vote(token, attribute) {
47
+ if (NON_SCOPABLE_TOKEN_TYPES.has(token.type)) return Promise.resolve("GRANT");
48
+ return Promise.resolve(token.getScopes().includes(attribute) ? "GRANT" : "ABSTAIN");
49
+ }
50
+ };
51
+ //#endregion
52
+ export { ScopeVoter, ScopeVoter as default };
@@ -0,0 +1,20 @@
1
+ import { RoleVoter } from "./RoleVoter.js";
2
+ import { ScopeVoter } from "./ScopeVoter.js";
3
+ //#region nodefony/src/voter/voterRegistry.ts
4
+ const factories = /* @__PURE__ */ new Map();
5
+ /**
6
+ * Enregistre (ou remplace) la fabrique d'un voter. Appelé par les builtins au
7
+ * chargement du module, et par les apps/plugins pour les leurs (`ProjectVoter`,
8
+ * `TenantVoter`…).
9
+ */
10
+ function registerVoterFactory(name, factory) {
11
+ factories.set(name, factory);
12
+ }
13
+ /** Toutes les fabriques enregistrées (consommées par l'`AuthorizationService`). */
14
+ function listVoterFactories() {
15
+ return factories;
16
+ }
17
+ registerVoterFactory("role", ({ container }) => new RoleVoter(container));
18
+ registerVoterFactory("scope", () => new ScopeVoter());
19
+ //#endregion
20
+ export { listVoterFactories, registerVoterFactory };
@@ -0,0 +1,121 @@
1
+ import { assertPageQuery } from "nodefony";
2
+ //#region nodefony/src/webauthn/MemoryWebAuthnCredentialStore.ts
3
+ /**
4
+ * Projection contractuelle : credential complet → vue admin **sans `publicKey`**.
5
+ * Partagée par les backends qui matérialisent des credentials en mémoire.
6
+ */
7
+ function toWebAuthnSummary(c) {
8
+ return {
9
+ id: c.id,
10
+ userId: c.userId,
11
+ transports: c.transports,
12
+ backupEligible: c.backupEligible,
13
+ backupState: c.backupState,
14
+ uvInitialized: c.uvInitialized,
15
+ signCount: c.signCount,
16
+ createdAt: c.createdAt,
17
+ lastUsedAt: c.lastUsedAt,
18
+ ...c.nickname !== void 0 ? { nickname: c.nickname } : {}
19
+ };
20
+ }
21
+ var MemoryWebAuthnCredentialStore = class {
22
+ /** id (base64url) → credential (source de vérité). */
23
+ #byId = /* @__PURE__ */ new Map();
24
+ /** userId → ids (allowCredentials + « mes appareils »). */
25
+ #idsByUser = /* @__PURE__ */ new Map();
26
+ findById(credentialId) {
27
+ return Promise.resolve(this.#byId.get(credentialId) ?? null);
28
+ }
29
+ findByUser(userId) {
30
+ const ids = this.#idsByUser.get(userId);
31
+ if (!ids) return Promise.resolve([]);
32
+ const out = [];
33
+ for (const id of ids) {
34
+ const cred = this.#byId.get(id);
35
+ if (cred) out.push(cred);
36
+ }
37
+ return Promise.resolve(out);
38
+ }
39
+ countByUser(userId) {
40
+ return Promise.resolve(this.#idsByUser.get(userId)?.size ?? 0);
41
+ }
42
+ save(credential) {
43
+ this.#byId.set(credential.id, credential);
44
+ let set = this.#idsByUser.get(credential.userId);
45
+ if (!set) {
46
+ set = /* @__PURE__ */ new Set();
47
+ this.#idsByUser.set(credential.userId, set);
48
+ }
49
+ set.add(credential.id);
50
+ return Promise.resolve();
51
+ }
52
+ update(credentialId, patch) {
53
+ const cred = this.#byId.get(credentialId);
54
+ if (cred) {
55
+ cred.signCount = patch.signCount;
56
+ cred.backupState = patch.backupState;
57
+ cred.uvInitialized = patch.uvInitialized;
58
+ cred.lastUsedAt = patch.lastUsedAt;
59
+ }
60
+ return Promise.resolve();
61
+ }
62
+ delete(credentialId) {
63
+ const cred = this.#byId.get(credentialId);
64
+ if (!cred) return Promise.resolve();
65
+ this.#byId.delete(credentialId);
66
+ const set = this.#idsByUser.get(cred.userId);
67
+ if (set) {
68
+ set.delete(credentialId);
69
+ if (set.size === 0) this.#idsByUser.delete(cred.userId);
70
+ }
71
+ return Promise.resolve();
72
+ }
73
+ /** Credentials filtrés, dans l'ordre contractuel (createdAt DESC, id ASC). */
74
+ #filtered(query) {
75
+ const out = [];
76
+ for (const cred of this.#byId.values()) {
77
+ if (query.userId !== void 0 && cred.userId !== query.userId) continue;
78
+ if (query.backedUp !== void 0 && cred.backupState !== query.backedUp) continue;
79
+ if (query.q !== void 0 && !cred.userId.startsWith(query.q)) continue;
80
+ out.push(cred);
81
+ }
82
+ return out.sort((a, b) => b.createdAt - a.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
83
+ }
84
+ listPage(query) {
85
+ assertPageQuery(query, "offset");
86
+ const limit = Math.max(1, Math.floor(query.limit));
87
+ const offset = query.offset !== void 0 && query.offset > 0 ? query.offset : 0;
88
+ const all = this.#filtered(query);
89
+ const items = all.slice(offset, offset + limit).map(toWebAuthnSummary);
90
+ return Promise.resolve({
91
+ items,
92
+ limit,
93
+ offset,
94
+ hasNext: offset + items.length < all.length,
95
+ ...query.withTotal === false ? {} : { total: all.length }
96
+ });
97
+ }
98
+ countCredentials(query) {
99
+ return Promise.resolve(this.#filtered(query).length);
100
+ }
101
+ /** Instantané sérialisable de l'état courant (pour la persistance fichier). */
102
+ snapshot() {
103
+ return { credentials: [...this.#byId.values()] };
104
+ }
105
+ /** Remplace l'état par celui d'un instantané (reconstruit l'index par user). */
106
+ restore(snapshot) {
107
+ this.#byId.clear();
108
+ this.#idsByUser.clear();
109
+ for (const cred of snapshot.credentials) {
110
+ this.#byId.set(cred.id, cred);
111
+ let set = this.#idsByUser.get(cred.userId);
112
+ if (!set) {
113
+ set = /* @__PURE__ */ new Set();
114
+ this.#idsByUser.set(cred.userId, set);
115
+ }
116
+ set.add(cred.id);
117
+ }
118
+ }
119
+ };
120
+ //#endregion
121
+ export { MemoryWebAuthnCredentialStore, MemoryWebAuthnCredentialStore as default, toWebAuthnSummary };
@@ -0,0 +1,18 @@
1
+ import { MemoryWebAuthnCredentialStore } from "./MemoryWebAuthnCredentialStore.js";
2
+ //#region nodefony/src/webauthn/webAuthnCredentialStoreRegistry.ts
3
+ const factories = /* @__PURE__ */ new Map();
4
+ /** Enregistre (ou remplace) la fabrique d'un store de credentials WebAuthn. */
5
+ function registerWebAuthnStore(name, factory) {
6
+ factories.set(name, factory);
7
+ }
8
+ /** Fabrique d'un store par nom, ou `undefined` si inconnu. */
9
+ function getWebAuthnStoreFactory(name) {
10
+ return factories.get(name);
11
+ }
12
+ /** Noms enregistrés (validation boot, introspection Studio, tests). */
13
+ function listWebAuthnStores() {
14
+ return [...factories.keys()];
15
+ }
16
+ registerWebAuthnStore("memory", () => new MemoryWebAuthnCredentialStore());
17
+ //#endregion
18
+ export { getWebAuthnStoreFactory, listWebAuthnStores, registerWebAuthnStore };
@@ -0,0 +1,87 @@
1
+ import { WEBHOOK_DEFAULT_ORDER, WEBHOOK_SORTABLE_FIELDS } from "./webhookSort.js";
2
+ import { assertPageQuery, compareByOrder, pickOrder } from "nodefony";
3
+ //#region nodefony/src/webhook/MemoryWebhookStore.ts
4
+ /**
5
+ * Applique les filtres d'{@link IWebhookListQuery} à un endpoint — sémantique de
6
+ * RÉFÉRENCE du contrat, partagée par les backends qui filtrent en mémoire.
7
+ *
8
+ * @param e - endpoint candidat.
9
+ * @param query - filtres du listing (les champs omis ne filtrent pas).
10
+ * @returns `true` si l'endpoint appartient à la collection filtrée.
11
+ */
12
+ function matchesWebhookQuery(e, query) {
13
+ if (query.enabled !== void 0 && e.enabled !== query.enabled) return false;
14
+ if (query.failing !== void 0 && e.failureCount > 0 !== query.failing) return false;
15
+ if (query.event !== void 0 && !e.events.includes(query.event)) return false;
16
+ if (query.q !== void 0 && query.q.length > 0) {
17
+ const needle = query.q.toLowerCase();
18
+ if (!`${e.url}\n${e.description ?? ""}`.toLowerCase().includes(needle)) return false;
19
+ }
20
+ return true;
21
+ }
22
+ /**
23
+ * Store d'endpoints webhook **en mémoire** — défaut dev/test (non persistant :
24
+ * les endpoints sont perdus au redémarrage). En prod, utiliser `drizzle`/
25
+ * `mongoose` (config `webhooks.store`).
26
+ *
27
+ * Map indexée par id (O(1)). Les lectures renvoient une **copie défensive** : le
28
+ * store détient la vérité, un consommateur ne peut pas muter un record en place.
29
+ */
30
+ var MemoryWebhookStore = class {
31
+ /**
32
+ * {@inheritDoc IWebhookStore.sortableFields}
33
+ *
34
+ * Le store porte l'endpoint complet : il sait trier tout le vocabulaire
35
+ * public, sans réduction de capacité.
36
+ */
37
+ sortableFields = WEBHOOK_SORTABLE_FIELDS;
38
+ #byId = /* @__PURE__ */ new Map();
39
+ async save(endpoint) {
40
+ this.#byId.set(endpoint.id, { ...endpoint });
41
+ }
42
+ async findById(id) {
43
+ const found = this.#byId.get(id);
44
+ return found ? { ...found } : null;
45
+ }
46
+ async update(id, patch) {
47
+ const current = this.#byId.get(id);
48
+ if (!current) return;
49
+ this.#byId.set(id, {
50
+ ...current,
51
+ ...patch
52
+ });
53
+ }
54
+ async delete(id) {
55
+ this.#byId.delete(id);
56
+ }
57
+ async listAll() {
58
+ return [...this.#byId.values()].map((e) => ({ ...e }));
59
+ }
60
+ async listPage(query) {
61
+ assertPageQuery(query, "offset");
62
+ const limit = Math.max(1, Math.floor(query.limit));
63
+ const offset = Math.max(0, Math.floor(query.offset ?? 0));
64
+ const filtered = [...this.#byId.values()].filter((e) => matchesWebhookQuery(e, query));
65
+ const order = pickOrder(query.order, this.sortableFields, WEBHOOK_DEFAULT_ORDER);
66
+ filtered.sort(compareByOrder(order, (e, field) => e[field]));
67
+ const items = filtered.slice(offset, offset + limit).map((e) => ({
68
+ ...e,
69
+ events: [...e.events],
70
+ metadata: { ...e.metadata }
71
+ }));
72
+ return {
73
+ items,
74
+ total: query.withTotal === false ? void 0 : filtered.length,
75
+ limit,
76
+ offset,
77
+ hasNext: offset + items.length < filtered.length
78
+ };
79
+ }
80
+ async countEndpoints(query) {
81
+ let n = 0;
82
+ for (const e of this.#byId.values()) if (matchesWebhookQuery(e, query)) n += 1;
83
+ return n;
84
+ }
85
+ };
86
+ //#endregion
87
+ export { MemoryWebhookStore, matchesWebhookQuery };
@@ -0,0 +1,208 @@
1
+ import { webhookSignatureHeaders } from "./webhookSignature.js";
2
+ //#region nodefony/src/webhook/WebhookDispatcher.ts
3
+ /**
4
+ * Dispatcher de webhooks sortants — abonné au journal d'audit, il filtre les
5
+ * événements par souscription, signe (Standard Webhooks v1) et livre, **sans
6
+ * jamais mettre le framework en danger** (RÈGLE PERF) :
7
+ *
8
+ * - **Hot-path protégé** : `onAuditEvent` (appelé dans le fire-and-forget de
9
+ * `AuditService.record`) court-circuite à **coût nul** quand aucun endpoint
10
+ * n'est configuré (cas dominant). Sinon il empile seulement ; le travail
11
+ * (JSON/signature/réseau) est **différé** hors de la pile via un pump microtask.
12
+ * - **Concurrence bornée** : au plus `maxConcurrent` livraisons en vol → un
13
+ * endpoint lent/mort ne peut pas saturer sockets/FD/mémoire.
14
+ * - **File bornée** : au-delà de `maxQueue`, les livraisons sont **abandonnées**
15
+ * (best-effort) — jamais de croissance mémoire illimitée sous un pic.
16
+ * - **Lazy alloc** : ni file ni Set de timers tant qu'aucune livraison.
17
+ *
18
+ * Toutes les E/S et le temps sont **injectés** (`deps`) → logique testable sans
19
+ * réseau ni timers réels.
20
+ */
21
+ const MAX_BACKOFF_MS = 3e5;
22
+ const BASE_BACKOFF_MS = 5e3;
23
+ /** Une souscription matche-t-elle une action d'audit ? `*` = toutes, `x.*` = préfixe. */
24
+ function matchesSubscription(patterns, action) {
25
+ for (const p of patterns) {
26
+ if (p === "*" || p === action) return true;
27
+ if (p.endsWith(".*") && action.startsWith(p.slice(0, -1))) return true;
28
+ }
29
+ return false;
30
+ }
31
+ /**
32
+ * Classe le résultat d'une livraison : 2xx = succès ; réseau/timeout/429/408/5xx =
33
+ * réessayable ; 3xx/4xx (config cliente erronée) = échec définitif (pas de retry).
34
+ */
35
+ function classifyDelivery(r) {
36
+ if (r.ok) return "success";
37
+ if (r.status === null) return "retry";
38
+ if (r.status === 429 || r.status === 408 || r.status >= 500) return "retry";
39
+ return "fail";
40
+ }
41
+ /** Backoff exponentiel déterministe (jitter cross-pod = slice Redis cluster). */
42
+ function backoffMs(attempt) {
43
+ return Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** attempt);
44
+ }
45
+ var WebhookDispatcher = class {
46
+ #deps;
47
+ /** File des livraisons en attente (lazy, bornée à `policy.maxQueue`). */
48
+ #queue = null;
49
+ /** Livraisons en vol (≤ `policy.maxConcurrent`). */
50
+ #inFlight = 0;
51
+ /** Annulations des retries planifiés (lazy) — vidées au shutdown. */
52
+ #timers = null;
53
+ /** Livraisons abandonnées (file pleine) — observabilité. */
54
+ #dropped = 0;
55
+ #pumpScheduled = false;
56
+ #stopped = false;
57
+ constructor(deps) {
58
+ this.#deps = deps;
59
+ }
60
+ /**
61
+ * Réagit à un événement d'audit. **Hot-path** : court-circuit à coût nul si
62
+ * aucun endpoint ; sinon filtre et empile (le travail lourd est différé).
63
+ */
64
+ onAuditEvent(event) {
65
+ if (this.#stopped) return;
66
+ if (event.category === "webhook") return;
67
+ if (this.#deps.endpointCount() === 0) return;
68
+ for (const ep of this.#deps.getSnapshot()) {
69
+ if (!ep.enabled) continue;
70
+ if (!matchesSubscription(ep.events, event.action)) continue;
71
+ this.#enqueue({
72
+ ep,
73
+ event,
74
+ attempt: 0
75
+ });
76
+ }
77
+ }
78
+ /** Empile une livraison ; **abandonne** (best-effort) si la file est pleine. */
79
+ #enqueue(job) {
80
+ if (this.#stopped) return;
81
+ if (this.#queue === null) this.#queue = [];
82
+ if (this.#queue.length >= this.#deps.policy.maxQueue) {
83
+ this.#dropped++;
84
+ if (this.#dropped === 1 || this.#dropped % 100 === 0) this.#deps.log(`webhooks: file pleine (${this.#deps.policy.maxQueue}) — ${this.#dropped} livraison(s) abandonnée(s) (best-effort)`);
85
+ return;
86
+ }
87
+ this.#queue.push(job);
88
+ this.#schedulePump();
89
+ }
90
+ /** Déclenche le pump hors de la pile courante (1 microtask coalescée). */
91
+ #schedulePump() {
92
+ if (this.#pumpScheduled || this.#stopped) return;
93
+ this.#pumpScheduled = true;
94
+ queueMicrotask(() => {
95
+ this.#pumpScheduled = false;
96
+ this.#pump();
97
+ });
98
+ }
99
+ /** Lance des livraisons jusqu'à `maxConcurrent`. */
100
+ #pump() {
101
+ if (this.#queue === null || this.#stopped) return;
102
+ const max = this.#deps.policy.maxConcurrent;
103
+ while (this.#inFlight < max && this.#queue.length > 0) {
104
+ const job = this.#queue.shift();
105
+ this.#inFlight++;
106
+ this.#process(job).finally(() => {
107
+ this.#inFlight--;
108
+ if (this.#queue !== null && this.#queue.length > 0) this.#schedulePump();
109
+ });
110
+ }
111
+ }
112
+ /** Signe + livre une tentative ; gère succès / retry / échec définitif. */
113
+ async #process(job) {
114
+ const { ep, event, attempt } = job;
115
+ try {
116
+ const id = this.#deps.newMessageId();
117
+ const nowMs = this.#deps.now();
118
+ const tsS = Math.floor(nowMs / 1e3);
119
+ const body = JSON.stringify({
120
+ id,
121
+ timestamp: new Date(nowMs).toISOString(),
122
+ type: event.action,
123
+ data: event
124
+ });
125
+ const headers = {
126
+ "content-type": "application/json",
127
+ "user-agent": "Nodefony-Webhooks/1.0",
128
+ ...webhookSignatureHeaders(this.#deps.secretOf(ep), id, tsS, body)
129
+ };
130
+ let addresses;
131
+ try {
132
+ addresses = await this.#deps.resolveTarget(ep.url);
133
+ } catch (e) {
134
+ const error = `ssrf: ${e.message}`;
135
+ await this.#deps.markDelivery(ep.id, {
136
+ ok: false,
137
+ status: null,
138
+ error
139
+ });
140
+ this.#deps.recordDelivery(ep.id, {
141
+ messageId: id,
142
+ type: event.action,
143
+ attempt,
144
+ ok: false,
145
+ status: null,
146
+ error,
147
+ durationMs: this.#deps.now() - nowMs,
148
+ requestBody: body,
149
+ responseBody: null
150
+ });
151
+ return;
152
+ }
153
+ const result = await this.#deps.deliver(ep.url, body, headers, {
154
+ timeoutMs: this.#deps.policy.deliveryTimeoutMs,
155
+ addresses,
156
+ allowHttp: this.#deps.policy.allowHttp
157
+ });
158
+ if (classifyDelivery(result) === "retry" && attempt < this.#deps.policy.maxRetries) {
159
+ this.#scheduleRetry(job);
160
+ return;
161
+ }
162
+ await this.#deps.markDelivery(ep.id, result);
163
+ this.#deps.recordDelivery(ep.id, {
164
+ messageId: id,
165
+ type: event.action,
166
+ attempt,
167
+ ok: result.ok,
168
+ status: result.status,
169
+ error: result.error,
170
+ durationMs: this.#deps.now() - nowMs,
171
+ requestBody: body,
172
+ responseBody: result.responseBody ?? null
173
+ });
174
+ } catch (e) {
175
+ this.#deps.log(`webhook dispatch ${ep.id}: ${e.message}`);
176
+ }
177
+ }
178
+ /** Replanifie la livraison après backoff (repasse par la file bornée). */
179
+ #scheduleRetry(job) {
180
+ if (this.#stopped) return;
181
+ if (this.#timers === null) this.#timers = /* @__PURE__ */ new Set();
182
+ const cancel = this.#deps.schedule(() => {
183
+ this.#timers?.delete(cancel);
184
+ this.#enqueue({
185
+ ep: job.ep,
186
+ event: job.event,
187
+ attempt: job.attempt + 1
188
+ });
189
+ }, backoffMs(job.attempt));
190
+ this.#timers.add(cancel);
191
+ }
192
+ /** Livraisons abandonnées pour cause de file pleine (observabilité). */
193
+ droppedCount() {
194
+ return this.#dropped;
195
+ }
196
+ /** Arrêt propre : stoppe l'admission, annule les retries, vide la file. */
197
+ shutdown() {
198
+ this.#stopped = true;
199
+ if (this.#timers !== null) {
200
+ for (const cancel of this.#timers) cancel();
201
+ this.#timers.clear();
202
+ this.#timers = null;
203
+ }
204
+ this.#queue = null;
205
+ }
206
+ };
207
+ //#endregion
208
+ export { WebhookDispatcher, backoffMs, classifyDelivery, matchesSubscription };