@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.
- package/LICENSE +544 -0
- package/README.md +182 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +151 -0
- package/dist/nodefony/command/security-secrets.js +158 -0
- package/dist/nodefony/command/security-token.js +335 -0
- package/dist/nodefony/command/security-user-add.js +131 -0
- package/dist/nodefony/command/security-user-delete.js +102 -0
- package/dist/nodefony/command/security-user-list.js +77 -0
- package/dist/nodefony/config/config.js +366 -0
- package/dist/nodefony/config/defineModuleConfig.js +35 -0
- package/dist/nodefony/contracts/IAccessVoter.js +13 -0
- package/dist/nodefony/contracts/IApiKey.js +1 -0
- package/dist/nodefony/contracts/IAuditEvent.js +1 -0
- package/dist/nodefony/contracts/IAuditStore.js +1 -0
- package/dist/nodefony/contracts/IAuthenticator.js +1 -0
- package/dist/nodefony/contracts/IAuthorizationService.js +1 -0
- package/dist/nodefony/contracts/IFirewall.js +1 -0
- package/dist/nodefony/contracts/IFirewallDescription.js +1 -0
- package/dist/nodefony/contracts/IJwtKeystore.js +1 -0
- package/dist/nodefony/contracts/IOAuthProvider.js +1 -0
- package/dist/nodefony/contracts/ISecuredArea.js +1 -0
- package/dist/nodefony/contracts/IToken.js +1 -0
- package/dist/nodefony/contracts/ITokenStore.js +1 -0
- package/dist/nodefony/contracts/ITotpSecret.js +1 -0
- package/dist/nodefony/contracts/ITotpSecretStore.js +1 -0
- package/dist/nodefony/contracts/IWebAuthnCredential.js +1 -0
- package/dist/nodefony/contracts/IWebAuthnCredentialStore.js +1 -0
- package/dist/nodefony/contracts/IWebhookEndpoint.js +1 -0
- package/dist/nodefony/contracts/IWebhookStore.js +1 -0
- package/dist/nodefony/contracts/index.js +2 -0
- package/dist/nodefony/errors/AccessDeniedError.js +14 -0
- package/dist/nodefony/errors/ApiKeyError.js +21 -0
- package/dist/nodefony/errors/AuthenticationError.js +14 -0
- package/dist/nodefony/errors/CsrfError.js +23 -0
- package/dist/nodefony/errors/InvalidTargetError.js +39 -0
- package/dist/nodefony/errors/SsrfError.js +17 -0
- package/dist/nodefony/errors/ThrottledError.js +21 -0
- package/dist/nodefony/errors/UnverifiableTokenError.js +42 -0
- package/dist/nodefony/errors/WebAuthnError.js +21 -0
- package/dist/nodefony/errors/index.js +9 -0
- package/dist/nodefony/service/accessTokenVerifier.js +77 -0
- package/dist/nodefony/service/apiKeys.js +310 -0
- package/dist/nodefony/service/auditService.js +145 -0
- package/dist/nodefony/service/authFlow.js +332 -0
- package/dist/nodefony/service/authorization.js +95 -0
- package/dist/nodefony/service/cors.js +81 -0
- package/dist/nodefony/service/csrf.js +97 -0
- package/dist/nodefony/service/firewall.js +699 -0
- package/dist/nodefony/service/oauth2.js +153 -0
- package/dist/nodefony/service/securityHeaders.js +80 -0
- package/dist/nodefony/service/tokenService.js +486 -0
- package/dist/nodefony/service/totp.js +209 -0
- package/dist/nodefony/service/webAuthn.js +343 -0
- package/dist/nodefony/service/webhooks.js +539 -0
- package/dist/nodefony/src/RoleHierarchyWalker.js +77 -0
- package/dist/nodefony/src/SecuredArea.js +51 -0
- package/dist/nodefony/src/admin/SecurityAdminApi.js +495 -0
- package/dist/nodefony/src/admin/WebhookAdminApi.js +378 -0
- package/dist/nodefony/src/admin/adminAudit.js +37 -0
- package/dist/nodefony/src/admin/userRevocationCascade.js +40 -0
- package/dist/nodefony/src/apikey/apiKeyFormat.js +107 -0
- package/dist/nodefony/src/audit/MemoryAuditStore.js +121 -0
- package/dist/nodefony/src/audit/auditBridge.js +82 -0
- package/dist/nodefony/src/audit/auditFilters.js +60 -0
- package/dist/nodefony/src/audit/auditStoreRegistry.js +25 -0
- package/dist/nodefony/src/audit/readAuditContext.js +24 -0
- package/dist/nodefony/src/audit/recordAudit.js +16 -0
- package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +36 -0
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +164 -0
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +224 -0
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +174 -0
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +176 -0
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +92 -0
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +95 -0
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +63 -0
- package/dist/nodefony/src/authenticator/bearer.js +2 -0
- package/dist/nodefony/src/authenticator/externalSubject.js +36 -0
- package/dist/nodefony/src/authenticator/peekIssuer.js +56 -0
- package/dist/nodefony/src/crypto/secretCipher.js +79 -0
- package/dist/nodefony/src/csp.js +54 -0
- package/dist/nodefony/src/csrfToken.js +65 -0
- package/dist/nodefony/src/net/ssrfGuard.js +130 -0
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +37 -0
- package/dist/nodefony/src/oauth/providers/github.js +65 -0
- package/dist/nodefony/src/oauth/providers/oidc.js +48 -0
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +94 -0
- package/dist/nodefony/src/realtime/frameAuthorizer.js +279 -0
- package/dist/nodefony/src/realtime/realtimeContracts.js +1 -0
- package/dist/nodefony/src/sessionIdentity.js +35 -0
- package/dist/nodefony/src/throttle/LoginThrottler.js +97 -0
- package/dist/nodefony/src/token/AnonymousToken.js +40 -0
- package/dist/nodefony/src/token/JwtKeystore.js +160 -0
- package/dist/nodefony/src/token/MemoryTokenStore.js +236 -0
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +231 -0
- package/dist/nodefony/src/token/UserToken.js +67 -0
- package/dist/nodefony/src/token/jwtRuntime.js +19 -0
- package/dist/nodefony/src/token/secretFile.js +134 -0
- package/dist/nodefony/src/token/tokenCriteria.js +35 -0
- package/dist/nodefony/src/token/tokenFilters.js +72 -0
- package/dist/nodefony/src/token/tokenSort.js +40 -0
- package/dist/nodefony/src/token/tokenStatus.js +35 -0
- package/dist/nodefony/src/token/tokenStoreRegistry.js +25 -0
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +97 -0
- package/dist/nodefony/src/totp/totpCipher.js +30 -0
- package/dist/nodefony/src/totp/totpCrypto.js +226 -0
- package/dist/nodefony/src/totp/totpOperations.js +129 -0
- package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +18 -0
- package/dist/nodefony/src/voter/RoleVoter.js +32 -0
- package/dist/nodefony/src/voter/ScopeVoter.js +52 -0
- package/dist/nodefony/src/voter/voterRegistry.js +20 -0
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +121 -0
- package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +18 -0
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +87 -0
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +208 -0
- package/dist/nodefony/src/webhook/webhookCipher.js +27 -0
- package/dist/nodefony/src/webhook/webhookDelivery.js +102 -0
- package/dist/nodefony/src/webhook/webhookFilters.js +56 -0
- package/dist/nodefony/src/webhook/webhookSignature.js +51 -0
- package/dist/nodefony/src/webhook/webhookSort.js +48 -0
- package/dist/nodefony/src/webhook/webhookStoreRegistry.js +18 -0
- package/dist/types/index.d.ts +157 -0
- package/dist/types/nodefony/command/security-secrets.d.ts +24 -0
- package/dist/types/nodefony/command/security-token.d.ts +44 -0
- package/dist/types/nodefony/command/security-user-add.d.ts +28 -0
- package/dist/types/nodefony/command/security-user-delete.d.ts +25 -0
- package/dist/types/nodefony/command/security-user-list.d.ts +28 -0
- package/dist/types/nodefony/config/config.d.ts +295 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
- package/dist/types/nodefony/contracts/IAccessVoter.d.ts +23 -0
- package/dist/types/nodefony/contracts/IApiKey.d.ts +75 -0
- package/dist/types/nodefony/contracts/IAuditEvent.d.ts +94 -0
- package/dist/types/nodefony/contracts/IAuditStore.d.ts +80 -0
- package/dist/types/nodefony/contracts/IAuthenticator.d.ts +66 -0
- package/dist/types/nodefony/contracts/IAuthorizationService.d.ts +28 -0
- package/dist/types/nodefony/contracts/IFirewall.d.ts +64 -0
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +120 -0
- package/dist/types/nodefony/contracts/IJwtKeystore.d.ts +40 -0
- package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +51 -0
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +57 -0
- package/dist/types/nodefony/contracts/IToken.d.ts +41 -0
- package/dist/types/nodefony/contracts/ITokenStore.d.ts +240 -0
- package/dist/types/nodefony/contracts/ITotpSecret.d.ts +41 -0
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +88 -0
- package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +56 -0
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +118 -0
- package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +82 -0
- package/dist/types/nodefony/contracts/IWebhookStore.d.ts +85 -0
- package/dist/types/nodefony/contracts/index.d.ts +9 -0
- package/dist/types/nodefony/errors/AccessDeniedError.d.ts +10 -0
- package/dist/types/nodefony/errors/ApiKeyError.d.ts +17 -0
- package/dist/types/nodefony/errors/AuthenticationError.d.ts +10 -0
- package/dist/types/nodefony/errors/CsrfError.d.ts +19 -0
- package/dist/types/nodefony/errors/InvalidTargetError.d.ts +34 -0
- package/dist/types/nodefony/errors/SsrfError.d.ts +13 -0
- package/dist/types/nodefony/errors/ThrottledError.d.ts +16 -0
- package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +37 -0
- package/dist/types/nodefony/errors/WebAuthnError.d.ts +17 -0
- package/dist/types/nodefony/errors/index.d.ts +8 -0
- package/dist/types/nodefony/service/accessTokenVerifier.d.ts +29 -0
- package/dist/types/nodefony/service/apiKeys.d.ts +103 -0
- package/dist/types/nodefony/service/auditService.d.ts +30 -0
- package/dist/types/nodefony/service/authFlow.d.ts +123 -0
- package/dist/types/nodefony/service/authorization.d.ts +33 -0
- package/dist/types/nodefony/service/cors.d.ts +48 -0
- package/dist/types/nodefony/service/csrf.d.ts +57 -0
- package/dist/types/nodefony/service/firewall.d.ts +148 -0
- package/dist/types/nodefony/service/oauth2.d.ts +66 -0
- package/dist/types/nodefony/service/securityHeaders.d.ts +66 -0
- package/dist/types/nodefony/service/tokenService.d.ts +103 -0
- package/dist/types/nodefony/service/totp.d.ts +58 -0
- package/dist/types/nodefony/service/webAuthn.d.ts +123 -0
- package/dist/types/nodefony/service/webhooks.d.ts +160 -0
- package/dist/types/nodefony/src/RoleHierarchyWalker.d.ts +21 -0
- package/dist/types/nodefony/src/SecuredArea.d.ts +31 -0
- package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +82 -0
- package/dist/types/nodefony/src/admin/WebhookAdminApi.d.ts +30 -0
- package/dist/types/nodefony/src/admin/adminAudit.d.ts +27 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +31 -0
- package/dist/types/nodefony/src/apikey/apiKeyFormat.d.ts +43 -0
- package/dist/types/nodefony/src/audit/MemoryAuditStore.d.ts +33 -0
- package/dist/types/nodefony/src/audit/auditBridge.d.ts +49 -0
- package/dist/types/nodefony/src/audit/auditFilters.d.ts +56 -0
- package/dist/types/nodefony/src/audit/auditStoreRegistry.d.ts +37 -0
- package/dist/types/nodefony/src/audit/readAuditContext.d.ts +17 -0
- package/dist/types/nodefony/src/audit/recordAudit.d.ts +13 -0
- package/dist/types/nodefony/src/authenticator/AnonymousAuthenticator.d.ts +26 -0
- package/dist/types/nodefony/src/authenticator/ApiKeyAuthenticator.d.ts +74 -0
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +132 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +78 -0
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +69 -0
- package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +70 -0
- package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +53 -0
- package/dist/types/nodefony/src/authenticator/authenticatorRegistry.d.ts +39 -0
- package/dist/types/nodefony/src/authenticator/bearer.d.ts +22 -0
- package/dist/types/nodefony/src/authenticator/externalSubject.d.ts +27 -0
- package/dist/types/nodefony/src/authenticator/peekIssuer.d.ts +31 -0
- package/dist/types/nodefony/src/crypto/secretCipher.d.ts +31 -0
- package/dist/types/nodefony/src/csp.d.ts +39 -0
- package/dist/types/nodefony/src/csrfToken.d.ts +36 -0
- package/dist/types/nodefony/src/net/ssrfGuard.d.ts +43 -0
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +45 -0
- package/dist/types/nodefony/src/oauth/providers/github.d.ts +9 -0
- package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +35 -0
- package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +62 -0
- package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +171 -0
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +139 -0
- package/dist/types/nodefony/src/sessionIdentity.d.ts +20 -0
- package/dist/types/nodefony/src/throttle/LoginThrottler.d.ts +68 -0
- package/dist/types/nodefony/src/token/AnonymousToken.d.ts +23 -0
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +43 -0
- package/dist/types/nodefony/src/token/MemoryTokenStore.d.ts +66 -0
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +149 -0
- package/dist/types/nodefony/src/token/UserToken.d.ts +41 -0
- package/dist/types/nodefony/src/token/jwtRuntime.d.ts +28 -0
- package/dist/types/nodefony/src/token/secretFile.d.ts +70 -0
- package/dist/types/nodefony/src/token/tokenCriteria.d.ts +20 -0
- package/dist/types/nodefony/src/token/tokenFilters.d.ts +76 -0
- package/dist/types/nodefony/src/token/tokenSort.d.ts +33 -0
- package/dist/types/nodefony/src/token/tokenStatus.d.ts +38 -0
- package/dist/types/nodefony/src/token/tokenStoreRegistry.d.ts +38 -0
- package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +43 -0
- package/dist/types/nodefony/src/totp/totpCipher.d.ts +9 -0
- package/dist/types/nodefony/src/totp/totpCrypto.d.ts +164 -0
- package/dist/types/nodefony/src/totp/totpOperations.d.ts +73 -0
- package/dist/types/nodefony/src/totp/totpSecretStoreRegistry.d.ts +27 -0
- package/dist/types/nodefony/src/voter/RoleVoter.d.ts +25 -0
- package/dist/types/nodefony/src/voter/ScopeVoter.d.ts +30 -0
- package/dist/types/nodefony/src/voter/voterRegistry.d.ts +33 -0
- package/dist/types/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.d.ts +39 -0
- package/dist/types/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.d.ts +26 -0
- package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +37 -0
- package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +69 -0
- package/dist/types/nodefony/src/webhook/webhookCipher.d.ts +8 -0
- package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +28 -0
- package/dist/types/nodefony/src/webhook/webhookFilters.d.ts +64 -0
- package/dist/types/nodefony/src/webhook/webhookSignature.d.ts +20 -0
- package/dist/types/nodefony/src/webhook/webhookSort.d.ts +39 -0
- package/dist/types/nodefony/src/webhook/webhookStoreRegistry.d.ts +31 -0
- package/docs/api-keys.md +691 -0
- package/docs/audit.md +751 -0
- package/docs/authenticators.md +487 -0
- package/docs/authorization.md +497 -0
- package/docs/cors.md +497 -0
- package/docs/csrf.md +392 -0
- package/docs/external-jwt.md +181 -0
- package/docs/firewall.md +546 -0
- package/docs/headers.md +616 -0
- package/docs/index.md +207 -0
- package/docs/lexique.md +190 -0
- package/docs/oauth2.md +575 -0
- package/docs/obtenir-un-jeton.md +225 -0
- package/docs/tokens.md +520 -0
- package/docs/totp.md +804 -0
- package/docs/webauthn.md +733 -0
- package/docs/webhooks.md +1016 -0
- package/package.json +83 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
import { defineSecurityConfig } from "../config/defineModuleConfig.js";
|
|
2
|
+
import { decryptSecret, encryptSecret, generateEphemeralKey } from "../src/crypto/secretCipher.js";
|
|
3
|
+
import { WEBHOOK_FACETS } from "../src/webhook/webhookFilters.js";
|
|
4
|
+
import { getWebhookStoreFactory, listWebhookStores } from "../src/webhook/webhookStoreRegistry.js";
|
|
5
|
+
import { deriveWebhookKey } from "../src/webhook/webhookCipher.js";
|
|
6
|
+
import { assertPublicUrl } from "../src/net/ssrfGuard.js";
|
|
7
|
+
import { WebhookDispatcher } from "../src/webhook/WebhookDispatcher.js";
|
|
8
|
+
import { deliverWebhook } from "../src/webhook/webhookDelivery.js";
|
|
9
|
+
import { AUTO_STORE, EMPTY_INFRA, Service, countFacets, deriveStoreBackend, readStoreLocation, resolveAutoStore } from "nodefony";
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { Buffer } from "node:buffer";
|
|
12
|
+
import { schemaMismatchOf } from "@nodefony/http";
|
|
13
|
+
//#region nodefony/service/webhooks.ts
|
|
14
|
+
const serviceName = "webhooks";
|
|
15
|
+
/** Historique de livraisons gardé PAR endpoint (ring borné, RAM, par pod). */
|
|
16
|
+
const MAX_DELIVERIES_PER_ENDPOINT = 20;
|
|
17
|
+
/** Corps de requête tronqué dans l'historique (anti-mémoire). */
|
|
18
|
+
const MAX_RECORDED_BODY = 8192;
|
|
19
|
+
/** Retire le secret chiffré → vue publique. */
|
|
20
|
+
function toSummary(endpoint) {
|
|
21
|
+
const { secretEnc: _omit, ...rest } = endpoint;
|
|
22
|
+
return rest;
|
|
23
|
+
}
|
|
24
|
+
/** Identifiant public d'endpoint (`wh_<random url-safe>`). */
|
|
25
|
+
function generateId() {
|
|
26
|
+
return `wh_${randomBytes(12).toString("base64url")}`;
|
|
27
|
+
}
|
|
28
|
+
/** Secret de signature Standard Webhooks (`whsec_<base64 256 bits>`). */
|
|
29
|
+
function generateSecret() {
|
|
30
|
+
return `whsec_${randomBytes(32).toString("base64")}`;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* **Webhooks sortants** (P6.13) — service d'orchestration du registre d'endpoints.
|
|
34
|
+
*
|
|
35
|
+
* Coquille fine : au boot (si `webhooks.enabled`) il résout le **store**
|
|
36
|
+
* d'endpoints pluggable + la **clé de chiffrement** des secrets de signature, puis
|
|
37
|
+
* expose le CRUD (register/list/update/rotate/revoke). Le secret de signature est
|
|
38
|
+
* **chiffré au repos** (réversible : relu pour signer chaque livraison, jamais
|
|
39
|
+
* haché). La livraison signée elle-même (Standard Webhooks v1) vit dans le
|
|
40
|
+
* dispatcher (Slice B), qui consomme {@link getSnapshot}/{@link getSigningKey}.
|
|
41
|
+
*
|
|
42
|
+
* Toute URL est validée **anti-SSRF** à l'enregistrement (et re-pinnée à la
|
|
43
|
+
* livraison). Politique de clé calquée sur TOTP/RedisIdempotencyStore : absente en
|
|
44
|
+
* dev = clé éphémère + WARNING ; en production = fatal (webhooks désactivés).
|
|
45
|
+
*/
|
|
46
|
+
var WebhookService = class extends Service {
|
|
47
|
+
module;
|
|
48
|
+
#config = null;
|
|
49
|
+
#store = null;
|
|
50
|
+
#key = null;
|
|
51
|
+
#ready = false;
|
|
52
|
+
/** Cache mémoire des endpoints (snapshot sync pour le dispatcher). */
|
|
53
|
+
#endpoints = null;
|
|
54
|
+
/** Date (ms) du dernier chargement du cache — borne sa fraîcheur (multi-pod). */
|
|
55
|
+
#loadedAt = 0;
|
|
56
|
+
/** Un rechargement est en vol : évite N relectures concurrentes sous rafale. */
|
|
57
|
+
#reloading = false;
|
|
58
|
+
/** Dispatcher de livraison (abonné à l'audit) — créé au boot si l'audit existe. */
|
|
59
|
+
#dispatcher = null;
|
|
60
|
+
/** Désabonnement de l'audit (appelé à l'arrêt). */
|
|
61
|
+
#unsubscribe = null;
|
|
62
|
+
/** Sink d'audit — trace l'auto-désactivation (signal borné, pas chaque échec). */
|
|
63
|
+
#audit = null;
|
|
64
|
+
/** Historique de livraisons par endpoint (lazy, ring borné, RAM par pod). */
|
|
65
|
+
#deliveries = null;
|
|
66
|
+
constructor(module) {
|
|
67
|
+
super(serviceName, module.container, module.notificationsCenter, module.options);
|
|
68
|
+
this.module = module;
|
|
69
|
+
this.kernel?.once("onBoot", () => this.#build());
|
|
70
|
+
this.kernel?.once("onTerminate", () => this.#shutdown());
|
|
71
|
+
}
|
|
72
|
+
#build() {
|
|
73
|
+
let config;
|
|
74
|
+
try {
|
|
75
|
+
config = defineSecurityConfig(this.options);
|
|
76
|
+
} catch {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (!config.webhooks.enabled) {
|
|
80
|
+
this.log("webhooks idle — désactivés en config", "DEBUG");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const store = this.#resolveStore(config);
|
|
84
|
+
if (!store) return;
|
|
85
|
+
const key = this.#resolveKey(config);
|
|
86
|
+
if (!key) return;
|
|
87
|
+
this.#config = config;
|
|
88
|
+
this.#store = store;
|
|
89
|
+
this.#key = key;
|
|
90
|
+
this.#ready = true;
|
|
91
|
+
this.#reloadSnapshot();
|
|
92
|
+
this.#attachDispatcher();
|
|
93
|
+
this.log(`webhooks ready — store "${config.webhooks.store}"`, "DEBUG");
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Branche le dispatcher de livraison sur le journal d'audit (abonné). No-op si
|
|
97
|
+
* l'audit est absent (CRUD seul). Le listener ne fait que filtrer + différer
|
|
98
|
+
* (jamais de travail bloquant dans le hot-path de `record()`).
|
|
99
|
+
*/
|
|
100
|
+
#attachDispatcher() {
|
|
101
|
+
const audit = this.get("auditService");
|
|
102
|
+
if (!audit || typeof audit.subscribe !== "function") {
|
|
103
|
+
this.log("webhooks: auditService absent — dispatcher inactif", "WARNING");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
this.#audit = audit;
|
|
107
|
+
this.#dispatcher = new WebhookDispatcher({
|
|
108
|
+
endpointCount: () => this.endpointCount(),
|
|
109
|
+
getSnapshot: () => this.getSnapshot(),
|
|
110
|
+
secretOf: (ep) => this.decryptEndpointSecret(ep).toString("utf8"),
|
|
111
|
+
policy: this.getDeliveryPolicy(),
|
|
112
|
+
resolveTarget: (url) => this.#resolveTarget(url),
|
|
113
|
+
deliver: (url, body, headers, opts) => deliverWebhook(url, body, headers, opts),
|
|
114
|
+
markDelivery: (id, r) => this.markDelivery(id, r),
|
|
115
|
+
recordDelivery: (id, rec) => this.#recordDelivery(id, rec),
|
|
116
|
+
now: () => Date.now(),
|
|
117
|
+
newMessageId: () => `msg_${randomBytes(12).toString("base64url")}`,
|
|
118
|
+
schedule: (fn, ms) => {
|
|
119
|
+
const t = setTimeout(fn, ms);
|
|
120
|
+
t.unref?.();
|
|
121
|
+
return () => clearTimeout(t);
|
|
122
|
+
},
|
|
123
|
+
log: (m) => this.log(m, "ERROR")
|
|
124
|
+
});
|
|
125
|
+
this.#unsubscribe = audit.subscribe((e) => this.#dispatcher.onAuditEvent(e));
|
|
126
|
+
this.log("webhooks dispatcher abonné à l'audit", "DEBUG");
|
|
127
|
+
}
|
|
128
|
+
/** Arrêt propre : désabonnement audit + annulation des retries en vol. */
|
|
129
|
+
#shutdown() {
|
|
130
|
+
if (this.#unsubscribe) {
|
|
131
|
+
this.#unsubscribe();
|
|
132
|
+
this.#unsubscribe = null;
|
|
133
|
+
}
|
|
134
|
+
this.#dispatcher?.shutdown();
|
|
135
|
+
this.#dispatcher = null;
|
|
136
|
+
}
|
|
137
|
+
/** Adapter posé au container (ORM) prioritaire, sinon driver configuré (registre). */
|
|
138
|
+
#resolveStore(config) {
|
|
139
|
+
const existing = this.get("webhookStore");
|
|
140
|
+
if (existing) {
|
|
141
|
+
this.kernel?.registerStoreResolution({
|
|
142
|
+
brick: "webhooks",
|
|
143
|
+
nature: "durable",
|
|
144
|
+
configured: config.webhooks.store,
|
|
145
|
+
resolved: deriveStoreBackend(existing),
|
|
146
|
+
available: listWebhookStores(),
|
|
147
|
+
reason: "adapter posé au container (infra database déclarée)",
|
|
148
|
+
configPath: "security.webhooks.store",
|
|
149
|
+
location: readStoreLocation(existing)
|
|
150
|
+
});
|
|
151
|
+
return existing;
|
|
152
|
+
}
|
|
153
|
+
let driver = config.webhooks.store;
|
|
154
|
+
let reason = `store explicitement configuré ("${driver}")`;
|
|
155
|
+
if (driver === AUTO_STORE) {
|
|
156
|
+
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listWebhookStores());
|
|
157
|
+
driver = auto.store;
|
|
158
|
+
reason = auto.reason;
|
|
159
|
+
this.log(`webhooks.store "auto" → "${driver}" (${auto.reason})`, "INFO");
|
|
160
|
+
}
|
|
161
|
+
const factory = getWebhookStoreFactory(driver);
|
|
162
|
+
if (!factory) {
|
|
163
|
+
const msg = `webhooks store "${driver}" inconnu (enregistrés : ${listWebhookStores().join(", ") || "aucun"})`;
|
|
164
|
+
if (this.kernel?.environment === "production") throw new Error(`${msg} — webhooks indisponibles : boot avorté.`);
|
|
165
|
+
this.log(`${msg} — webhooks indisponibles`, "CRITIC");
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
if (driver === "memory" && this.kernel?.environment === "production") this.log("webhooks.store \"memory\" en PRODUCTION — abonnements volatils et per-pod : perdus au redémarrage, non partagés entre pods. Déclarer une infra durable (NF_DATABASE_URL).", "WARNING");
|
|
169
|
+
const store = factory({
|
|
170
|
+
container: this.container,
|
|
171
|
+
config
|
|
172
|
+
});
|
|
173
|
+
this.container?.set("webhookStore", store);
|
|
174
|
+
this.kernel?.registerStoreResolution({
|
|
175
|
+
brick: "webhooks",
|
|
176
|
+
nature: "durable",
|
|
177
|
+
configured: config.webhooks.store,
|
|
178
|
+
resolved: driver,
|
|
179
|
+
available: listWebhookStores(),
|
|
180
|
+
reason,
|
|
181
|
+
configPath: "security.webhooks.store",
|
|
182
|
+
location: readStoreLocation(store)
|
|
183
|
+
});
|
|
184
|
+
return store;
|
|
185
|
+
}
|
|
186
|
+
/** Clé AES-256 du secret au repos. Dev : éphémère + WARNING ; prod : fatal (null). */
|
|
187
|
+
#resolveKey(config) {
|
|
188
|
+
const material = config.webhooks.encryptionKey;
|
|
189
|
+
if (material && material.length > 0) return deriveWebhookKey(material);
|
|
190
|
+
if (this.kernel?.environment === "production") {
|
|
191
|
+
this.log("webhooks: AUCUNE clé de chiffrement (`security.webhooks.encryptionKey`) en PRODUCTION — webhooks désactivés (un secret chiffré par une clé éphémère serait illisible après redémarrage / sur les autres pods). Fournir une clé depuis l'environnement.", "CRITIC");
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
this.log("webhooks: aucune clé de chiffrement configurée — clé ÉPHÉMÈRE générée (dev). Les secrets de signature ne survivront pas au redémarrage. Définir `webhooks.encryptionKey` — générer la clé et le câblage : `npx nodefony security:secrets`.", "WARNING");
|
|
195
|
+
return generateEphemeralKey();
|
|
196
|
+
}
|
|
197
|
+
async #reloadSnapshot() {
|
|
198
|
+
if (!this.#store) return;
|
|
199
|
+
this.#reloading = true;
|
|
200
|
+
try {
|
|
201
|
+
const all = await this.#store.listAll();
|
|
202
|
+
this.#endpoints = new Map(all.map((e) => [e.id, e]));
|
|
203
|
+
} catch (e) {
|
|
204
|
+
if (schemaMismatchOf(e) !== null) this.log("webhooks: la base ne porte pas encore la table des points de livraison — c'est l'état normal avant la première migration. Aucun abonnement chargé ; la lecture sera retentée. Pour savoir où en est le schéma : `nodefony orm:migrate:status`.", "INFO");
|
|
205
|
+
else this.log(e, "ERROR");
|
|
206
|
+
} finally {
|
|
207
|
+
this.#loadedAt = Date.now();
|
|
208
|
+
this.#reloading = false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Borne la fraîcheur du cache d'endpoints — **le seul mécanisme qui rend les
|
|
213
|
+
* webhooks corrects à plusieurs pods.**
|
|
214
|
+
*
|
|
215
|
+
* Le store est partagé, le cache ne l'est pas : un endpoint créé sur le pod A
|
|
216
|
+
* n'existe pour le pod B qu'après relecture. Sans borne, B ne livrerait rien
|
|
217
|
+
* pour cet abonnement jusqu'à son redémarrage — et le cas le plus courant est
|
|
218
|
+
* le pire : des pods démarrés AVANT toute création de webhook court-circuitent
|
|
219
|
+
* sur `endpointCount() === 0` et ne rechargent jamais.
|
|
220
|
+
*
|
|
221
|
+
* Choix : relecture **paresseuse, déclenchée par la lecture**, jamais un timer
|
|
222
|
+
* — aucun coût quand il ne se passe rien, et le rechargement se paie là où il
|
|
223
|
+
* sert. Appel non bloquant : la lecture en cours sert le cache courant, la
|
|
224
|
+
* suivante voit l'état frais. La propagation est donc **éventuelle et bornée**
|
|
225
|
+
* par `webhooks.snapshotTtlS`, pas immédiate ; c'est écrit dans la config et
|
|
226
|
+
* dans la doc, jamais supposé.
|
|
227
|
+
*
|
|
228
|
+
* Coût hot-path : une soustraction et deux comparaisons, zéro allocation.
|
|
229
|
+
*/
|
|
230
|
+
#touchSnapshot() {
|
|
231
|
+
if (!this.#ready || this.#reloading) return;
|
|
232
|
+
const ttlMs = (this.#config?.webhooks.snapshotTtlS ?? 30) * 1e3;
|
|
233
|
+
if (Date.now() - this.#loadedAt < ttlMs) return;
|
|
234
|
+
this.#reloadSnapshot();
|
|
235
|
+
}
|
|
236
|
+
/** Le service est-il opérationnel (activé + store + clé) ? */
|
|
237
|
+
isReady() {
|
|
238
|
+
return this.#ready;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Champs de tri que le backend **actuellement branché** sait honorer, en
|
|
242
|
+
* vocabulaire public. Le data plane admin les passe en allowlist au traducteur
|
|
243
|
+
* de requête de page : hors de cette liste, un `?order=` est refusé en 400.
|
|
244
|
+
*
|
|
245
|
+
* La liste vient du store, jamais d'une constante recopiée ici : un backend
|
|
246
|
+
* qui ne trierait pas refuserait alors le tri **sans qu'aucune règle
|
|
247
|
+
* supplémentaire ne soit écrite**. Store absent (webhooks désactivés) ⇒ aucune
|
|
248
|
+
* capacité annoncée, donc aucun tri promis.
|
|
249
|
+
*
|
|
250
|
+
* @returns les champs triables, ou un tableau vide.
|
|
251
|
+
*/
|
|
252
|
+
sortableFields() {
|
|
253
|
+
return this.#store?.sortableFields ?? [];
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Enregistre un endpoint : valide l'URL (anti-SSRF), génère un secret de
|
|
257
|
+
* signature, le chiffre au repos. Retourne l'endpoint + le secret **en clair**
|
|
258
|
+
* (la seule occasion de le lire pour le copier).
|
|
259
|
+
*
|
|
260
|
+
* @throws SsrfError si l'URL est invalide / cible non publique.
|
|
261
|
+
*/
|
|
262
|
+
async register(input) {
|
|
263
|
+
this.#assertReady();
|
|
264
|
+
await this.#assertSafeUrl(input.url);
|
|
265
|
+
const secret = generateSecret();
|
|
266
|
+
const now = Date.now();
|
|
267
|
+
const endpoint = {
|
|
268
|
+
id: generateId(),
|
|
269
|
+
url: input.url,
|
|
270
|
+
secretEnc: encryptSecret(Buffer.from(secret, "utf8"), this.#key),
|
|
271
|
+
events: [...input.events],
|
|
272
|
+
enabled: input.enabled ?? true,
|
|
273
|
+
description: input.description ?? null,
|
|
274
|
+
tenantId: input.tenantId ?? null,
|
|
275
|
+
createdBy: input.createdBy ?? null,
|
|
276
|
+
createdAt: now,
|
|
277
|
+
updatedAt: now,
|
|
278
|
+
lastDeliveryAt: null,
|
|
279
|
+
lastDeliveryStatus: null,
|
|
280
|
+
lastDeliveryError: null,
|
|
281
|
+
failureCount: 0,
|
|
282
|
+
metadata: input.metadata ?? {}
|
|
283
|
+
};
|
|
284
|
+
await this.#store.save(endpoint);
|
|
285
|
+
this.#endpoints?.set(endpoint.id, endpoint);
|
|
286
|
+
return {
|
|
287
|
+
endpoint: toSummary(endpoint),
|
|
288
|
+
secret
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Page d'endpoints (vue publique, sans secret) — pagination **native au
|
|
293
|
+
* store** : la console n'a jamais tout le registre en RAM.
|
|
294
|
+
*
|
|
295
|
+
* @param query - fenêtre + filtres ({@link IWebhookListQuery}).
|
|
296
|
+
* @returns la page, chaque endpoint réduit à sa vue publique.
|
|
297
|
+
*/
|
|
298
|
+
async listPage(query) {
|
|
299
|
+
this.#assertReady();
|
|
300
|
+
const page = await this.#store.listPage(query);
|
|
301
|
+
return {
|
|
302
|
+
...page,
|
|
303
|
+
items: page.items.map(toSummary)
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Nombre d'endpoints correspondant aux filtres (`COUNT` natif au store).
|
|
308
|
+
*
|
|
309
|
+
* @param query - filtres ({@link IWebhookListQuery}) ; `limit` ignoré.
|
|
310
|
+
* @returns le compte exact, ou `-1` si le backend ne sait pas compter.
|
|
311
|
+
*/
|
|
312
|
+
async countEndpoints(query) {
|
|
313
|
+
this.#assertReady();
|
|
314
|
+
return this.#store.countEndpoints(query);
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Les compteurs de tête de la console — posés sur la collection ENTIÈRE, pas
|
|
318
|
+
* sur la page affichée.
|
|
319
|
+
*
|
|
320
|
+
* Un endpoint peut être **actif ET en échec** : les facettes se recoupent, et
|
|
321
|
+
* aucune n'est déduite d'une autre par soustraction. Chaque compteur vaut
|
|
322
|
+
* `null` si le backend ne sait pas compter.
|
|
323
|
+
*
|
|
324
|
+
* @param query - filtres à appliquer avant comptage (sans fenêtre).
|
|
325
|
+
*/
|
|
326
|
+
async countWebhookFacets(query) {
|
|
327
|
+
this.#assertReady();
|
|
328
|
+
return countFacets(WEBHOOK_FACETS, (facet) => this.#store.countEndpoints({
|
|
329
|
+
...query,
|
|
330
|
+
...facet
|
|
331
|
+
}));
|
|
332
|
+
}
|
|
333
|
+
/** Un endpoint par id (vue publique), ou `null`. */
|
|
334
|
+
async getEndpoint(id) {
|
|
335
|
+
this.#assertReady();
|
|
336
|
+
const found = await this.#store.findById(id);
|
|
337
|
+
return found ? toSummary(found) : null;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Met à jour les champs mutables (url/events/enabled/description/metadata).
|
|
341
|
+
* Une nouvelle `url` est re-validée anti-SSRF. Retourne l'endpoint mis à jour,
|
|
342
|
+
* ou `null` si absent.
|
|
343
|
+
*/
|
|
344
|
+
async update(id, patch) {
|
|
345
|
+
this.#assertReady();
|
|
346
|
+
const current = await this.#store.findById(id);
|
|
347
|
+
if (!current) return null;
|
|
348
|
+
if (patch.url !== void 0 && patch.url !== current.url) await this.#assertSafeUrl(patch.url);
|
|
349
|
+
const applied = {
|
|
350
|
+
...patch,
|
|
351
|
+
updatedAt: Date.now()
|
|
352
|
+
};
|
|
353
|
+
await this.#store.update(id, applied);
|
|
354
|
+
const next = {
|
|
355
|
+
...current,
|
|
356
|
+
...applied
|
|
357
|
+
};
|
|
358
|
+
this.#endpoints?.set(id, next);
|
|
359
|
+
return toSummary(next);
|
|
360
|
+
}
|
|
361
|
+
/** Active/désactive un endpoint (révocation douce = `false`). */
|
|
362
|
+
async setEnabled(id, enabled) {
|
|
363
|
+
return this.update(id, { enabled });
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Régénère le secret de signature (rotation) et retourne le nouveau en clair.
|
|
367
|
+
* L'ancien cesse immédiatement d'être valide. `null` si l'endpoint est absent.
|
|
368
|
+
*/
|
|
369
|
+
async rotateSecret(id) {
|
|
370
|
+
this.#assertReady();
|
|
371
|
+
const current = await this.#store.findById(id);
|
|
372
|
+
if (!current) return null;
|
|
373
|
+
const secret = generateSecret();
|
|
374
|
+
const patch = {
|
|
375
|
+
secretEnc: encryptSecret(Buffer.from(secret, "utf8"), this.#key),
|
|
376
|
+
updatedAt: Date.now()
|
|
377
|
+
};
|
|
378
|
+
await this.#store.update(id, patch);
|
|
379
|
+
const next = {
|
|
380
|
+
...current,
|
|
381
|
+
...patch
|
|
382
|
+
};
|
|
383
|
+
this.#endpoints?.set(id, next);
|
|
384
|
+
return {
|
|
385
|
+
endpoint: toSummary(next),
|
|
386
|
+
secret
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Révèle le secret en clair d'un endpoint (réversible — usage admin, à auditer
|
|
391
|
+
* par l'appelant). `null` si absent.
|
|
392
|
+
*/
|
|
393
|
+
async revealSecret(id) {
|
|
394
|
+
this.#assertReady();
|
|
395
|
+
const current = await this.#store.findById(id);
|
|
396
|
+
if (!current) return null;
|
|
397
|
+
return decryptSecret(current.secretEnc, this.#key).toString("utf8");
|
|
398
|
+
}
|
|
399
|
+
/** Supprime un endpoint. Retourne `false` si absent. */
|
|
400
|
+
async delete(id) {
|
|
401
|
+
this.#assertReady();
|
|
402
|
+
if (!await this.#store.findById(id)) return false;
|
|
403
|
+
await this.#store.delete(id);
|
|
404
|
+
this.#endpoints?.delete(id);
|
|
405
|
+
this.#deliveries?.delete(id);
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Historique des dernières livraisons d'un endpoint (plus récentes d'abord) —
|
|
410
|
+
* ce que Nodefony a ENVOYÉ + la réponse observée. RAM, borné, par pod
|
|
411
|
+
* (observabilité éphémère, non persistée). `[]` si aucune livraison.
|
|
412
|
+
*/
|
|
413
|
+
listDeliveries(id) {
|
|
414
|
+
const ring = this.#deliveries?.get(id);
|
|
415
|
+
return ring ? [...ring] : [];
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Pousse une trace de livraison dans le ring de l'endpoint (lazy alloc, ring
|
|
419
|
+
* borné `MAX_DELIVERIES_PER_ENDPOINT`, corps tronqué). Appelé par le dispatcher
|
|
420
|
+
* sur l'issue FINALE d'une livraison.
|
|
421
|
+
*/
|
|
422
|
+
#recordDelivery(id, rec) {
|
|
423
|
+
if (this.#deliveries === null) this.#deliveries = /* @__PURE__ */ new Map();
|
|
424
|
+
let ring = this.#deliveries.get(id);
|
|
425
|
+
if (ring === void 0) {
|
|
426
|
+
ring = [];
|
|
427
|
+
this.#deliveries.set(id, ring);
|
|
428
|
+
}
|
|
429
|
+
const entry = {
|
|
430
|
+
ts: Date.now(),
|
|
431
|
+
messageId: rec.messageId,
|
|
432
|
+
type: rec.type,
|
|
433
|
+
attempt: rec.attempt,
|
|
434
|
+
ok: rec.ok,
|
|
435
|
+
status: rec.status,
|
|
436
|
+
error: rec.error,
|
|
437
|
+
durationMs: rec.durationMs,
|
|
438
|
+
requestBody: rec.requestBody.length > MAX_RECORDED_BODY ? rec.requestBody.slice(0, MAX_RECORDED_BODY) : rec.requestBody,
|
|
439
|
+
responseBody: rec.responseBody
|
|
440
|
+
};
|
|
441
|
+
ring.unshift(entry);
|
|
442
|
+
if (ring.length > MAX_DELIVERIES_PER_ENDPOINT) ring.length = MAX_DELIVERIES_PER_ENDPOINT;
|
|
443
|
+
}
|
|
444
|
+
/** Snapshot mémoire (sync) des endpoints — itération du dispatcher (si >0). */
|
|
445
|
+
getSnapshot() {
|
|
446
|
+
this.#touchSnapshot();
|
|
447
|
+
return this.#endpoints ? [...this.#endpoints.values()] : [];
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Nombre d'endpoints (0-alloc) — court-circuit hot-path du dispatcher.
|
|
451
|
+
*
|
|
452
|
+
* @remarks C'est ICI que la fraîcheur se joue, pas seulement dans
|
|
453
|
+
* {@link getSnapshot} : un pod démarré avant toute création de webhook a un
|
|
454
|
+
* cache VIDE, court-circuite sur ce zéro et n'atteindrait jamais le snapshot.
|
|
455
|
+
*/
|
|
456
|
+
endpointCount() {
|
|
457
|
+
this.#touchSnapshot();
|
|
458
|
+
return this.#endpoints ? this.#endpoints.size : 0;
|
|
459
|
+
}
|
|
460
|
+
/** Déchiffre le secret de signature d'un endpoint (pour signer une livraison). */
|
|
461
|
+
decryptEndpointSecret(endpoint) {
|
|
462
|
+
this.#assertReady();
|
|
463
|
+
return decryptSecret(endpoint.secretEnc, this.#key);
|
|
464
|
+
}
|
|
465
|
+
/** Politique de livraison (tolérance/retries/timeout…) issue de la config. */
|
|
466
|
+
getDeliveryPolicy() {
|
|
467
|
+
this.#assertReady();
|
|
468
|
+
const w = this.#config.webhooks;
|
|
469
|
+
return {
|
|
470
|
+
timestampToleranceS: w.timestampToleranceS,
|
|
471
|
+
maxRetries: w.maxRetries,
|
|
472
|
+
autoDisableThreshold: w.autoDisableThreshold,
|
|
473
|
+
deliveryTimeoutMs: w.deliveryTimeoutMs,
|
|
474
|
+
maxConcurrent: w.maxConcurrent,
|
|
475
|
+
maxQueue: w.maxQueue,
|
|
476
|
+
allowHttp: w.allowHttp,
|
|
477
|
+
denyPrivateIps: w.denyPrivateIps
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Enregistre le résultat d'une livraison (appelé par le dispatcher) :
|
|
482
|
+
* lastDelivery*, compteur d'échecs consécutifs, et **auto-désactivation** de
|
|
483
|
+
* l'endpoint au-delà du seuil (façon GitHub). Le succès remet le compteur à 0.
|
|
484
|
+
*/
|
|
485
|
+
async markDelivery(id, result) {
|
|
486
|
+
if (!this.#ready || !this.#store) return;
|
|
487
|
+
const current = await this.#store.findById(id);
|
|
488
|
+
if (!current) return;
|
|
489
|
+
const failureCount = result.ok ? 0 : current.failureCount + 1;
|
|
490
|
+
const now = Date.now();
|
|
491
|
+
const threshold = this.#config.webhooks.autoDisableThreshold;
|
|
492
|
+
const disable = !result.ok && threshold > 0 && failureCount >= threshold;
|
|
493
|
+
const patch = {
|
|
494
|
+
lastDeliveryAt: now,
|
|
495
|
+
lastDeliveryStatus: result.status,
|
|
496
|
+
lastDeliveryError: result.error,
|
|
497
|
+
failureCount,
|
|
498
|
+
updatedAt: now,
|
|
499
|
+
...disable ? { enabled: false } : {}
|
|
500
|
+
};
|
|
501
|
+
if (disable) {
|
|
502
|
+
this.log(`webhook ${id} auto-désactivé après ${failureCount} échecs consécutifs`, "WARNING");
|
|
503
|
+
this.#audit?.record({
|
|
504
|
+
category: "webhook",
|
|
505
|
+
action: "webhook.disabled",
|
|
506
|
+
outcome: "failure",
|
|
507
|
+
actor: null,
|
|
508
|
+
resource: id,
|
|
509
|
+
reason: "max_failures"
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
await this.#store.update(id, patch);
|
|
513
|
+
this.#endpoints?.set(id, {
|
|
514
|
+
...current,
|
|
515
|
+
...patch
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
#assertReady() {
|
|
519
|
+
if (!this.#ready || !this.#store || !this.#key) throw new Error("webhooks indisponibles (désactivés ou mal configurés)");
|
|
520
|
+
}
|
|
521
|
+
async #assertSafeUrl(url) {
|
|
522
|
+
const w = this.#config.webhooks;
|
|
523
|
+
await assertPublicUrl(url, {
|
|
524
|
+
allowPrivate: !w.denyPrivateIps,
|
|
525
|
+
allowHttp: w.allowHttp
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
/** Re-contrôle SSRF avant livraison → IP validées à pinner (anti-rebinding). */
|
|
529
|
+
async #resolveTarget(url) {
|
|
530
|
+
const w = this.#config.webhooks;
|
|
531
|
+
const { addresses } = await assertPublicUrl(url, {
|
|
532
|
+
allowPrivate: !w.denyPrivateIps,
|
|
533
|
+
allowHttp: w.allowHttp
|
|
534
|
+
});
|
|
535
|
+
return addresses;
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
//#endregion
|
|
539
|
+
export { WebhookService, WebhookService as default };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
//#region nodefony/src/RoleHierarchyWalker.ts
|
|
2
|
+
/**
|
|
3
|
+
* Résout la hiérarchie de rôles — `ROLE_ADMIN` hérite `ROLE_USER`, etc.
|
|
4
|
+
*
|
|
5
|
+
* Aplatissement DFS **précalculé au boot** (lecture O(1) au runtime, hot-path) +
|
|
6
|
+
* **détection de cycles au boot** (throw avec le chemin complet, pas de fail-silent).
|
|
7
|
+
* Niveau A de l'autorisation (P6.8).
|
|
8
|
+
*/
|
|
9
|
+
var RoleHierarchyWalker = class {
|
|
10
|
+
#flat = /* @__PURE__ */ new Map();
|
|
11
|
+
constructor(hierarchy = {}) {
|
|
12
|
+
this.#detectCycles(hierarchy);
|
|
13
|
+
this.#precompute(hierarchy);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* L'utilisateur (rôles plats) possède-t-il le rôle requis, hiérarchie résolue ?
|
|
17
|
+
*
|
|
18
|
+
* @param userRoles - rôles plats de l'utilisateur.
|
|
19
|
+
* @param required - rôle exigé.
|
|
20
|
+
*/
|
|
21
|
+
hasRole(userRoles, required) {
|
|
22
|
+
for (const role of userRoles) {
|
|
23
|
+
if (role === required) return true;
|
|
24
|
+
if (this.#flat.get(role)?.has(required)) return true;
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
/** Ensemble complet des rôles atteignables (plats + hérités). */
|
|
29
|
+
reachableRoles(userRoles) {
|
|
30
|
+
const out = new Set(userRoles);
|
|
31
|
+
for (const role of userRoles) {
|
|
32
|
+
const inherited = this.#flat.get(role);
|
|
33
|
+
if (inherited) for (const r of inherited) out.add(r);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
#precompute(hierarchy) {
|
|
38
|
+
for (const role of Object.keys(hierarchy)) this.#flat.set(role, this.#flatten(role, hierarchy));
|
|
39
|
+
}
|
|
40
|
+
#flatten(role, hierarchy) {
|
|
41
|
+
const out = /* @__PURE__ */ new Set();
|
|
42
|
+
const stack = [...hierarchy[role] ?? []];
|
|
43
|
+
while (stack.length) {
|
|
44
|
+
const current = stack.pop();
|
|
45
|
+
if (out.has(current)) continue;
|
|
46
|
+
out.add(current);
|
|
47
|
+
const children = hierarchy[current];
|
|
48
|
+
if (children) for (const child of children) stack.push(child);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
#detectCycles(hierarchy) {
|
|
53
|
+
const WHITE = 0;
|
|
54
|
+
const GRAY = 1;
|
|
55
|
+
const BLACK = 2;
|
|
56
|
+
const color = /* @__PURE__ */ new Map();
|
|
57
|
+
const path = [];
|
|
58
|
+
const visit = (node) => {
|
|
59
|
+
color.set(node, GRAY);
|
|
60
|
+
path.push(node);
|
|
61
|
+
for (const next of hierarchy[node] ?? []) {
|
|
62
|
+
const c = color.get(next) ?? WHITE;
|
|
63
|
+
if (c === GRAY) {
|
|
64
|
+
const start = path.indexOf(next);
|
|
65
|
+
const cycle = [...path.slice(start), next].join(" → ");
|
|
66
|
+
throw new Error(`RoleHierarchy: cycle détecté — ${cycle}`);
|
|
67
|
+
}
|
|
68
|
+
if (c === WHITE && hierarchy[next]) visit(next);
|
|
69
|
+
}
|
|
70
|
+
path.pop();
|
|
71
|
+
color.set(node, BLACK);
|
|
72
|
+
};
|
|
73
|
+
for (const node of Object.keys(hierarchy)) if ((color.get(node) ?? WHITE) === WHITE) visit(node);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
//#endregion
|
|
77
|
+
export { RoleHierarchyWalker, RoleHierarchyWalker as default };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
//#region nodefony/src/SecuredArea.ts
|
|
2
|
+
/**
|
|
3
|
+
* Zone sécurisée concrète — pattern d'URL compilé + métadonnées d'authentification.
|
|
4
|
+
*
|
|
5
|
+
* Objet **léger** (pas un Service DI : zéro besoin d'event/log par zone, hot-path).
|
|
6
|
+
* Le firewall en instancie une par entrée `areas` de la config, triées par
|
|
7
|
+
* spécificité au boot.
|
|
8
|
+
*/
|
|
9
|
+
var SecuredArea = class {
|
|
10
|
+
name;
|
|
11
|
+
pattern;
|
|
12
|
+
security;
|
|
13
|
+
stateless;
|
|
14
|
+
mode;
|
|
15
|
+
authenticators;
|
|
16
|
+
host;
|
|
17
|
+
realtime;
|
|
18
|
+
resource;
|
|
19
|
+
constructor(name, config) {
|
|
20
|
+
this.name = name;
|
|
21
|
+
this.pattern = new RegExp(config.pattern, "u");
|
|
22
|
+
this.security = config.security;
|
|
23
|
+
this.stateless = config.stateless;
|
|
24
|
+
this.mode = config.mode;
|
|
25
|
+
this.authenticators = config.authenticators;
|
|
26
|
+
this.host = config.host;
|
|
27
|
+
this.realtime = config.realtime;
|
|
28
|
+
this.resource = config.resource;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Cœur du match — pathname (+ host éventuel) déjà extraits, SANS `context`.
|
|
32
|
+
* Réutilisable par le verrou WebSocket (une frame n'a qu'un path) : source
|
|
33
|
+
* UNIQUE de la décision de zone (invariant `api.request {path}` ≤ `GET {path}`).
|
|
34
|
+
*/
|
|
35
|
+
matchPath(pathname, host) {
|
|
36
|
+
if (this.host && this.host !== host) return false;
|
|
37
|
+
return this.pattern.test(pathname);
|
|
38
|
+
}
|
|
39
|
+
/** La requête tombe-t-elle dans cette zone ? (host éventuel + pathname). */
|
|
40
|
+
match(context) {
|
|
41
|
+
const req = context.request;
|
|
42
|
+
if (!req) return false;
|
|
43
|
+
const rp = req.pathname;
|
|
44
|
+
if (typeof rp === "string") return this.matchPath(rp, context.domain);
|
|
45
|
+
if (!req.url) return false;
|
|
46
|
+
const pathname = req.url instanceof URL ? req.url.pathname : String(req.url);
|
|
47
|
+
return this.matchPath(pathname, context.domain);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
//#endregion
|
|
51
|
+
export { SecuredArea, SecuredArea as default };
|