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

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 (74) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/index.js +60 -60
  3. package/dist/nodefony/config/config.js +13 -5
  4. package/dist/nodefony/contracts/IWebhookPayload.js +1 -0
  5. package/dist/nodefony/errors/index.js +4 -4
  6. package/dist/nodefony/keycloak/keycloakAdmin.js +3 -3
  7. package/dist/nodefony/keycloak/security-keycloak-realm.js +1 -1
  8. package/dist/nodefony/service/accessTokenVerifier.js +5 -5
  9. package/dist/nodefony/service/apiKeys.js +8 -4
  10. package/dist/nodefony/service/authFlow.js +3 -3
  11. package/dist/nodefony/service/authorization.js +1 -1
  12. package/dist/nodefony/service/firewall.js +233 -41
  13. package/dist/nodefony/service/keycloak.js +1 -1
  14. package/dist/nodefony/service/oauth2.js +8 -4
  15. package/dist/nodefony/service/securityHeaders.js +340 -47
  16. package/dist/nodefony/service/tokenService.js +8 -8
  17. package/dist/nodefony/service/totp.js +3 -3
  18. package/dist/nodefony/service/webAuthn.js +1 -1
  19. package/dist/nodefony/service/webhooks.js +11 -4
  20. package/dist/nodefony/src/admin/SecurityAdminApi.js +19 -0
  21. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +1 -1
  22. package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +31 -7
  23. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +62 -20
  24. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +5 -2
  25. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +1 -1
  26. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +1 -1
  27. package/dist/nodefony/src/csp.js +265 -10
  28. package/dist/nodefony/src/cspReports.js +307 -0
  29. package/dist/nodefony/src/oauth/clientCredentials.js +1 -1
  30. package/dist/nodefony/src/oauth/metadata.js +1 -1
  31. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +17 -4
  32. package/dist/nodefony/src/oauth/providers/github.js +1 -1
  33. package/dist/nodefony/src/realtime/UserRealtimeToken.js +59 -2
  34. package/dist/nodefony/src/realtime/frameAuthorizer.js +7 -6
  35. package/dist/nodefony/src/token/RemoteJwtVerifier.js +8 -2
  36. package/dist/nodefony/src/token/tokenTimeClaims.js +34 -0
  37. package/dist/nodefony/src/totp/totpOperations.js +1 -1
  38. package/dist/nodefony/src/voter/RoleVoter.js +1 -1
  39. package/dist/nodefony/src/webhook/WebhookDispatcher.js +71 -17
  40. package/dist/nodefony/src/webhook/webhookDelivery.js +3 -3
  41. package/dist/nodefony/testing/index.js +98 -0
  42. package/dist/types/index.d.ts +1 -0
  43. package/dist/types/nodefony/config/config.d.ts +2 -0
  44. package/dist/types/nodefony/contracts/IApiKey.d.ts +8 -2
  45. package/dist/types/nodefony/contracts/IFirewall.d.ts +15 -2
  46. package/dist/types/nodefony/contracts/ITokenStore.d.ts +7 -3
  47. package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +13 -3
  48. package/dist/types/nodefony/contracts/IWebhookPayload.d.ts +24 -0
  49. package/dist/types/nodefony/service/apiKeys.d.ts +3 -0
  50. package/dist/types/nodefony/service/firewall.d.ts +28 -7
  51. package/dist/types/nodefony/service/securityHeaders.d.ts +82 -11
  52. package/dist/types/nodefony/service/webhooks.d.ts +7 -1
  53. package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +3 -0
  54. package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +11 -0
  55. package/dist/types/nodefony/src/csp.d.ts +142 -0
  56. package/dist/types/nodefony/src/cspReports.d.ts +73 -0
  57. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +15 -0
  58. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +35 -1
  59. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +2 -2
  60. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +25 -17
  61. package/dist/types/nodefony/src/token/tokenTimeClaims.d.ts +32 -0
  62. package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +36 -1
  63. package/dist/types/nodefony/testing/index.d.ts +57 -0
  64. package/docs/api-keys.md +3 -1
  65. package/docs/authenticators.md +6 -4
  66. package/docs/csrf.md +3 -2
  67. package/docs/external-jwt.md +5 -1
  68. package/docs/firewall.md +3 -2
  69. package/docs/headers.md +427 -71
  70. package/docs/login-page.md +4 -3
  71. package/docs/totp.md +2 -2
  72. package/docs/webauthn.md +2 -2
  73. package/docs/webhooks.md +57 -11
  74. package/package.json +20 -14
@@ -1,4 +1,5 @@
1
1
  import { webhookSignatureHeaders } from "./webhookSignature.js";
2
+ import { MAX_CHANNEL_PATTERN_WILDCARDS, compileChannelPattern, countWildcards } from "nodefony";
2
3
  // nodefony/src/webhook/WebhookDispatcher.ts
3
4
  /**
4
5
  * Dispatcher de webhooks sortants — abonné au journal d'audit, il filtre les
@@ -20,15 +21,60 @@ import { webhookSignatureHeaders } from "./webhookSignature.js";
20
21
  */
21
22
  const MAX_BACKOFF_MS = 3e5;
22
23
  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;
24
+ /**
25
+ * Motifs compilés par liste d'abonnement — une fois par tableau `events`, jamais par
26
+ * événement. La clé est le tableau lui-même : un abonnement modifié porte un tableau
27
+ * neuf, donc se recompile, et l'ancien part avec lui au ramasse-miettes.
28
+ */
29
+ const compiledSubscriptions = /* @__PURE__ */ new WeakMap();
30
+ /**
31
+ * Rend les motifs compilés d'une liste d'abonnement, compilés à sa première lecture puis relus du cache.
32
+ *
33
+ * Deux appels sur le MÊME tableau rendent la même liste : c'est ce qui garantit
34
+ * qu'un abonnement n'est jamais recompilé par événement. Un motif trop large
35
+ * (refusé par `compileChannelPattern`) n'y figure pas.
36
+ *
37
+ * @param patterns - les motifs de l'abonnement (`IWebhookEndpoint.events`).
38
+ * @returns les expressions compilées, une par motif valide.
39
+ */
40
+ function compiledPatterns(patterns) {
41
+ let compiled = compiledSubscriptions.get(patterns);
42
+ if (compiled === void 0) {
43
+ const list = [];
44
+ for (const p of patterns) {
45
+ const re = compileChannelPattern(p);
46
+ if (re !== null) list.push(re);
47
+ }
48
+ compiled = list;
49
+ compiledSubscriptions.set(patterns, compiled);
28
50
  }
51
+ return compiled;
52
+ }
53
+ /**
54
+ * Dit si un abonnement webhook désigne une action d'audit, selon la grammaire commune aux canaux et aux webhooks (ADR-0023 W1).
55
+ *
56
+ * `*` vaut n'importe quelle suite de caractères : `*` désigne tout, `login.*` les
57
+ * actions qui commencent par `login.`, `commande:*` aussi bien que `*:créée`
58
+ * (`compileChannelPattern`, cœur). Chaque liste n'est compilée qu'une fois.
59
+ *
60
+ * @param patterns - les motifs de l'abonnement (`IWebhookEndpoint.events`).
61
+ * @param action - le nom de l'action d'audit.
62
+ * @returns `true` si un motif au moins désigne l'action.
63
+ */
64
+ function matchesSubscription(patterns, action) {
65
+ for (const re of compiledPatterns(patterns)) if (re.test(action)) return true;
29
66
  return false;
30
67
  }
31
68
  /**
69
+ * Refuse une liste d'abonnement dont un motif porte trop de `*`, en le nommant.
70
+ *
71
+ * @param patterns - les motifs proposés à l'enregistrement ou à la mise à jour.
72
+ * @throws Error si un motif dépasse `MAX_CHANNEL_PATTERN_WILDCARDS` étoiles.
73
+ */
74
+ function assertSubscriptionPatterns(patterns) {
75
+ for (const p of patterns) if (countWildcards(p) > MAX_CHANNEL_PATTERN_WILDCARDS) throw new Error(`webhooks: le motif « ${p} » porte ${countWildcards(p)} caractères « * » (maximum ${MAX_CHANNEL_PATTERN_WILDCARDS}) — un motif en demande un, deux au pire.`);
76
+ }
77
+ /**
32
78
  * Classe le résultat d'une livraison : 2xx = succès ; réseau/timeout/429/408/5xx =
33
79
  * réessayable ; 3xx/4xx (config cliente erronée) = échec définitif (pas de retry).
34
80
  */
@@ -71,7 +117,8 @@ var WebhookDispatcher = class {
71
117
  this.#enqueue({
72
118
  ep,
73
119
  event,
74
- attempt: 0
120
+ attempt: 0,
121
+ messageId: null
75
122
  });
76
123
  }
77
124
  }
@@ -112,17 +159,20 @@ var WebhookDispatcher = class {
112
159
  }
113
160
  /** Signe + livre une tentative ; gère succès / retry / échec définitif. */
114
161
  async #process(job) {
115
- const { ep, event, attempt } = job;
162
+ const { event, attempt } = job;
163
+ const ep = this.#deps.currentEndpoint(job.ep.id);
164
+ if (ep === null || !ep.enabled || !matchesSubscription(ep.events, event.action)) return;
116
165
  try {
117
- const id = this.#deps.newMessageId();
166
+ const id = job.messageId ?? this.#deps.newMessageId();
118
167
  const nowMs = this.#deps.now();
119
168
  const tsS = Math.floor(nowMs / 1e3);
120
- const body = JSON.stringify({
169
+ const payload = {
121
170
  id,
122
171
  timestamp: new Date(nowMs).toISOString(),
123
172
  type: event.action,
124
173
  data: event
125
- });
174
+ };
175
+ const body = JSON.stringify(payload);
126
176
  const headers = {
127
177
  "content-type": "application/json",
128
178
  "user-agent": "Nodefony-Webhooks/1.0",
@@ -132,7 +182,7 @@ var WebhookDispatcher = class {
132
182
  try {
133
183
  addresses = await this.#deps.resolveTarget(ep.url);
134
184
  } catch (e) {
135
- const error = `ssrf: ${e.message}`;
185
+ const error = `ssrf: ${e instanceof Error ? e.message : String(e)}`;
136
186
  await this.#deps.markDelivery(ep.id, {
137
187
  ok: false,
138
188
  status: null,
@@ -157,7 +207,7 @@ var WebhookDispatcher = class {
157
207
  allowHttp: this.#deps.policy.allowHttp
158
208
  });
159
209
  if (classifyDelivery(result) === "retry" && attempt < this.#deps.policy.maxRetries) {
160
- this.#scheduleRetry(job);
210
+ this.#scheduleRetry(job, id);
161
211
  return;
162
212
  }
163
213
  await this.#deps.markDelivery(ep.id, result);
@@ -173,11 +223,14 @@ var WebhookDispatcher = class {
173
223
  responseBody: result.responseBody ?? null
174
224
  });
175
225
  } catch (e) {
176
- this.#deps.log(`webhook dispatch ${ep.id}: ${e.message}`);
226
+ this.#deps.log(`webhook dispatch ${ep.id}: ${e instanceof Error ? e.message : String(e)}`);
177
227
  }
178
228
  }
179
- /** Replanifie la livraison après backoff (repasse par la file bornée). */
180
- #scheduleRetry(job) {
229
+ /**
230
+ * Replanifie la livraison après backoff (repasse par la file bornée), sous le
231
+ * MÊME `webhook-id` que la tentative échouée.
232
+ */
233
+ #scheduleRetry(job, messageId) {
181
234
  if (this.#stopped) return;
182
235
  this.#timers ??= /* @__PURE__ */ new Set();
183
236
  const cancel = this.#deps.schedule(() => {
@@ -185,7 +238,8 @@ var WebhookDispatcher = class {
185
238
  this.#enqueue({
186
239
  ep: job.ep,
187
240
  event: job.event,
188
- attempt: job.attempt + 1
241
+ attempt: job.attempt + 1,
242
+ messageId
189
243
  });
190
244
  }, backoffMs(job.attempt));
191
245
  this.#timers.add(cancel);
@@ -205,4 +259,4 @@ var WebhookDispatcher = class {
205
259
  this.#queue = null;
206
260
  }
207
261
  };
208
- export { WebhookDispatcher, backoffMs, classifyDelivery, matchesSubscription };
262
+ export { WebhookDispatcher, assertSubscriptionPatterns, backoffMs, classifyDelivery, compiledPatterns, matchesSubscription };
@@ -1,7 +1,7 @@
1
+ import { request } from "node:http";
1
2
  import { Buffer } from "node:buffer";
2
3
  import { isIP } from "node:net";
3
- import { request } from "node:https";
4
- import { request as request$1 } from "node:http";
4
+ import { request as request$1 } from "node:https";
5
5
  // nodefony/src/webhook/webhookDelivery.ts
6
6
  /**
7
7
  * Émetteur HTTP d'une livraison webhook — `node:http(s)` natif (0 dép). Trois
@@ -49,7 +49,7 @@ function deliverWebhook(url, body, headers, opts) {
49
49
  else callback(null, pinned, family);
50
50
  } : void 0;
51
51
  const payload = Buffer.from(body, "utf8");
52
- const requestFn = isHttps ? request : request$1;
52
+ const requestFn = isHttps ? request$1 : request;
53
53
  return new Promise((resolve) => {
54
54
  let settled = false;
55
55
  const done = (r) => {
@@ -0,0 +1,98 @@
1
+ import { RoleHierarchyWalker } from "../src/RoleHierarchyWalker.js";
2
+ import { DEFAULT_SYSTEM_RULES, buildFrameAuthorizer } from "../src/realtime/frameAuthorizer.js";
3
+ // nodefony/testing/index.ts
4
+ /**
5
+ * Une identité de connexion AUTHENTIFIÉE, telle que le pare-feu l'aurait posée
6
+ * au handshake.
7
+ *
8
+ * Pour éprouver un visiteur anonyme, ne rien passer au harnais : il pose alors
9
+ * le jeton anonyme, comme le repli Zero Trust du runtime.
10
+ *
11
+ * @param options - identifiant, rôles, scopes, utilisateur.
12
+ * @returns un jeton à passer en `identity` au harnais temps réel.
13
+ */
14
+ function createTestIdentity(options = {}) {
15
+ const identifier = options.identifier ?? "alice";
16
+ const roles = options.roles ?? ["ROLE_USER"];
17
+ return new TestToken(identifier, options.id ?? identifier, roles, options.scopes ?? [], options.user ?? {
18
+ identifier,
19
+ roles: [...roles]
20
+ });
21
+ }
22
+ /** Le jeton que rend {@link createTestIdentity}. */
23
+ var TestToken = class {
24
+ identifier;
25
+ id;
26
+ roles;
27
+ scopes;
28
+ user;
29
+ type = "session";
30
+ #identity = null;
31
+ constructor(identifier, id, roles, scopes, user) {
32
+ this.identifier = identifier;
33
+ this.id = id;
34
+ this.roles = roles;
35
+ this.scopes = scopes;
36
+ this.user = user;
37
+ }
38
+ /**
39
+ * Identité d'admission gelée — ce que reçoit la règle `allow` d'un canal.
40
+ *
41
+ * Même forme que celle du jeton du pare-feu (`UserRealtimeToken.getIdentity`) :
42
+ * sans elle, tout canal à `allow` refusait la connexion d'essai, et une
43
+ * application ne pouvait éprouver un salon privé qu'en écrivant son propre jeton.
44
+ */
45
+ getIdentity() {
46
+ if (this.#identity !== null) return this.#identity;
47
+ this.#identity = Object.freeze({
48
+ type: this.type,
49
+ authenticated: true,
50
+ id: this.id,
51
+ userIdentifier: this.identifier,
52
+ roles: Object.freeze([...this.roles]),
53
+ scopes: Object.freeze([...this.scopes]),
54
+ issuer: null,
55
+ account: true
56
+ });
57
+ return this.#identity;
58
+ }
59
+ getUserIdentifier() {
60
+ return this.identifier;
61
+ }
62
+ isAuthenticated() {
63
+ return true;
64
+ }
65
+ getRoles() {
66
+ return [...this.roles];
67
+ }
68
+ getScopes() {
69
+ return [...this.scopes];
70
+ }
71
+ getAttribute(key) {
72
+ return key === "user" ? this.user : void 0;
73
+ }
74
+ };
75
+ /**
76
+ * Le VRAI verrou de frame du pare-feu, pour le harnais temps réel.
77
+ *
78
+ * C'est lui qui applique `@RealtimeChannel(nom, { roles })`,
79
+ * `@RealtimeAction(nom, { roles })` et les canaux système : sans lui, une
80
+ * politique déclarée n'est appliquée par personne — en test comme au runtime.
81
+ *
82
+ * @param channels - ce qui connaît les politiques déclarées : le hub temps réel
83
+ * (`getRealtimeHub()`), lu à CHAQUE frame — les politiques que le controller
84
+ * enregistre à sa construction y sont donc vues.
85
+ * @param options - hiérarchie de rôles de l'application.
86
+ * @returns le verrou à passer en `frameAuthorizer` au harnais.
87
+ */
88
+ function createTestFrameAuthorizer(channels, options = {}) {
89
+ const walker = new RoleHierarchyWalker(options.roleHierarchy ?? {});
90
+ return buildFrameAuthorizer({
91
+ matchPath: () => null,
92
+ hasRole: (userRoles, required) => walker.hasRole(userRoles, required)
93
+ }, {
94
+ channelResolver: channels,
95
+ systemRules: DEFAULT_SYSTEM_RULES
96
+ });
97
+ }
98
+ export { createTestFrameAuthorizer, createTestIdentity };
@@ -168,6 +168,7 @@ export { WEBHOOK_SORTABLE_FIELDS, WEBHOOK_DEFAULT_ORDER, } from "./nodefony/src/
168
168
  export { registerWebhookStore, getWebhookStoreFactory, listWebhookStores, } from "./nodefony/src/webhook/webhookStoreRegistry.js";
169
169
  export type { WebhookStoreFactory, IWebhookStoreFactoryContext, } from "./nodefony/src/webhook/webhookStoreRegistry.js";
170
170
  export type { IWebhookEndpoint, WebhookEndpointUpdate, WebhookEndpointSummary, } from "./nodefony/contracts/IWebhookEndpoint.js";
171
+ export type { IWebhookPayload } from "./nodefony/contracts/IWebhookPayload.js";
171
172
  export type { IWebhookListQuery, IWebhookStore, } from "./nodefony/contracts/IWebhookStore.js";
172
173
  export { assertPublicUrl, isBlockedAddress, } from "./nodefony/src/net/ssrfGuard.js";
173
174
  export type { IAssertPublicUrlOptions, IPublicUrlResult, DnsResolver, } from "./nodefony/src/net/ssrfGuard.js";
@@ -85,7 +85,9 @@ export declare const securityConfigSchema: z.ZodObject<{
85
85
  hsts: z.ZodDefault<z.ZodBoolean>;
86
86
  hstsMaxAgeS: z.ZodDefault<z.ZodNumber>;
87
87
  csp: z.ZodDefault<z.ZodString>;
88
+ cspSources: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>>>;
88
89
  cspNonces: z.ZodDefault<z.ZodBoolean>;
90
+ cspReport: z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodString]>>;
89
91
  frameguard: z.ZodDefault<z.ZodEnum<{
90
92
  deny: "deny";
91
93
  sameorigin: "sameorigin";
@@ -16,7 +16,10 @@ export interface IApiKeyView {
16
16
  subjectId: string;
17
17
  /** Discriminant du porteur. */
18
18
  subjectType: "user" | "service";
19
- /** Organisation/tenant (`null` = global). */
19
+ /**
20
+ * Périmètre d'un locataire — réservé, toujours `null` tant que le multi-tenant
21
+ * n'existe pas (`createForSubject` refuse une valeur).
22
+ */
20
23
  tenantId: string | null;
21
24
  /** Création (epoch ms). */
22
25
  createdAt: number;
@@ -70,6 +73,9 @@ export interface ICreateApiKeyOptions {
70
73
  scopes?: string[];
71
74
  /** Durée de vie en jours ; `null` = sans expiration ; omis = défaut config. */
72
75
  expiresInDays?: number | null;
73
- /** Tenant/organisation (multi-tenant) ; `null` = global. */
76
+ /**
77
+ * Périmètre d'un locataire — réservé, **refusé s'il est rempli** (`ApiKeyError` 400)
78
+ * tant que le multi-tenant n'existe pas. Absent ou `null` : clé sans périmètre.
79
+ */
74
80
  tenantId?: string | null;
75
81
  }
@@ -28,13 +28,26 @@ export interface IFirewall extends IFirewallGate {
28
28
  handleCors(context: ContextType): number | undefined;
29
29
  /**
30
30
  * Pose les en-têtes de sécurité applicatifs (CSP, Referrer-Policy, COOP/COEP/
31
- * CORP, Permissions-Policy) sur la réponse. No-op si désactivés / hors HTTP.
31
+ * CORP, Permissions-Policy) sur la réponse, selon son type (ADR-0024 §C11).
32
+ * No-op si désactivés / hors HTTP.
33
+ *
34
+ * @param contentType - `Content-Type` de la réponse ; `null` = sans contenu ;
35
+ * omis = type inconnu (politique complète).
36
+ * @param servedAsFile - corps servi tel quel (fichier statique, relais), qui
37
+ * ne peut pas porter de nonce : un document y reçoit la politique sans
38
+ * nonce ni `'strict-dynamic'` (ADR-0024 §C1).
32
39
  */
33
- applySecurityHeaders(context: ContextType): void;
40
+ applySecurityHeaders(context: ContextType, contentType?: string | null, servedAsFile?: boolean): void;
34
41
  /**
35
42
  * Déclare des directives CSP additionnelles pour un module (ex. `@nodefony/frontend`
36
43
  * en dev : origines Vite + `'unsafe-eval'`). Fusionnées dans le CSP de base et
37
44
  * recomposées (re-split nonce) au (dé)enregistrement — jamais par requête.
45
+ *
46
+ * Ne lève jamais : une contribution MALFORMÉE est ignorée en entier (journal
47
+ * ERROR), une source TROP LARGE (`*`, `https:`, `https://*`) est retirée
48
+ * (journal WARNING). La politique ne devient jamais plus permissive que demandé,
49
+ * et l'application démarre. Ce que l'application écrit elle-même (sa config,
50
+ * ses `@Csp`) ne passe pas par ce filtre.
38
51
  */
39
52
  registerCspOrigins(moduleName: string, fragment: CspFragment): void;
40
53
  /** Retire les directives CSP d'un module (ex. `stopDev` du frontend). */
@@ -14,7 +14,7 @@
14
14
  * 3. **seuil par porteur** (`invalidBefore`) — révocation EN MASSE (tout access
15
15
  * auto-porté émis avant un instant T est rejeté → « déconnexion globale » / ban).
16
16
  */
17
- import type { IPage, IPageQuery, ISortableSource } from "nodefony";
17
+ import type { IPage, IPageQuery, ISortableSource, TokenStatus } from "nodefony";
18
18
  /** Raison de révocation d'un jeton — tracée pour l'audit de sécurité. */
19
19
  export type TokenRevokeReason = "logout" | "rotated" | "reuse_detected" | "manual" | "compromised" | "subject_disabled" | "expired_cleanup";
20
20
  /**
@@ -153,8 +153,12 @@ export interface ITokenListQuery extends IPageQuery {
153
153
  */
154
154
  status?: TokenStatus;
155
155
  }
156
- /** État de vie d'un jeton — partition exhaustive, évaluée révoqué → expiré → actif. */
157
- export type TokenStatus = "active" | "expired" | "revoked";
156
+ /**
157
+ * État de vie d'un jeton — partition exhaustive, évaluée révoqué → expiré → actif.
158
+ * Défini au cœur (le temps réel le lit aussi, sans dépendre de la sécurité) et
159
+ * réexporté ici sous le même nom.
160
+ */
161
+ export type { TokenStatus } from "nodefony";
158
162
  /**
159
163
  * Contrat du store de jetons — implémentations enregistrées via
160
164
  * `registerTokenStore`, sélectionnées par config. Toutes les opérations sont
@@ -15,15 +15,25 @@ export interface IWebhookEndpoint {
15
15
  /** Secret de signature **chiffré** au repos (blob `gcm1.…`). Jamais en clair. */
16
16
  readonly secretEnc: string;
17
17
  /**
18
- * Actions d'audit souscrites (ex. `"login.success"`, `"user.created"`).
19
- * `"*"` = toutes. La livraison ne part que si l'action de l'événement matche.
18
+ * Motifs des actions d'audit souscrites, dans la grammaire commune aux canaux et
19
+ * aux webhooks (ADR-0023 W1) : `*` vaut n'importe quelle suite de caractères —
20
+ * `"*"` désigne tout, `"login.*"` les actions qui commencent par `login.`,
21
+ * `"commande:*"` comme `"*:créée"` ; le reste est littéral, trois `*` au plus
22
+ * (refusé à l'enregistrement au-delà). La livraison ne part que si un motif désigne
23
+ * l'action. Sans `tenantId`, l'abonnement a le périmètre de la PLATEFORME : il reçoit
24
+ * aussi les événements qu'une version ultérieure rattachera à un locataire
25
+ * (ADR-0023 W2).
20
26
  */
21
27
  readonly events: readonly string[];
22
28
  /** Endpoint actif ? (désactivé = aucune livraison). */
23
29
  readonly enabled: boolean;
24
30
  /** Libellé humain optionnel (console admin). */
25
31
  readonly description: string | null;
26
- /** Slot multi-tenant (réservé P17) — `null` = global. */
32
+ /**
33
+ * Périmètre d'un locataire — réservé, toujours `null` tant que le multi-tenant
34
+ * n'existe pas (`register` refuse une valeur). Un abonnement sans périmètre ne
35
+ * reçoit que les événements sans périmètre (ADR-0023 W2).
36
+ */
27
37
  readonly tenantId: string | null;
28
38
  /** Identité de l'admin créateur (soft ref, traçabilité). */
29
39
  readonly createdBy: string | null;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Corps JSON d'une livraison webhook, tel que le destinataire le reçoit — le type à poser sur le `JSON.parse` du récepteur.
3
+ *
4
+ * Signé avec ses en-têtes (`webhook-id`, `webhook-timestamp`, `webhook-signature`,
5
+ * convention Standard Webhooks) : vérifier la signature AVANT de lire le corps.
6
+ *
7
+ * @typeParam T - la forme de `data` ; `unknown` tant que le récepteur ne l'a pas vérifiée.
8
+ */
9
+ export interface IWebhookPayload<T = unknown> {
10
+ /**
11
+ * Identifiant du MESSAGE, égal à l'en-tête `webhook-id` et stable d'un réessai à l'autre : la clé de déduplication du récepteur.
12
+ */
13
+ readonly id: string;
14
+ /**
15
+ * Heure de la TENTATIVE de livraison (ISO 8601), pas celle de l'événement : un réessai porte une heure plus tardive.
16
+ *
17
+ * L'heure de l'événement vit dans `data` (`ts` pour un événement d'audit).
18
+ */
19
+ readonly timestamp: string;
20
+ /** Nom de l'événement — l'action d'audit, ex. `"login.success"` ; c'est lui que filtrent les motifs d'abonnement. */
21
+ readonly type: string;
22
+ /** L'événement lui-même ; pour l'audit, un `IAuditEvent`. */
23
+ readonly data: T;
24
+ }
@@ -49,6 +49,9 @@ declare class ApiKeyService extends Service {
49
49
  * @param roles - rôles du porteur tels que son compte les porte ; omis =
50
50
  * aucun rôle, donc tout scope réservé est refusé (fail-closed).
51
51
  * @throws ApiKeyError 400 — nom vide/trop long, scope hors catalogue, expiry invalide.
52
+ * @throws ApiKeyError 400 — `tenantId` rempli : le périmètre d'un locataire n'est
53
+ * pas encore appliqué (aucun lecteur ne cloisonne sur lui) — l'accepter rendrait
54
+ * la clé trompeuse, et l'appliquer un jour lui retirerait des accès (rupture).
52
55
  * @throws ApiKeyError 403 — scope réservé à des rôles que le porteur n'a pas.
53
56
  * @throws ApiKeyError 409 — plafond `maxPerSubject` atteint.
54
57
  * @throws ApiKeyError 403 — aucun compte sous cet identifiant (la clé est liée
@@ -8,6 +8,12 @@ import type { IFirewall } from "../contracts/IFirewall.js";
8
8
  import type { IAuthenticator } from "../contracts/IAuthenticator.js";
9
9
  import type { ISecuredArea } from "../contracts/ISecuredArea.js";
10
10
  import type { IFirewallDescription, IRoleHierarchyDescription } from "../contracts/IFirewallDescription.js";
11
+ /**
12
+ * `Cache-Control` par défaut d'une réponse dont la CSP porte un nonce : jamais
13
+ * gardée par un cache partagé (le nonce y serait servi à plusieurs visiteurs),
14
+ * toujours redemandée avant réutilisation (ADR-0024 §C2).
15
+ */
16
+ export declare const NONCED_DOCUMENT_CACHE_CONTROL = "private, no-cache";
11
17
  /**
12
18
  * Orchestrateur de sécurité Nodefony — refonte 2026 (P6).
13
19
  *
@@ -27,7 +33,7 @@ import type { IFirewallDescription, IRoleHierarchyDescription } from "../contrac
27
33
  *
28
34
  * Conformité : tout 401 porte un challenge `WWW-Authenticate` (RFC 9110
29
35
  * §15.5.2) — celui du premier authenticator de la zone qui en déclare un, sinon
30
- * le défi de repli {@link FALLBACK_CHALLENGE}.
36
+ * le défi de repli {@link SESSION_AUTH_CHALLENGE}.
31
37
  *
32
38
  * CORS, CSRF et autorisation par décorateurs viennent se brancher en S4/S5.
33
39
  * Toutes les structures sont **lazy** (perf : une app sans zone = zéro alloc).
@@ -141,20 +147,35 @@ declare class Firewall extends Service implements IFirewall {
141
147
  /**
142
148
  * En-têtes de sécurité APPLICATIFS (P6 J5) — CSP, Referrer-Policy, isolation
143
149
  * cross-origin (COOP/COEP/CORP), Origin-Agent-Cluster, Permissions-Policy.
144
- * Posés sur toute réponse du pipeline (branché dans `handleHttp`). Complète le
145
- * socle transport de `@nodefony/http` (nosniff/frame/HSTS, posé à l'entrée brute)
146
- * SANS le ré-émettre. No-op si désactivé ou réponse non-HTTP (WS).
150
+ * Appelé par la réponse juste avant son `writeHead`, une fois son type fixé :
151
+ * la politique suit ce type (ADR-0024 §C11). Complète le socle transport de
152
+ * `@nodefony/http` (nosniff/frame/HSTS) SANS le ré-émettre. No-op si désactivé
153
+ * ou réponse non-HTTP (WS).
147
154
  *
148
155
  * En-têtes constants = table figée pré-calculée au boot (0 alloc/concat). Le CSP
149
- * nonce/req (étape B) ajoute 1 `join` + 1 `setHeader` UNIQUEMENT si activé.
156
+ * nonce/req ajoute 1 `join` + 1 `setHeader`, sur un document seulement.
157
+ *
158
+ * @param context - contexte de la réponse.
159
+ * @param contentType - `Content-Type` de la réponse ; `null` = sans contenu ;
160
+ * omis = type inconnu, servi comme un document (politique complète).
161
+ * @param servedAsFile - le corps est servi tel quel (fichier statique,
162
+ * relais) : aucun gabarit ne l'a écrit, il ne peut pas porter de nonce. Un
163
+ * document reçoit alors la politique sans nonce ni `'strict-dynamic'`
164
+ * (ADR-0024 §C1) ; les autres types n'en changent pas.
150
165
  */
151
- applySecurityHeaders(context: ContextType): void;
166
+ applySecurityHeaders(context: ContextType, contentType?: string | null, servedAsFile?: boolean): void;
152
167
  /**
153
168
  * Déclare des directives CSP additionnelles pour `moduleName` (cf `IFirewall`).
154
169
  * No-op si les en-têtes applicatifs sont désactivés (pas de CSP à étendre).
155
170
  * Recompose `#securityHeaders` (merge + re-split nonce) — hors hot-path.
156
171
  */
157
- registerCspOrigins(moduleName: string, fragment: CspFragment): void;
172
+ registerCspOrigins(moduleName: string, declared: CspFragment): void;
173
+ /**
174
+ * Le noyau HTTP a recalculé ses en-têtes de transport (édition à chaud de sa
175
+ * config) : la politique suit `frameOptions`, qui pilote aussi
176
+ * `frame-ancestors` (ADR-0024 §C5). Hors chemin chaud.
177
+ */
178
+ onTransportHeadersChanged(): void;
158
179
  /** Retire les directives CSP de `moduleName` et recompose si nécessaire. */
159
180
  unregisterCspOrigins(moduleName: string): void;
160
181
  log(pci: unknown, severity?: Severity, msgid?: Msgid, msg?: Message): Pdu;
@@ -1,10 +1,26 @@
1
1
  import { type CspFragment } from "../src/csp.js";
2
+ /**
3
+ * Famille d'une réponse au regard de la politique de contenu (ADR-0024 §C11) :
4
+ * `document` (HTML, XHTML, SVG — politique complète avec nonce et
5
+ * `Permissions-Policy`), `script` (JavaScript — politique complète SANS nonce,
6
+ * celle que prend un worker), `other` (tout le reste — politique courte figée).
7
+ */
8
+ export type ResponseKind = "document" | "script" | "other";
9
+ /**
10
+ * Range une réponse dans sa famille d'après son `Content-Type`.
11
+ *
12
+ * @param contentType - valeur de `Content-Type` (paramètres admis) ; `null` =
13
+ * réponse sans contenu ; `undefined` = type INCONNU de l'appelant, traité en
14
+ * document — la politique la plus complète, jamais une plus faible par défaut.
15
+ * @returns la famille dont la réponse reçoit la politique.
16
+ */
17
+ export declare function responseKindOf(contentType: string | null | undefined): ResponseKind;
2
18
  /**
3
19
  * Sous-ensemble APPLICATIF de la config `headers` (cf defineSecurityConfig).
4
20
  *
5
21
  * `hsts` / `frameguard` / `noSniff` n'y figurent PAS volontairement : ces trois
6
- * en-têtes « transport » sont posés par `@nodefony/http` à l'entrée brute
7
- * (`onHttpRequest`, AVANT le pipeline) pour couvrir AUSSI les fichiers statiques,
22
+ * en-têtes « transport » sont posés par `@nodefony/http` sur toute réponse
23
+ * (`HttpKernel.transportHeaders`, figés au démarrage) pour couvrir AUSSI les fichiers statiques,
8
24
  * les erreurs précoces et un serveur sans module security (secure-by-default).
9
25
  * `@nodefony/security` ne les ré-émet pas → une seule source par en-tête.
10
26
  */
@@ -18,6 +34,12 @@ export interface ISecurityHeadersOptions {
18
34
  corp?: string | undefined;
19
35
  originAgentCluster?: boolean | undefined;
20
36
  permissionsPolicy?: string | undefined;
37
+ /**
38
+ * Valeur de `Reporting-Endpoints` (`groupe="url"`), quand la politique
39
+ * désigne un groupe `report-to` composé par le framework (ADR-0024 §C7).
40
+ * Posé là où la politique complète part : document, script, fichier servi.
41
+ */
42
+ reportingEndpoints?: string | null | undefined;
21
43
  }
22
44
  /**
23
45
  * En-têtes de sécurité **applicatifs** de Nodefony (couche `@nodefony/security`,
@@ -26,19 +48,65 @@ export interface ISecurityHeadersOptions {
26
48
  * cross-origin COOP/COEP/CORP, Origin-Agent-Cluster, Permissions-Policy) → zéro
27
49
  * alloc, zéro concat par requête (le firewall la pose telle quelle).
28
50
  *
29
- * **Étape A+B** : en-têtes constants pré-calculés au boot (Referrer/COOP/…). Pour le
30
- * CSP, deux régimes mutuellement exclusifs : **statique** (posé tel quel via `headers`,
31
- * 0 alloc/req) OU **nonce par requête** (`cspNonces` + placeholder `{{nonce}}` dans le
32
- * CSP) → segments pré-split au boot, recomposés par requête via `cspFor(nonce)` (1
33
- * `join`, aucun parse/regex dans le hot-path). Le nonce lui-même vit sur `Context`.
51
+ * Pour le CSP, deux régimes mutuellement exclusifs : **statique** (posé tel quel via
52
+ * `headers`, 0 alloc/req) OU **nonce par requête** (`cspNonces` + placeholder
53
+ * `{{nonce}}`) → segments pré-split au boot, recomposés par requête via
54
+ * `cspFor(nonce)` (1 concaténation, aucun parse/regex dans le hot-path).
55
+ *
56
+ * **Deux variantes, composées au boot** (ADR-0024 §C6) : la politique telle
57
+ * qu'écrite, servie en TLS, et la même SANS `upgrade-insecure-requests`, servie
58
+ * en clair — sur une page `http`, la directive réécrirait les sous-ressources en
59
+ * `https`, que le serveur ne sert pas. Sans la directive, les deux variantes sont
60
+ * le MÊME objet : par requête, un booléen choisit, rien de plus.
34
61
  *
35
62
  * @see Fetch Metadata · W3C CSP Level 3 · WHATWG (COOP/COEP/CORP).
36
63
  */
37
64
  export declare class SecurityHeaders {
38
65
  #private;
39
- constructor(o: ISecurityHeadersOptions);
40
- /** Table d'en-têtes applicatifs CONSTANTS (figée), posée telle quelle par le firewall. */
66
+ /**
67
+ * @param o - options des en-têtes (config `security.headers`, CSP déjà fusionnée).
68
+ * @param onRejectedExtra - appelé quand les directives `@Csp` d'une route sortent
69
+ * de la grammaire CSP3 : la route sert alors la politique de base.
70
+ */
71
+ constructor(o: ISecurityHeadersOptions, onRejectedExtra?: (error: unknown) => void);
72
+ /**
73
+ * Table d'en-têtes figée d'un DOCUMENT servi tel quel — fichier statique,
74
+ * réponse relayée — dont le corps ne passe par aucun gabarit et ne peut donc
75
+ * pas porter de nonce (ADR-0024 §C1) : politique complète sans nonce ni
76
+ * `'strict-dynamic'` orphelin, et tous les en-têtes d'un document.
77
+ *
78
+ * @param tls - la page est servie en `https` (garde `upgrade-insecure-requests`).
79
+ * @returns la table à poser telle quelle.
80
+ */
81
+ headersForServedFile(tls: boolean): Readonly<Record<string, string>>;
82
+ /**
83
+ * Table d'en-têtes figée d'une réponse qui n'est pas un document (ADR-0024
84
+ * §C11) : JavaScript (politique complète sans nonce, Referrer-Policy, COEP,
85
+ * CORP) ou reste (politique courte `default-src 'none'`, Referrer-Policy,
86
+ * CORP). Ni l'une ni l'autre ne porte de nonce ; COOP, Origin-Agent-Cluster
87
+ * et Permissions-Policy, lus sur un document seulement, n'y figurent pas.
88
+ *
89
+ * @param kind - famille de la réponse (cf {@link responseKindOf}).
90
+ * @param tls - la page est servie en `https` (garde `upgrade-insecure-requests`).
91
+ * @returns la table à poser telle quelle.
92
+ */
93
+ headersForResource(kind: Exclude<ResponseKind, "document">, tls: boolean): Readonly<Record<string, string>>;
94
+ /** Table d'en-têtes applicatifs CONSTANTS (figée) d'une réponse en clair. */
41
95
  get headers(): Readonly<Record<string, string>>;
96
+ /**
97
+ * Table constante SANS la CSP statique d'une réponse en clair — pour une
98
+ * réponse qui pose sa propre politique (route `@Csp`), afin que la CSP ne
99
+ * soit écrite qu'une fois.
100
+ */
101
+ get headersWithoutCsp(): Readonly<Record<string, string>>;
102
+ /**
103
+ * Table d'en-têtes constants selon le transport de la réponse.
104
+ *
105
+ * @param tls - la page est servie en `https` (proxy de confiance compris).
106
+ * @param withoutCsp - omettre la CSP statique (route `@Csp` qui pose la sienne).
107
+ * @returns la table figée à poser telle quelle.
108
+ */
109
+ headersFor(tls: boolean, withoutCsp?: boolean): Readonly<Record<string, string>>;
42
110
  /** `true` si le CSP est recomposé par requête avec un nonce (→ `cspFor`). */
43
111
  get hasNonce(): boolean;
44
112
  /**
@@ -48,9 +116,11 @@ export declare class SecurityHeaders {
48
116
  * `'`/`;`/espace) → aucune évasion possible du token CSP.
49
117
  *
50
118
  * @param nonce - nonce base64 de la requête (`Context.cspNonce`).
119
+ * @param tls - la page est servie en `https` : la politique garde
120
+ * `upgrade-insecure-requests`. Défaut : en clair.
51
121
  * @returns la valeur `Content-Security-Policy` à poser sur la réponse.
52
122
  */
53
- cspFor(nonce: string): string;
123
+ cspFor(nonce: string, tls?: boolean): string;
54
124
  /**
55
125
  * Recompose le CSP en fusionnant ADDITIVEMENT les directives `extra` d'une
56
126
  * route (`@Csp`) dans le CSP de base, PUIS en substituant le `nonce`. Le merge
@@ -60,8 +130,9 @@ export declare class SecurityHeaders {
60
130
  *
61
131
  * @param nonce - nonce base64 de la requête (ignoré si le CSP n'a pas de nonce).
62
132
  * @param extra - directives additionnelles de la route (`directive → sources`).
133
+ * @param tls - la page est servie en `https`. Défaut : en clair.
63
134
  * @returns la valeur `Content-Security-Policy` à poser sur la réponse.
64
135
  */
65
- cspForExtra(nonce: string, extra: CspFragment): string;
136
+ cspForExtra(nonce: string, extra: CspFragment, tls?: boolean): string;
66
137
  }
67
138
  export default SecurityHeaders;