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

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 (51) hide show
  1. package/README.md +24 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +4 -2
  4. package/dist/nodefony/command/security-mcp-token.js +43 -5
  5. package/dist/nodefony/config/config.js +17 -6
  6. package/dist/nodefony/config/defineModuleConfig.js +52 -1
  7. package/dist/nodefony/errors/FetchPublicError.js +18 -0
  8. package/dist/nodefony/errors/SsrfError.js +9 -1
  9. package/dist/nodefony/errors/StaleSessionError.js +20 -0
  10. package/dist/nodefony/errors/index.js +3 -1
  11. package/dist/nodefony/keycloak/scaffold.js +31 -14
  12. package/dist/nodefony/service/accessTokenVerifier.js +1 -1
  13. package/dist/nodefony/service/auditService.js +48 -3
  14. package/dist/nodefony/service/authFlow.js +6 -3
  15. package/dist/nodefony/service/firewall.js +48 -3
  16. package/dist/nodefony/service/tokenService.js +8 -3
  17. package/dist/nodefony/service/totp.js +7 -2
  18. package/dist/nodefony/service/webAuthn.js +45 -6
  19. package/dist/nodefony/src/audit/AuditRefusalAggregator.js +139 -0
  20. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +24 -4
  21. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +21 -8
  22. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +1 -1
  23. package/dist/nodefony/src/net/fetchPublic.js +244 -0
  24. package/dist/nodefony/src/net/ssrfGuard.js +2 -2
  25. package/dist/nodefony/src/realtime/frameAuthorizer.js +130 -39
  26. package/dist/nodefony/src/token/jwtRuntime.js +22 -2
  27. package/dist/nodefony/src/webhook/webhookDelivery.js +37 -86
  28. package/dist/types/index.d.ts +4 -1
  29. package/dist/types/nodefony/command/security-mcp-token.d.ts +12 -0
  30. package/dist/types/nodefony/config/config.d.ts +6 -0
  31. package/dist/types/nodefony/errors/FetchPublicError.d.ts +15 -0
  32. package/dist/types/nodefony/errors/SsrfError.d.ts +8 -1
  33. package/dist/types/nodefony/errors/StaleSessionError.d.ts +17 -0
  34. package/dist/types/nodefony/errors/index.d.ts +3 -0
  35. package/dist/types/nodefony/keycloak/scaffold.d.ts +12 -6
  36. package/dist/types/nodefony/service/authFlow.d.ts +2 -2
  37. package/dist/types/nodefony/service/webAuthn.d.ts +13 -0
  38. package/dist/types/nodefony/src/audit/AuditRefusalAggregator.d.ts +55 -0
  39. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +17 -3
  40. package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +11 -4
  41. package/dist/types/nodefony/src/net/fetchPublic.d.ts +73 -0
  42. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +10 -2
  43. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +19 -3
  44. package/dist/types/nodefony/src/token/jwtRuntime.d.ts +21 -2
  45. package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +5 -2
  46. package/docs/audit.md +112 -28
  47. package/docs/keycloak.md +4 -4
  48. package/docs/obtenir-un-jeton.md +17 -10
  49. package/docs/tokens.md +1 -1
  50. package/docs/webhooks.md +15 -7
  51. package/package.json +13 -13
@@ -19,6 +19,18 @@ export declare function ttlSeconds(raw: string | undefined): number | undefined
19
19
  * @returns les secondes, `undefined` pour le défaut de configuration, une `Error` sinon
20
20
  */
21
21
  export declare function tokenTtlSeconds(raw: string | undefined, write: boolean): number | undefined | Error;
22
+ /**
23
+ * Le jeton vise-t-il une AUTRE porte que celle de l'outillage ?
24
+ *
25
+ * `NF_MCP_TOKEN` et `--write` servent la porte par défaut, et elle seule : un
26
+ * jeton d'une autre audience (RFC 8707) y prendrait sa place, et cette porte le
27
+ * refuserait — ses outils réservés disparaîtraient sans un mot.
28
+ *
29
+ * @param resource - la valeur de `--resource`, ou rien
30
+ * @param defaultResource - la porte par défaut de l'application
31
+ * @returns `true` quand `--resource` désigne une autre porte
32
+ */
33
+ export declare function targetsOtherGate(resource: string | undefined, defaultResource: string): boolean;
22
34
  /**
23
35
  * `nodefony security:mcp:token` — émet un jeton d'accès pour une porte de cette
24
36
  * application (la porte MCP par défaut).
@@ -340,6 +340,12 @@ export declare const securityConfigSchema: z.ZodObject<{
340
340
  immutable: z.ZodDefault<z.ZodBoolean>;
341
341
  stream: z.ZodDefault<z.ZodBoolean>;
342
342
  retentionDays: z.ZodDefault<z.ZodNumber>;
343
+ aggregate: z.ZodDefault<z.ZodObject<{
344
+ enabled: z.ZodDefault<z.ZodBoolean>;
345
+ windowMs: z.ZodDefault<z.ZodNumber>;
346
+ burst: z.ZodDefault<z.ZodNumber>;
347
+ maxKeys: z.ZodDefault<z.ZodNumber>;
348
+ }, z.core.$strict>>;
343
349
  }, z.core.$strict>>;
344
350
  studio: z.ZodDefault<z.ZodObject<{
345
351
  enabled: z.ZodDefault<z.ZodBoolean>;
@@ -0,0 +1,15 @@
1
+ import { nodefonyError } from "nodefony";
2
+ /** Cause d'un échec de {@link FetchPublicError}. */
3
+ export type FetchPublicFailure = "timeout" | "too-many-redirects" | "network";
4
+ /**
5
+ * Échec d'une requête sortante vers un site tiers (`fetchPublic`) qui n'est PAS
6
+ * un refus de cible : délai total dépassé (`504`), trop de redirections ou
7
+ * échec réseau (`502`). Une cible interdite lève `SsrfError` (422), pas
8
+ * celle-ci — l'appelant distingue ainsi « tu n'as pas le droit d'y aller » de
9
+ * « le site n'a pas répondu ».
10
+ */
11
+ export declare class FetchPublicError extends nodefonyError {
12
+ /** Cause de l'échec, stable et exploitable sans lire le message. */
13
+ readonly reason: FetchPublicFailure;
14
+ constructor(reason: FetchPublicFailure, message: string);
15
+ }
@@ -8,6 +8,13 @@ import { nodefonyError } from "nodefony";
8
8
  * d'un webhook invalide).
9
9
  */
10
10
  export declare class SsrfError extends nodefonyError {
11
- constructor(message?: string | Error);
11
+ /**
12
+ * `forbidden` : la cible est interdite (protocole, identifiants, IP non
13
+ * publique). `unresolvable` : l'hôte n'a aucune adresse — rien d'interne n'est
14
+ * visé, le site est simplement injoignable ; un appelant qui rend ces deux cas
15
+ * à l'utilisateur les distingue par ce champ, jamais par le texte du message.
16
+ */
17
+ readonly reason: "forbidden" | "unresolvable";
18
+ constructor(message?: string | Error, reason?: "forbidden" | "unresolvable");
12
19
  }
13
20
  export default SsrfError;
@@ -0,0 +1,17 @@
1
+ import { AuthenticationError } from "./AuthenticationError.js";
2
+ /**
3
+ * Session PÉRIMÉE — elle ne désigne plus personne de valide : compte supprimé,
4
+ * verrouillé, désactivé, recréé sous le même identifiant, mot de passe changé
5
+ * depuis son ouverture, ou blob vidé. C'est un ÉTAT à purger, pas une preuve
6
+ * fausse présentée par un tiers : le `SessionAuthenticator` la détruit côté
7
+ * serveur ET côté navigateur (cookie expiré, RFC 6265 §5.3), puis le firewall
8
+ * poursuit comme si aucune session n'avait été présentée — une page ouverte
9
+ * aux anonymes s'affiche, une zone fermée redemande la connexion.
10
+ *
11
+ * Jamais levée pour une PANNE de la source d'identité : une base injoignable
12
+ * ne dit rien de la session, qui doit survivre à l'incident.
13
+ */
14
+ export declare class StaleSessionError extends AuthenticationError {
15
+ constructor(message?: string | Error);
16
+ }
17
+ export default StaleSessionError;
@@ -1,9 +1,12 @@
1
1
  export { AuthenticationError } from "./AuthenticationError.js";
2
+ export { StaleSessionError } from "./StaleSessionError.js";
2
3
  export { AccessDeniedError } from "./AccessDeniedError.js";
3
4
  export { ThrottledError } from "./ThrottledError.js";
4
5
  export { UnverifiableTokenError } from "./UnverifiableTokenError.js";
5
6
  export { InvalidTargetError } from "./InvalidTargetError.js";
6
7
  export { CsrfError } from "./CsrfError.js";
7
8
  export { SsrfError } from "./SsrfError.js";
9
+ export { FetchPublicError } from "./FetchPublicError.js";
10
+ export type { FetchPublicFailure } from "./FetchPublicError.js";
8
11
  export { WebAuthnError } from "./WebAuthnError.js";
9
12
  export { InsecureTransportError } from "./InsecureTransportError.js";
@@ -7,13 +7,13 @@ export declare const APP_KEYCLOAK_REALM_FILE = "docker/keycloak/import/realm.jso
7
7
  export declare const APP_KEYCLOAK_THEMES_DIR = "docker/keycloak/themes";
8
8
  export declare const APP_KEYCLOAK_THEME_DIR = "docker/keycloak/themes/nodefony";
9
9
  /**
10
- * Ressource de l'API partenaire d'une application neuve en développement :
11
- * ce que rend `partnerResource()` du gabarit `complete` sans `NF_JWT_ISSUER`
12
- * ni `NF_PORT_HTTPS` — le port HTTPS par défaut, comme les URL de retour du
13
- * realm. Deux paquets, une valeur : le test de contribution compare le
14
- * gabarit rendu à cette constante.
10
+ * Ressource de l'API partenaire de la démo en développement : le chemin de sa
11
+ * zone (`/demo/api/partner`) rendu absolu contre l'émetteur de développement
12
+ * — le port HTTPS par défaut, comme les URL de retour du realm. Deux paquets,
13
+ * une valeur : le test de contribution la confronte à une application
14
+ * `--demo` réellement rendue.
15
15
  */
16
- export declare const APP_PARTNER_DEV_RESOURCE = "https://localhost:5152/api/partner";
16
+ export declare const APP_PARTNER_DEV_RESOURCE = "https://localhost:5152/demo/api/partner";
17
17
  /** Les types de thème que le realm sait déclarer, et la clé qui les porte. */
18
18
  declare const THEME_TYPES: readonly ["login", "account", "email", "admin"];
19
19
  type TThemeType = (typeof THEME_TYPES)[number];
@@ -68,6 +68,12 @@ export declare function devMachineSecret(appName: string): string;
68
68
  * rôle client `admin` (`roleMapping: { admin: "ROLE_ADMIN" }`), et les
69
69
  * audiences des zones `external-jwt` que la configuration rendue déclare.
70
70
  *
71
+ * Le client « machine » (`<app>-machine`, `client_credentials`) n'existe que
72
+ * s'il a une audience à recevoir : une application neuve n'ouvre aucune zone à
73
+ * jeton tiers (#746), et un client que rien n'accepte serait un secret de plus
74
+ * à garder pour rien. La démo, qui apporte l'API entre programmes, le fait
75
+ * naître en déclarant sa ressource.
76
+ *
71
77
  * @param appName - nom de l'application (= realm = identifiant de client)
72
78
  * @param themes - types de thème à déclarer
73
79
  * @param ids - `sub` des comptes de démonstration, FIXÉS dans le fichier :
@@ -132,8 +132,8 @@ declare class AuthFlow extends Service {
132
132
  * (mêmes contrôles que le `SessionAuthenticator` — source unique).
133
133
  *
134
134
  * @returns la projection publique, ou `null` (pas de session, session
135
- * orpheline, compte verrouillé/désactivé) — le handler répond `200`
136
- * `{ user: null }`.
135
+ * orpheline, compte verrouillé/désactivé — la session périmée est alors
136
+ * détruite, cookie compris) — le handler répond `200` `{ user: null }`.
137
137
  */
138
138
  me(context: ContextType): Promise<ISafeUser | null>;
139
139
  /**
@@ -143,3 +143,16 @@ declare class WebAuthnService extends Service {
143
143
  }
144
144
  export default WebAuthnService;
145
145
  export { WebAuthnService };
146
+ /**
147
+ * Origines WebAuthn rendues en ASCII (punycode), forme sous laquelle le
148
+ * navigateur les présente — `https://café.example` → `https://xn--caf-dma.example`.
149
+ *
150
+ * Une entrée qui n'est pas une origine (`scheme://hôte[:port]`) est écartée et
151
+ * signalée : la garder ferait échouer CHAQUE cérémonie, sans que personne ne
152
+ * sache pourquoi.
153
+ *
154
+ * @param origins - origines telles qu'écrites dans la config.
155
+ * @param onInvalid - appelé pour chaque entrée écartée.
156
+ * @returns les origines valides, sérialisées en ASCII, dans l'ordre reçu.
157
+ */
158
+ export declare function asciiOrigins(origins: readonly string[], onInvalid: (origin: string) => void): string[];
@@ -0,0 +1,55 @@
1
+ import type { IAuditEventDraft } from "../../contracts/IAuditEvent.js";
2
+ /** Réglages de l'agrégation des refus (section `security.audit.aggregate`). */
3
+ export interface IAuditAggregateOptions {
4
+ /** Durée d'une fenêtre, en ms, ouverte au premier refus d'une clé. */
5
+ windowMs: number;
6
+ /** Refus d'une clé écrits tels quels par fenêtre ; les suivants sont comptés. */
7
+ burst: number;
8
+ /** Clés suivies au plus ; au-delà, les refus se regroupent sans l'IP. */
9
+ maxKeys: number;
10
+ }
11
+ /**
12
+ * Agrège les REFUS du journal d'audit par source, pour qu'un flot ne se paie pas
13
+ * d'une ligne par requête (OWASP Logging Cheat Sheet : la journalisation ne doit
14
+ * pas épuiser les ressources ni le journal de la base).
15
+ *
16
+ * Clé : catégorie, action, motif, ressource et IP — l'IP résolue par le contexte
17
+ * (`trustProxy`), donc non forgeable par le client. L'acteur n'entre PAS dans la
18
+ * clé : un bourrage d'identifiants change d'acteur à chaque essai, c'est sa
19
+ * source qui le trahit.
20
+ *
21
+ * Par clé et par fenêtre (ouverte au premier refus) : les `burst` premiers refus
22
+ * sont écrits tels quels — l'enquêteur voit les premiers essais, acteur compris ;
23
+ * les suivants sont seulement comptés, et la fenêtre close émet UNE ligne de
24
+ * synthèse (`metadata.aggregated`). Mémoire bornée : au-delà de `maxKeys` clés,
25
+ * les refus se regroupent sous l'IP `*`.
26
+ *
27
+ * Pur : l'horloge est passée à chaque appel, la synthèse part par `emit`. Les
28
+ * succès n'y passent jamais (l'appelant les écarte).
29
+ */
30
+ export declare class AuditRefusalAggregator {
31
+ #private;
32
+ /**
33
+ * @param options - fenêtre, rafale écrite telle quelle, borne de clés.
34
+ * @param emit - écrit une ligne de synthèse dans le journal.
35
+ */
36
+ constructor(options: IAuditAggregateOptions, emit: (draft: IAuditEventDraft) => void);
37
+ /** Nombre de clés suivies (fenêtres ouvertes). */
38
+ get size(): number;
39
+ /**
40
+ * Compte un refus.
41
+ *
42
+ * @param draft - l'événement de refus.
43
+ * @param now - horloge, en ms.
44
+ * @returns `true` : l'écrire tel quel ; `false` : il est compté dans la synthèse.
45
+ */
46
+ admit(draft: IAuditEventDraft, now: number): boolean;
47
+ /**
48
+ * Ferme les fenêtres échues : émet leur synthèse et libère leurs clés.
49
+ *
50
+ * @param now - horloge, en ms.
51
+ */
52
+ sweep(now: number): void;
53
+ /** Émet la synthèse de toutes les fenêtres ouvertes (arrêt) et vide la table. */
54
+ flush(): void;
55
+ }
@@ -3,7 +3,7 @@ import type { ContextType } from "@nodefony/http";
3
3
  import type { IUser } from "@nodefony/user";
4
4
  import type { IAuthenticator } from "../../contracts/IAuthenticator.js";
5
5
  import type { IToken } from "../../contracts/IToken.js";
6
- import type { IJwtRuntime } from "../token/jwtRuntime.js";
6
+ import { type IJwtRuntime } from "../token/jwtRuntime.js";
7
7
  /**
8
8
  * Authentification par **JWT Bearer** (RFC 6750) — réservée API service↔service /
9
9
  * agents (le web utilise la session BFF). Vérifie un access token EdDSA signé par
@@ -15,7 +15,8 @@ import type { IJwtRuntime } from "../token/jwtRuntime.js";
15
15
  * - **clé par `kid` depuis le keyset LOCAL** (`createLocalJWKSet`) — jamais
16
16
  * `jku`/`jwk` de l'en-tête (injection de clé / SSRF, §3.5).
17
17
  * - **`aud` (§3.9) + `iss` (§3.8) obligatoires** + `typ:"at+jwt"` (§3.11, sépare
18
- * access et refresh) + `exp`/`nbf` (jose).
18
+ * access et refresh) + `exp`/`nbf` (jose). L'audience est celle de la ZONE
19
+ * (`resource`, ou l'audience par défaut), jamais toute la liste `jwt.audiences`.
19
20
  * - **révocation** : denylist `jti` + seuil `invalidBefore` par porteur (le JWT
20
21
  * est auto-porté et non révocable sans état serveur).
21
22
  * - **sujet revérifié** (§3.10) : `loadUserByIdentifier(sub)` → compte disparu,
@@ -48,12 +49,25 @@ export declare class JwtAuthenticator implements IAuthenticator {
48
49
  * qu'un credential qui disparaîtrait sans laisser de trace.
49
50
  */
50
51
  supports(context: ContextType): boolean;
51
- /** Extrait le token brut (non vérifié) → porté par un `UserToken` type `"jwt"`. */
52
+ /**
53
+ * Extrait le token brut (non vérifié) → porté par un `UserToken` type `"jwt"`,
54
+ * avec la ressource de la zone quand elle en déclare une.
55
+ *
56
+ * L'audience transite par le token parce que `authenticate()` ne reçoit pas
57
+ * le contexte (même patron que `ExternalJwtAuthenticator`). Rien n'est posé
58
+ * pour une zone sans ressource : l'audience par défaut s'y applique.
59
+ */
52
60
  createToken(context: ContextType): Promise<IToken>;
53
61
  /**
54
62
  * Vérifie la signature + les claims du JWT, applique la révocation et résout le
55
63
  * sujet — ou lève un 401 au message uniforme.
56
64
  *
65
+ * L'audience exigée est celle de la ZONE, jamais la liste blanche d'émission
66
+ * entière (RFC 8707, RFC 9068 §4) : la `resource` de la zone si elle en
67
+ * déclare une, sinon l'audience par défaut — l'application elle-même. Un
68
+ * jeton émis pour la porte MCP, ou pour la ressource d'une autre zone, figure
69
+ * dans `jwt.audiences` sans pour autant ouvrir celle-ci.
70
+ *
57
71
  * @throws AuthenticationError (401) — token absent/invalide/expiré/révoqué, ou
58
72
  * sujet disparu/banni.
59
73
  * @throws Error (câblage : keystore/store/users absents) — logguée ERROR par le
@@ -57,8 +57,10 @@ export declare class SessionAuthenticator implements IAuthenticator {
57
57
  * token. Les contrôles d'état (existe, actif, non verrouillé) vivent dans
58
58
  * {@link resolveSessionIdentity} — partagés avec `AuthFlow.me()`.
59
59
  *
60
- * @throws AuthenticationError (401, message uniforme) — session orpheline,
61
- * compte verrouillé ou désactivé.
60
+ * @throws StaleSessionError (401, message uniforme) — session orpheline,
61
+ * compte verrouillé ou désactivé, liaison au compte rompue : un état à
62
+ * purger ({@link onFailure}). Une PANNE de la source d'identité remonte
63
+ * telle quelle, et ne purge rien.
62
64
  */
63
65
  authenticate(token: IToken): Promise<IToken>;
64
66
  /**
@@ -66,7 +68,12 @@ export declare class SessionAuthenticator implements IAuthenticator {
66
68
  * (`saveSession`) lie le blob au principal courant (string attendu).
67
69
  */
68
70
  onSuccess(context: ContextType, token: IToken): Promise<void>;
69
- /** Slot audit (P6.14). Le 401 est posé par le firewall. */
70
- onFailure(_context: ContextType, _error: Error): Promise<void>;
71
+ /**
72
+ * Une session périmée est DÉTRUITE, côté serveur (entrée du stockage) et
73
+ * côté navigateur (cookie expiré, RFC 6265 §5.3) — sans quoi le navigateur la
74
+ * renvoie à chaque requête et chaque page lui répond 401, sans issue pour
75
+ * l'utilisateur. Le firewall poursuit ensuite la chaîne de la zone.
76
+ */
77
+ onFailure(context: ContextType, error: Error): Promise<void>;
71
78
  }
72
79
  export default SessionAuthenticator;
@@ -0,0 +1,73 @@
1
+ import { type IncomingHttpHeaders } from "node:http";
2
+ import { type DnsResolver } from "./ssrfGuard.js";
3
+ /**
4
+ * Requête sortante vers un site TIERS, sûre par construction — la primitive que
5
+ * tout code de Nodefony (webhooks, aperçu de lien, agents, RAG) appelle quand
6
+ * l'adresse vient d'un humain.
7
+ *
8
+ * Guide pour un agent : n'écris jamais `fetch(urlUtilisateur)`. Appelle
9
+ * {@link fetchPublic} ; la règle de cible vit dans `assertPublicUrl`
10
+ * (`ssrfGuard.ts`) et n'est jamais recopiée ici.
11
+ */
12
+ /** Options de {@link fetchPublic}. */
13
+ export interface IFetchPublicOptions {
14
+ /** Méthode HTTP. Défaut : `GET` (`POST` si un corps est fourni). */
15
+ readonly method?: string;
16
+ /** En-têtes ajoutés. Ni `Cookie` ni `Authorization` ne sont posés d'office. */
17
+ readonly headers?: Readonly<Record<string, string>>;
18
+ /** Corps de la requête (texte UTF-8 ou octets). */
19
+ readonly body?: string | Uint8Array;
20
+ /** Redirections suivies au plus. Défaut : `0` (un 3xx est rendu tel quel). */
21
+ readonly maxRedirects?: number;
22
+ /** Octets du corps de réponse lus au plus. Défaut : 1 Mio. */
23
+ readonly maxBytes?: number;
24
+ /** Délai TOTAL (résolution, connexions, redirections, lecture), en ms. Défaut : 10 000. */
25
+ readonly timeoutMs?: number;
26
+ /** Autorise `http:` en plus de `https:`. Défaut : `false`. */
27
+ readonly allowHttp?: boolean;
28
+ /** Autorise les cibles non publiques (dev/test seulement). Défaut : `false`. */
29
+ readonly allowPrivate?: boolean;
30
+ /** Résolveur DNS injectable (tests). Défaut : `node:dns`. */
31
+ readonly resolver?: DnsResolver;
32
+ /**
33
+ * IP DÉJÀ vérifiées par `assertPublicUrl` pour l'adresse de départ : la
34
+ * connexion y est épinglée sans nouvelle résolution. Pour un appelant qui a
35
+ * contrôlé l'adresse plus tôt (webhooks) ; les redirections, elles, sont
36
+ * toujours revérifiées.
37
+ */
38
+ readonly vettedAddresses?: readonly string[];
39
+ }
40
+ /** Réponse de {@link fetchPublic}. */
41
+ export interface IFetchPublicResult {
42
+ /** Code de statut HTTP de la réponse finale. */
43
+ readonly status: number;
44
+ /** En-têtes de la réponse finale. */
45
+ readonly headers: IncomingHttpHeaders;
46
+ /** Corps reçu, NON décodé (`Content-Encoding` n'est jamais appliqué). */
47
+ readonly body: Uint8Array;
48
+ /** `true` si `maxBytes` a coupé la lecture (la connexion a alors été détruite). */
49
+ readonly truncated: boolean;
50
+ /** Adresse de la réponse finale (après redirections). */
51
+ readonly url: URL;
52
+ /** Nombre de redirections suivies. */
53
+ readonly redirects: number;
54
+ }
55
+ /**
56
+ * Lit une URL fournie par un humain sans jamais atteindre le réseau interne.
57
+ *
58
+ * À CHAQUE saut (adresse de départ comme redirections) : protocole, absence
59
+ * d'identifiants et IP sont jugés par `assertPublicUrl`, puis la connexion est
60
+ * ÉPINGLÉE sur l'IP vérifiée (aucune seconde résolution : un rebinding DNS ne
61
+ * mène nulle part). Le délai est TOTAL — un serveur qui goutte un octet par
62
+ * seconde est coupé — et la lecture s'arrête à `maxBytes` en détruisant la
63
+ * connexion. Aucun cookie, aucun `Authorization` n'est ajouté ni conservé à
64
+ * travers une redirection, et le corps n'est jamais décodé (pas de bombe de
65
+ * décompression).
66
+ *
67
+ * @param rawUrl - adresse à lire.
68
+ * @param options - méthode, corps, bornes et politique de cible.
69
+ * @returns la réponse finale, corps borné et non décodé.
70
+ * @throws SsrfError si la cible, ou une redirection, est interdite.
71
+ * @throws FetchPublicError si le délai total, le nombre de redirections ou le réseau échoue.
72
+ */
73
+ export declare function fetchPublic(rawUrl: string, options?: IFetchPublicOptions): Promise<IFetchPublicResult>;
@@ -1,4 +1,4 @@
1
- import type { FrameAuthorizer, IChannelPolicy, IRealtimeToken } from "./realtimeContracts.js";
1
+ import type { FrameAuthorizer, IChannelPolicies, IChannelPolicy, IRealtimeToken } from "./realtimeContracts.js";
2
2
  /**
3
3
  * Surface MINIMALE du firewall consommée par le verrou de frame : matcher une
4
4
  * zone par pathname ET vérifier un rôle (hiérarchie comprise). `Firewall` la
@@ -21,6 +21,12 @@ export interface IFrameAuthorizerFirewall {
21
21
  */
22
22
  export interface IChannelPolicyResolver {
23
23
  resolveChannelPolicy?(channel: string): IChannelPolicy | null;
24
+ /**
25
+ * Lecture ET écriture distincte d'un nom, en une résolution (ADR-0022 §6,
26
+ * décision 1 quater). Préférée quand elle existe ; un hub d'une version
27
+ * antérieure ne l'expose pas, et ne porte alors aucune écriture distincte.
28
+ */
29
+ resolveChannelPolicies?(channel: string): IChannelPolicies | null;
24
30
  }
25
31
  /**
26
32
  * Règle de canal **système** (plateforme) — un préfixe de namespace réservé et
@@ -42,7 +48,9 @@ export interface ISystemChannelRule {
42
48
  * @param surface - cible refusée : pont socket (`api.request`) ou canal
43
49
  * (subscribe/inbound).
44
50
  * @param target - pathname (api.request) ou nom de canal refusé.
45
- * @param reason - raison machine stable (`zone_protected` | `channel_policy`).
51
+ * @param reason - raison machine stable (`zone_protected` | `channel_policy`
52
+ * | `channel_write_policy` — l'écriture distincte du canal a refusé, la
53
+ * lecture était satisfaite).
46
54
  * @param token - jeton WS de l'acteur (lu pour `getUserIdentifier()`).
47
55
  */
48
56
  export type FrameDenyReporter = (surface: "api.request" | "channel", target: string, reason: string, token: IRealtimeToken) => void;
@@ -1,4 +1,4 @@
1
- import type { IChannelPolicy, ISubscriberIdentity, TokenStatus } from "nodefony";
1
+ import type { IChannelPolicies, IChannelPolicy, ISubscriberIdentity, TokenStatus } from "nodefony";
2
2
  /**
3
3
  * Miroirs STRUCTURELS des contrats `@nodefony/realtime` — **zéro import** du
4
4
  * module realtime (ni runtime ni type-only).
@@ -89,8 +89,18 @@ export interface IRealtimeAuthenticatorMatcher {
89
89
  /**
90
90
  * Verrou de frame (miroir de `FrameAuthorizer`) — SYNC : `true` = frame
91
91
  * autorisée. Lit le token déjà résolu au handshake (0 lecture base par frame).
92
+ *
93
+ * `judgesWrite` : le verrou juge l'écriture distincte d'un canal (ADR-0022 §6,
94
+ * décision 1 quater) ; un hub refuse de démarrer face à un verrou qui ne le
95
+ * déclare pas dès qu'une telle écriture est inscrite.
96
+ *
97
+ * `explain` : la phrase qui dit pourquoi une trame a été refusée, ou `null` —
98
+ * lue par le hub après un refus seulement (chemin froid).
92
99
  */
93
- export type FrameAuthorizer = (frame: unknown, token: IRealtimeToken) => boolean;
100
+ export type FrameAuthorizer = ((frame: unknown, token: IRealtimeToken) => boolean) & {
101
+ readonly judgesWrite?: true;
102
+ readonly explain?: (frame: unknown, token: IRealtimeToken) => string | null;
103
+ };
94
104
  /**
95
105
  * Politique d'autorisation d'un canal — déclaration UNIQUE au cœur (`nodefony`), que le
96
106
  * hub realtime transporte et que ce module juge. Plus de miroir structurel : la
@@ -102,7 +112,7 @@ export type FrameAuthorizer = (frame: unknown, token: IRealtimeToken) => boolean
102
112
  * (`defineSecurityConfig().realtimeChannels` + namespaces système réservés —
103
113
  * cf {@link buildFrameAuthorizer}).
104
114
  */
105
- export type { IChannelPolicy };
115
+ export type { IChannelPolicy, IChannelPolicies };
106
116
  /**
107
117
  * Surface consommée du `realtimeService` (miroir partiel de `RealtimeService`) —
108
118
  * seulement les seams que security câble/lit au boot et au dispatch. Résolu par
@@ -131,6 +141,12 @@ export interface IRealtimeService {
131
141
  * hub d'une version antérieure ne l'expose pas → traité comme `undefined`.
132
142
  */
133
143
  resolveChannelPolicy?(channel: string): IChannelPolicy | null;
144
+ /**
145
+ * Seam #1b — lecture ET écriture distincte d'un nom, en une résolution
146
+ * (ADR-0022 §6, décision 1 quater). Optionnel : un hub d'une version
147
+ * antérieure ne l'expose pas, et ne porte alors aucune écriture distincte.
148
+ */
149
+ resolveChannelPolicies?(channel: string): IChannelPolicies | null;
134
150
  /**
135
151
  * Namespaces de canaux **réservés à la plateforme**, tels que le hub les connaît
136
152
  * (`syslog:`, `orm:`, `kernel:`…). Le firewall y accroche ses politiques au lieu
@@ -9,7 +9,11 @@ import type { ISecurityConfig } from "../../config/defineModuleConfig.js";
9
9
  export interface IJwtRuntime {
10
10
  /** Émetteur (`iss`). */
11
11
  readonly issuer: string;
12
- /** Audiences acceptées au verify ; la première sert d'`aud` à l'émission. */
12
+ /**
13
+ * Liste blanche des audiences ÉMETTABLES (RFC 8707) ; la première est
14
+ * l'audience par défaut. Ce n'est pas ce qu'une zone accepte : une zone `jwt`
15
+ * n'accepte que SA ressource, ou l'audience par défaut si elle n'en a pas.
16
+ */
13
17
  readonly audiences: string[];
14
18
  /** TTL access token (s). */
15
19
  readonly accessTtlS: number;
@@ -24,8 +28,23 @@ export interface IJwtRuntime {
24
28
  * Dérive les paramètres effectifs depuis la config sécurité. `issuer` omis →
25
29
  * `"nodefony"` (DEVRAIT être surchargé en prod) ; `audiences` vide → l'app est sa
26
30
  * propre audience (`[issuer]`).
31
+ *
32
+ * La ressource d'une ZONE (`areas.<zone>.resource`) entre aussi dans la liste,
33
+ * APRÈS les audiences déclarées : c'est une ressource que ce serveur protège, et
34
+ * la redéclarer dans `jwt.audiences` était une seconde écriture de la même
35
+ * valeur — celle qu'on oublie, et dont le seul symptôme est un `invalid_target`
36
+ * à l'émission. Ajoutée en fin de liste, elle ne change jamais l'audience par
37
+ * défaut ({@link defaultAudience}).
38
+ *
39
+ * Elle n'ouvre rien de plus à la VÉRIFICATION : chaque zone, `jwt` comme
40
+ * `external-jwt`, exige SA ressource (l'audience par défaut pour une zone `jwt`
41
+ * qui n'en déclare pas) — un jeton émis pour une zone n'en ouvre pas une autre.
42
+ *
43
+ * @param jwt - section `jwt` de la config validée
44
+ * @param areas - zones du pare-feu ; omises, seules les audiences déclarées comptent
45
+ * @returns les paramètres partagés par l'émetteur et les vérificateurs
27
46
  */
28
- export declare function resolveJwtRuntime(jwt: ISecurityConfig["jwt"]): IJwtRuntime;
47
+ export declare function resolveJwtRuntime(jwt: ISecurityConfig["jwt"], areas?: ISecurityConfig["areas"]): IJwtRuntime;
29
48
  /**
30
49
  * Audience inscrite dans un jeton quand aucune ressource n'est demandée : la
31
50
  * première de la liste blanche. La liste n'est jamais vide après
@@ -22,7 +22,10 @@ export interface IDeliveryOptions {
22
22
  readonly allowHttp?: boolean;
23
23
  }
24
24
  /**
25
- * POST le corps signé vers l'URL, en pinnant l'IP validée et sans suivre les
26
- * redirections. Ne lève jamais : tout échec est encodé dans {@link IDeliveryResult}.
25
+ * POST le corps signé vers l'URL, par {@link fetchPublic} : IP vérifiée épinglée,
26
+ * aucune redirection suivie, délai TOTAL (un destinataire qui goutte un octet
27
+ * ne retient pas la livraison) et lecture du corps coupée à
28
+ * {@link RESPONSE_BODY_CAP} (connexion détruite, pas drainée). Ne lève jamais :
29
+ * tout échec est encodé dans {@link IDeliveryResult}.
27
30
  */
28
31
  export declare function deliverWebhook(url: string, body: string, headers: Record<string, string>, opts: IDeliveryOptions): Promise<IDeliveryResult>;