@nodefony/framework 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.
- package/README.md +15 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorateParam.js +7 -0
- package/dist/index.js +14 -10
- package/dist/nodefony/config/config.js +16 -2
- package/dist/nodefony/controller/AdminApiController.js +19 -6
- package/dist/nodefony/controller/BenchController.js +1 -1
- package/dist/nodefony/controller/CspReportController.js +70 -0
- package/dist/nodefony/controller/IssuerMetadataController.js +1 -1
- package/dist/nodefony/controller/LoginPageController.js +5 -7
- package/dist/nodefony/controller/ProtectedResourceMetadataController.js +1 -1
- package/dist/nodefony/controller/SessionAuthController.js +6 -7
- package/dist/nodefony/controller/TokenAuthController.js +3 -3
- package/dist/nodefony/decorators/routerDecorators.js +2 -2
- package/dist/nodefony/service/AdminBroker.js +1 -1
- package/dist/nodefony/service/IdempotencyStore.js +1 -1
- package/dist/nodefony/service/router.js +5 -4
- package/dist/nodefony/src/Controller.js +321 -114
- package/dist/nodefony/src/PlaygroundAdminApi.js +1 -1
- package/dist/nodefony/src/Resolver.js +7 -10
- package/dist/nodefony/src/ResourceController.js +11 -3
- package/dist/nodefony/src/SecurityApiController.js +30 -7
- package/dist/nodefony/src/conditionalRequest.js +103 -0
- package/dist/nodefony/src/docsReader.js +1 -1
- package/dist/nodefony/src/idempotency.js +1 -1
- package/dist/nodefony/src/loginPage.js +2 -1
- package/dist/nodefony/src/scopeCatalog.js +1 -1
- package/dist/nodefony/src/syslogFilters.js +6 -0
- package/dist/nodefony/src/viewNonce.js +58 -0
- package/dist/types/index.d.ts +3 -2
- package/dist/types/nodefony/config/config.d.ts +20 -0
- package/dist/types/nodefony/controller/AdminApiController.d.ts +5 -3
- package/dist/types/nodefony/controller/CspReportController.d.ts +35 -0
- package/dist/types/nodefony/controller/LoginPageController.d.ts +3 -2
- package/dist/types/nodefony/controller/SessionAuthController.d.ts +5 -6
- package/dist/types/nodefony/controller/TokenAuthController.d.ts +3 -3
- package/dist/types/nodefony/interfaces/IController.d.ts +2 -2
- package/dist/types/nodefony/service/router.d.ts +3 -2
- package/dist/types/nodefony/src/Controller.d.ts +119 -13
- package/dist/types/nodefony/src/Resolver.d.ts +9 -1
- package/dist/types/nodefony/src/ResourceController.d.ts +11 -3
- package/dist/types/nodefony/src/SecurityApiController.d.ts +17 -4
- package/dist/types/nodefony/src/conditionalRequest.d.ts +82 -0
- package/dist/types/nodefony/src/loginPage.d.ts +1 -1
- package/dist/types/nodefony/src/syslogFilters.d.ts +6 -0
- package/dist/types/nodefony/src/viewNonce.d.ts +33 -0
- package/docs/admin.md +51 -2
- package/docs/controller.md +146 -27
- package/docs/decorateurs.md +8 -2
- package/docs/idempotence.md +22 -4
- package/docs/index.md +4 -2
- package/docs/routing.md +15 -1
- package/docs/templates.md +15 -0
- package/package.json +10 -9
|
@@ -3,7 +3,7 @@ import { assertPageQuery } from "nodefony";
|
|
|
3
3
|
import { HttpError } from "@nodefony/http";
|
|
4
4
|
// nodefony/src/ResourceController.ts
|
|
5
5
|
/**
|
|
6
|
-
* Controller de ressource **
|
|
6
|
+
* Controller de ressource **duplex** (V4.2 — une action, deux transports) :
|
|
7
7
|
* la logique métier est écrite UNE fois dans le service de ressource, les
|
|
8
8
|
* actions de la sous-classe ne sont que des portes (REST, WS-RPC `invoke`,
|
|
9
9
|
* GraphQL à venir) qui retournent la **valeur brute** — `returnController`
|
|
@@ -38,8 +38,12 @@ import { HttpError } from "@nodefony/http";
|
|
|
38
38
|
* return this.listResource();
|
|
39
39
|
* }
|
|
40
40
|
* \@route("books-get", { path: "/{id}", requirements: { methods: ["GET", "WEBSOCKET"] } })
|
|
41
|
-
* detail(\@Param("id") id: string) {
|
|
42
|
-
*
|
|
41
|
+
* async detail(\@Param("id") id: string) {
|
|
42
|
+
* // `null` rendu tel quel = « l'action répond elle-même » : la requête
|
|
43
|
+
* // pendrait jusqu'au délai d'expiration. La porte décide du 404.
|
|
44
|
+
* const found = await this.getResource(id);
|
|
45
|
+
* if (!found) throw new HttpError(`Book ${id} not found`, 404);
|
|
46
|
+
* return found;
|
|
43
47
|
* }
|
|
44
48
|
* }
|
|
45
49
|
* ```
|
|
@@ -117,6 +121,10 @@ var ResourceController = class extends Controller {
|
|
|
117
121
|
/**
|
|
118
122
|
* Lit une entité par id — `null` si absente (la porte décide du 404).
|
|
119
123
|
*
|
|
124
|
+
* ⚠️ Ne pas rendre ce `null` tel quel depuis une action : pour le Resolver,
|
|
125
|
+
* `null` veut dire « l'action répond elle-même », et la requête attend
|
|
126
|
+
* alors son délai d'expiration. Lever un `HttpError` 404.
|
|
127
|
+
*
|
|
120
128
|
* `options.relations` charge les associations dans la foulée. La porte doit
|
|
121
129
|
* n'y laisser passer que des relations qu'elle a DÉCLARÉES : un `include`
|
|
122
130
|
* libre laisse le client nommer n'importe quelle association, donc lire des
|
|
@@ -1,23 +1,46 @@
|
|
|
1
1
|
import Controller from "./Controller.js";
|
|
2
|
+
import { SESSION_AUTH_CHALLENGE } from "nodefony";
|
|
2
3
|
// nodefony/src/SecurityApiController.ts
|
|
3
4
|
const NO_STORE = { "cache-control": "no-store" };
|
|
4
5
|
/**
|
|
6
|
+
* En-têtes d'un 401 : ceux reçus, plus le défi par défaut
|
|
7
|
+
* ({@link SESSION_AUTH_CHALLENGE}) s'ils n'en portent aucun — RFC 9110 §15.5.2
|
|
8
|
+
* exige un `WWW-Authenticate` sur TOUT 401. Un défi posé par l'appelant (un
|
|
9
|
+
* `Bearer` de ressource protégée, par exemple) garde la main.
|
|
10
|
+
*
|
|
11
|
+
* Chemin froid : n'est appelé que pour un 401.
|
|
12
|
+
*
|
|
13
|
+
* @param headers - les en-têtes déjà prévus pour la réponse.
|
|
14
|
+
* @returns les mêmes en-têtes, défi compris.
|
|
15
|
+
*/
|
|
16
|
+
function withAuthChallenge(headers) {
|
|
17
|
+
if (headers !== void 0) {
|
|
18
|
+
for (const name in headers) if (name.toLowerCase() === "www-authenticate") return { ...headers };
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
...headers,
|
|
22
|
+
"www-authenticate": SESSION_AUTH_CHALLENGE
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
5
26
|
* Base des contrôleurs d'authentification (`/nodefony/security/api/*`) : toute
|
|
6
|
-
* réponse JSON part en `Cache-Control: no-store
|
|
27
|
+
* réponse JSON part en `Cache-Control: no-store`, et tout 401 porte un défi.
|
|
7
28
|
*
|
|
8
29
|
* Ces réponses portent une identité, un jeton, un secret affiché une seule
|
|
9
30
|
* fois (clé API, secret TOTP, codes de récupération) ou leur refus. Un cookie
|
|
10
31
|
* ou un en-tête dans la requête n'empêche pas un cache partagé de STOCKER la
|
|
11
|
-
* réponse (RFC 9111 §3) ; RFC 6749 §5.1 exige `no-store` sur un jeton.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
32
|
+
* réponse (RFC 9111 §3) ; RFC 6749 §5.1 exige `no-store` sur un jeton. Un 401
|
|
33
|
+
* reçoit le défi par défaut ({@link withAuthChallenge}, RFC 9110 §15.5.2). Ces
|
|
34
|
+
* règles vivent ici, une fois : une action ajoutée à un contrôleur héritier ne
|
|
35
|
+
* peut pas les oublier. Un en-tête passé par l'action garde la priorité.
|
|
14
36
|
*/
|
|
15
37
|
var SecurityApiController = class extends Controller {
|
|
16
38
|
renderJson(obj, status, headers) {
|
|
17
|
-
|
|
39
|
+
const merged = {
|
|
18
40
|
...NO_STORE,
|
|
19
41
|
...headers
|
|
20
|
-
}
|
|
42
|
+
};
|
|
43
|
+
return super.renderJson(obj, status, Number(status) === 401 ? withAuthChallenge(merged) : merged);
|
|
21
44
|
}
|
|
22
45
|
};
|
|
23
|
-
export { SecurityApiController as default };
|
|
46
|
+
export { SecurityApiController as default, withAuthChallenge };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// nodefony/src/conditionalRequest.ts
|
|
2
|
+
/** Une `entity-tag` (RFC 9110 §8.8.3) : `W/` facultatif, puis une chaîne entre guillemets. */
|
|
3
|
+
const ENTITY_TAG = /(?:W\/)?"[^"]*"/g;
|
|
4
|
+
/**
|
|
5
|
+
* Validateurs d'un fichier, tirés de ses métadonnées sans le lire.
|
|
6
|
+
*
|
|
7
|
+
* L'`ETag` est FORTE : taille et date de modification à la microseconde, deux
|
|
8
|
+
* grandeurs qu'une écriture change. C'est la forme que posent les serveurs de
|
|
9
|
+
* fichiers courants, et la seule qui autorise une reprise (§13.1.5).
|
|
10
|
+
*
|
|
11
|
+
* @param stats - taille et date de modification du fichier
|
|
12
|
+
* @param now - instant de la réponse (ms)
|
|
13
|
+
* @returns les validateurs du fichier
|
|
14
|
+
*/
|
|
15
|
+
function fileValidators(stats, now) {
|
|
16
|
+
const micros = Math.floor(stats.mtimeMs * 1e3);
|
|
17
|
+
const seconds = Math.floor(Math.min(stats.mtimeMs, now) / 1e3) * 1e3;
|
|
18
|
+
return {
|
|
19
|
+
etag: `"${stats.size.toString(16)}-${micros.toString(16)}"`,
|
|
20
|
+
lastModified: seconds,
|
|
21
|
+
lastModifiedStrong: stats.mtimeMs + 1e3 <= now
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Date HTTP (RFC 9110 §5.6.7) lue en ms, ou `null` si la valeur n'en est pas
|
|
26
|
+
* une — une condition sur une date invalide DOIT être ignorée (§13.1.3, §13.1.4).
|
|
27
|
+
*
|
|
28
|
+
* @param value - valeur brute de l'en-tête
|
|
29
|
+
*/
|
|
30
|
+
function parseHttpDate(value) {
|
|
31
|
+
if (value === void 0) return null;
|
|
32
|
+
const ms = Date.parse(value);
|
|
33
|
+
return Number.isNaN(ms) ? null : ms;
|
|
34
|
+
}
|
|
35
|
+
/** Les `entity-tag` d'une liste, ou `"*"`. */
|
|
36
|
+
function entityTags(value) {
|
|
37
|
+
if (value.trim() === "*") return "*";
|
|
38
|
+
return value.match(ENTITY_TAG) ?? [];
|
|
39
|
+
}
|
|
40
|
+
/** Partie opaque d'une `entity-tag` (sans `W/`). */
|
|
41
|
+
function opaque(tag) {
|
|
42
|
+
return tag.startsWith("W/") ? tag.slice(2) : tag;
|
|
43
|
+
}
|
|
44
|
+
/** Comparaison FORTE (§8.8.3.2) : aucune des deux n'est faible, et elles sont identiques. */
|
|
45
|
+
function strongMatch(a, b) {
|
|
46
|
+
return !a.startsWith("W/") && !b.startsWith("W/") && a === b;
|
|
47
|
+
}
|
|
48
|
+
/** Comparaison FAIBLE (§8.8.3.2) : parties opaques identiques. */
|
|
49
|
+
function weakMatch(a, b) {
|
|
50
|
+
return opaque(a) === opaque(b);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Évalue les préconditions d'une requête sur une représentation qui EXISTE,
|
|
54
|
+
* dans l'ordre de RFC 9110 §13.2.2 — `If-Match`, sinon `If-Unmodified-Since` ;
|
|
55
|
+
* puis `If-None-Match`, sinon `If-Modified-Since`. `Range` et `If-Range`
|
|
56
|
+
* viennent après, s'il faut servir (voir {@link ifRangeHolds}).
|
|
57
|
+
*
|
|
58
|
+
* @param method - méthode de la requête
|
|
59
|
+
* @param headers - en-têtes de la requête
|
|
60
|
+
* @param validators - validateurs de la représentation
|
|
61
|
+
* @returns 200 pour servir, 304 si rien n'a changé (GET et HEAD seulement),
|
|
62
|
+
* 412 si une condition est fausse
|
|
63
|
+
*/
|
|
64
|
+
function evaluatePreconditions(method, headers, validators) {
|
|
65
|
+
const read = method === "GET" || method === "HEAD";
|
|
66
|
+
const ifMatch = headers["if-match"];
|
|
67
|
+
if (ifMatch !== void 0) {
|
|
68
|
+
const tags = entityTags(ifMatch);
|
|
69
|
+
const { etag } = validators;
|
|
70
|
+
if (!(tags === "*" || etag !== null && tags.some((tag) => strongMatch(tag, etag)))) return 412;
|
|
71
|
+
} else {
|
|
72
|
+
const since = parseHttpDate(headers["if-unmodified-since"]);
|
|
73
|
+
if (since !== null && validators.lastModified !== null && validators.lastModified > since) return 412;
|
|
74
|
+
}
|
|
75
|
+
const ifNoneMatch = headers["if-none-match"];
|
|
76
|
+
if (ifNoneMatch !== void 0) {
|
|
77
|
+
const tags = entityTags(ifNoneMatch);
|
|
78
|
+
const { etag } = validators;
|
|
79
|
+
if (tags === "*" || etag !== null && tags.some((tag) => weakMatch(tag, etag))) return read ? 304 : 412;
|
|
80
|
+
} else if (read) {
|
|
81
|
+
const since = parseHttpDate(headers["if-modified-since"]);
|
|
82
|
+
if (since !== null && validators.lastModified !== null && validators.lastModified <= since) return 304;
|
|
83
|
+
}
|
|
84
|
+
return 200;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Une condition `If-Range` tient-elle (RFC 9110 §13.1.5) ? Vraie, la plage
|
|
88
|
+
* demandée est servie (206) ; fausse, `Range` est ignoré et la représentation
|
|
89
|
+
* part entière (200) — un client ne recolle jamais deux versions.
|
|
90
|
+
*
|
|
91
|
+
* Une étiquette tient si elle est FORTEMENT égale ; une date, si elle égale
|
|
92
|
+
* exactement `Last-Modified` ET que celle-ci est un validateur fort.
|
|
93
|
+
*
|
|
94
|
+
* @param value - valeur brute de `If-Range`
|
|
95
|
+
* @param validators - validateurs de la représentation
|
|
96
|
+
*/
|
|
97
|
+
function ifRangeHolds(value, validators) {
|
|
98
|
+
const trimmed = value.trim();
|
|
99
|
+
if (trimmed.startsWith("\"") || trimmed.startsWith("W/")) return validators.etag !== null && strongMatch(trimmed, validators.etag);
|
|
100
|
+
const date = parseHttpDate(trimmed);
|
|
101
|
+
return date !== null && validators.lastModifiedStrong && validators.lastModified === date;
|
|
102
|
+
}
|
|
103
|
+
export { evaluatePreconditions, fileValidators, ifRangeHolds, parseHttpDate };
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { basename, dirname, extname, join } from "node:path";
|
|
2
2
|
import { findProjectRoot, isDocVisible, packageManagerCommandLines, packageManagerExecArgs, portableSpawn, readDocScope, readSymbolsGraph, resolvePackageManager } from "nodefony";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
|
-
import { promisify } from "node:util";
|
|
5
4
|
import { readFile, readdir, stat } from "node:fs/promises";
|
|
6
5
|
import { fileURLToPath } from "node:url";
|
|
7
6
|
import { execFile, spawn } from "node:child_process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
8
|
// nodefony/src/docsReader.ts
|
|
9
9
|
const execFileAsync = promisify(execFile);
|
|
10
10
|
/**
|
|
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
// nodefony/src/idempotency.ts
|
|
4
4
|
/**
|
|
5
5
|
* Logique **pure et partagée** de la porte d'idempotence des mutations, conforme à
|
|
6
|
-
* `draft-ietf-httpapi-idempotency-key-header-
|
|
6
|
+
* `draft-ietf-httpapi-idempotency-key-header-08`. Source unique de la sémantique
|
|
7
7
|
* (statuts normatifs, scope de clé, fingerprint, bornage) consommée par les DEUX
|
|
8
8
|
* call-sites qui mutent :
|
|
9
9
|
* - le **data plane admin** (`AdminApiController` — réponse `{status,headers,body}`) ;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LOGIN_PAGE_ASSETS_BASE, LOGIN_PAGE_LAYOUTS, LOGIN_PAGE_SKINS, LOGIN_PAGE_SKIN_SPECS, LOGIN_PAGE_THEME_KEY, OAUTH2_CANCELLED_REASON, isLoginPageSkin, loginPageSkinFile, oauth2AuthorizePath, safeRedirectPath } from "nodefony";
|
|
1
|
+
import { CSP_NONCE_META_PROPERTY, LOGIN_PAGE_ASSETS_BASE, LOGIN_PAGE_LAYOUTS, LOGIN_PAGE_SKINS, LOGIN_PAGE_SKIN_SPECS, LOGIN_PAGE_THEME_KEY, OAUTH2_CANCELLED_REASON, isLoginPageSkin, loginPageSkinFile, oauth2AuthorizePath, safeRedirectPath } from "nodefony";
|
|
2
2
|
// nodefony/src/loginPage.ts
|
|
3
3
|
/**
|
|
4
4
|
* Mémoïse une ressource chargée de façon asynchrone : le SUCCÈS est gardé, un
|
|
@@ -158,6 +158,7 @@ const LOGIN_PAGE_TEMPLATE = `<!doctype html>
|
|
|
158
158
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
159
159
|
<meta name="color-scheme" content="dark light" />
|
|
160
160
|
<meta name="robots" content="noindex" />
|
|
161
|
+
<% if (it.nonce) { %><meta property="${CSP_NONCE_META_PROPERTY}" nonce="<%= it.nonce %>"><% } %>
|
|
161
162
|
<title><%= it.title %> — <%= it.brand %></title>
|
|
162
163
|
<link rel="icon" href="<%= it.logoHref %>" type="image/svg+xml" />
|
|
163
164
|
<% if (!it.theme) { %>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { extractActionScopes } from "../decorators/routerDecorators.js";
|
|
2
1
|
import router_default from "../service/router.js";
|
|
2
|
+
import { extractActionScopes } from "../decorators/routerDecorators.js";
|
|
3
3
|
// nodefony/src/scopeCatalog.ts
|
|
4
4
|
/**
|
|
5
5
|
* P6.8 — **Découverte au boot** : scanne TOUTES les routes montées (`Router.routes`)
|
|
@@ -29,6 +29,8 @@ const SYSLOG_FILTERS = {
|
|
|
29
29
|
module: "string",
|
|
30
30
|
/** Catégorie de message (msgid) — inclusion insensible à la casse. */
|
|
31
31
|
msgid: "string",
|
|
32
|
+
/** Module exclu — égalité insensible à la casse (`NODEFONY` = le framework). */
|
|
33
|
+
excludeModule: "string",
|
|
32
34
|
/** Protocole d'origine ; absent = les deux. */
|
|
33
35
|
protocol: ["ws", "http"],
|
|
34
36
|
/**
|
|
@@ -42,6 +44,10 @@ const SYSLOG_FILTERS = {
|
|
|
42
44
|
* seconde liste ne peut se périmer.
|
|
43
45
|
*/
|
|
44
46
|
flow: { each: Object.keys(FLOW_STEPS) },
|
|
47
|
+
/** Écarte les lignes d'accès de la console d'administration elle-même. */
|
|
48
|
+
hideConsole: "boolean",
|
|
49
|
+
/** Une ligne par requête (sa ligne d'accès), avec le nombre de lignes regroupées. */
|
|
50
|
+
groupByRequest: "boolean",
|
|
45
51
|
/** Borne basse d'horodatage (epoch ms, incluse). */
|
|
46
52
|
from: "int",
|
|
47
53
|
/** Borne haute d'horodatage (epoch ms, incluse). */
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// nodefony/src/viewNonce.ts
|
|
2
|
+
/**
|
|
3
|
+
* Signature CSP des vues : chaque `<script>` et `<style>` ÉCRIT dans un
|
|
4
|
+
* gabarit reçoit le nonce de la requête, sans geste de l'auteur.
|
|
5
|
+
*
|
|
6
|
+
* La politique par défaut exige un nonce sur les scripts (`'strict-dynamic'`)
|
|
7
|
+
* et sur les styles (ADR-0024 §C1, §C2). Une page écrite vite, par quelqu'un
|
|
8
|
+
* qui ignore tout de la CSP, ne doit pas être bloquée (§C0) : la signature se
|
|
9
|
+
* pose donc au rendu, par le framework.
|
|
10
|
+
*
|
|
11
|
+
* Elle porte sur le TEXTE DU GABARIT, avant que les données y soient insérées :
|
|
12
|
+
* une balise que les données apporteraient (une valeur insérée brute par
|
|
13
|
+
* `<%~ %>`) n'est jamais signée — c'est ce qui garde au nonce sa valeur contre
|
|
14
|
+
* l'injection. Même principe que l'`index.html` d'un front bâti
|
|
15
|
+
* (`prebuiltUi.ts#splitAroundNonce`).
|
|
16
|
+
*/
|
|
17
|
+
/** Variable de vue qui porte le nonce CSP de la requête (`<%= cspNonce %>`). */
|
|
18
|
+
const VIEW_NONCE_LOCAL = "cspNonce";
|
|
19
|
+
/** Ouverture d'une balise `<script` ou `<style` qui ne porte pas de `nonce`. */
|
|
20
|
+
const UNSIGNED_TAG_OPEN = /<(script|style)\b(?![^>]*\bnonce\s*=)/giu;
|
|
21
|
+
/** Une balise Eta (`<% %>`, `<%= %>`, `<%~ %>`) : du code, jamais du balisage. */
|
|
22
|
+
const ETA_TAG = /<%[\s\S]*?%>/gu;
|
|
23
|
+
/** Attribut inséré : le nonce, lu dans les variables de la vue à chaque rendu. */
|
|
24
|
+
const NONCE_ATTRIBUTE = ` nonce="<%= it.${VIEW_NONCE_LOCAL} %>"`;
|
|
25
|
+
/**
|
|
26
|
+
* Ajoute le nonce de la requête à chaque `<script>` et `<style>` littéral du
|
|
27
|
+
* gabarit qui n'en porte pas.
|
|
28
|
+
*
|
|
29
|
+
* Une balise déjà signée par l'auteur est laissée telle quelle ; un `<script`
|
|
30
|
+
* écrit À L'INTÉRIEUR d'une balise Eta (une chaîne de code) n'est jamais
|
|
31
|
+
* touché. Un `<script>` dont un attribut vient des données
|
|
32
|
+
* (`src="<%= it.url %>"`) n'est pas signé non plus : le signer exécuterait
|
|
33
|
+
* l'URL qu'elles apportent. L'auteur qui la tient pour sûre signe lui-même
|
|
34
|
+
* (`nonce="<%= it.cspNonce %>"`). Le rendu doit fournir la variable
|
|
35
|
+
* {@link VIEW_NONCE_LOCAL}.
|
|
36
|
+
*
|
|
37
|
+
* @param source - texte du gabarit Eta, tel que lu sur le disque.
|
|
38
|
+
* @returns le gabarit signé — la MÊME chaîne quand il n'y a rien à signer.
|
|
39
|
+
*/
|
|
40
|
+
function signTemplateTags(source) {
|
|
41
|
+
UNSIGNED_TAG_OPEN.lastIndex = 0;
|
|
42
|
+
if (!UNSIGNED_TAG_OPEN.test(source)) return source;
|
|
43
|
+
let etaRanges = null;
|
|
44
|
+
ETA_TAG.lastIndex = 0;
|
|
45
|
+
for (let m = ETA_TAG.exec(source); m !== null; m = ETA_TAG.exec(source)) (etaRanges ??= []).push(m.index, m.index + m[0].length);
|
|
46
|
+
return source.replace(UNSIGNED_TAG_OPEN, (tag, name, offset) => {
|
|
47
|
+
if (etaRanges !== null) for (let i = 0; i < etaRanges.length; i += 2) {
|
|
48
|
+
const start = etaRanges[i] ?? 0;
|
|
49
|
+
if (start > offset) {
|
|
50
|
+
if (name.toLowerCase() === "script" && start < source.indexOf(">", offset)) return tag;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
if (offset < (etaRanges[i + 1] ?? 0)) return tag;
|
|
54
|
+
}
|
|
55
|
+
return tag + NONCE_ATTRIBUTE;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
export { VIEW_NONCE_LOCAL, signTemplateTags };
|
package/dist/types/index.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ import IssuerMetadataController, { mountIssuerMetadataRoutes } from "./nodefony/
|
|
|
16
16
|
import ProtectedResourceMetadataController, { mountProtectedResourceRoutes, protectedResourceRoutePaths, collectProtectedResources } from "./nodefony/controller/ProtectedResourceMetadataController.js";
|
|
17
17
|
import WebAuthnController, { mountWebAuthnRoutes } from "./nodefony/controller/WebAuthnController.js";
|
|
18
18
|
import OAuth2Controller, { mountOAuth2Routes } from "./nodefony/controller/OAuth2Controller.js";
|
|
19
|
+
import CspReportController, { mountCspReportRoute } from "./nodefony/controller/CspReportController.js";
|
|
19
20
|
import BenchController, { mountBenchRoutes } from "./nodefony/controller/BenchController.js";
|
|
20
21
|
import ApiKeyController, { mountApiKeyRoutes } from "./nodefony/controller/ApiKeyController.js";
|
|
21
22
|
import TotpController, { mountTotpRoutes } from "./nodefony/controller/TotpController.js";
|
|
@@ -24,7 +25,7 @@ import { createFrameworkAdminApi } from "./nodefony/src/FrameworkAdminApi.js";
|
|
|
24
25
|
import { buildPlaygroundSnapshot } from "./nodefony/src/PlaygroundAdminApi.js";
|
|
25
26
|
import { createSyslogAdminApi } from "./nodefony/src/SyslogAdminApi.js";
|
|
26
27
|
import Eta from "./nodefony/service/Eta.js";
|
|
27
|
-
import { controllers, route, controller, Get, Post, Put, Delete, Patch, Options, Head, All, Domain, BypassFirewall, IsGranted, RequireScope, RequireAuthentication, Anonymous, Csp, CsrfProtect, CsrfExempt, Idempotent, CurrentUser, Scope, UseSession, HttpCode, Header, Redirect, Param, Body, Query, Headers, Cookie, Session, Req, Res, UploadedFile, UploadedFiles, routeExpectsBodyStream, buildContextParamArgs, getParamArgsMeta } from "./nodefony/decorators/routerDecorators.js";
|
|
28
|
+
import { controllers, route, controller, Get, Post, Put, Delete, Patch, Options, Head, All, Domain, BypassFirewall, IsGranted, RequireScope, RequireAuthentication, Anonymous, Csp, CsrfProtect, CsrfExempt, Idempotent, CurrentUser, Scope, UseSession, HttpCode, Header, Redirect, Param, Body, Query, Headers, Cookie, Session, Req, Res, UploadedFile, UploadedFiles, routeExpectsBodyStream, buildContextParamArgs, getParamArgsMeta, getRouteSchema } from "./nodefony/decorators/routerDecorators.js";
|
|
28
29
|
export type { IHttpMethodDecorator } from "./nodefony/decorators/routerDecorators.js";
|
|
29
30
|
export { validateRouteInput } from "./nodefony/src/routeSchema.js";
|
|
30
31
|
export type { IRouteSchema, RouteInput, RoutePart, RawRouteInput, RawRouteParams, RawRouteQuery, RawRouteHeaders, } from "./nodefony/src/routeSchema.js";
|
|
@@ -79,7 +80,7 @@ declare class Framework extends Module<IFrameworkConfig> {
|
|
|
79
80
|
onKernelReady(): Promise<this>;
|
|
80
81
|
}
|
|
81
82
|
export default Framework;
|
|
82
|
-
export { Controller, ResourceController, Route, Router, FRAMEWORK_ROUTER, isFrameworkRouter, Resolver, AdminBroker, MemoryIdempotencyStore, AdminApiController, SessionAuthController, mountSessionAuthRoutes, LoginPageController, mountLoginPage, TokenAuthController, mountTokenAuthRoutes, IssuerMetadataController, mountIssuerMetadataRoutes, ProtectedResourceMetadataController, mountProtectedResourceRoutes, protectedResourceRoutePaths, collectProtectedResources, WebAuthnController, mountWebAuthnRoutes, OAuth2Controller, mountOAuth2Routes, BenchController, mountBenchRoutes, ApiKeyController, mountApiKeyRoutes, TotpController, mountTotpRoutes, createKernelAdminApi, createFrameworkAdminApi, createSyslogAdminApi, buildPlaygroundSnapshot, Eta, route, controller, controllers, Get, Post, Put, Delete, Patch, Options, Head, All, Domain, BypassFirewall, IsGranted, RequireScope, RequireAuthentication, Anonymous, Csp, CsrfProtect, CsrfExempt, Idempotent, CurrentUser, Scope, UseSession, HttpCode, Header, Redirect, Param, Body, Query, Headers, Cookie, Session, Req, Res, UploadedFile, UploadedFiles, routeExpectsBodyStream, buildContextParamArgs, getParamArgsMeta, registerIdempotencyStore, getIdempotencyStoreFactory, listIdempotencyStores, };
|
|
83
|
+
export { Controller, ResourceController, Route, Router, FRAMEWORK_ROUTER, isFrameworkRouter, Resolver, AdminBroker, MemoryIdempotencyStore, AdminApiController, SessionAuthController, mountSessionAuthRoutes, LoginPageController, mountLoginPage, TokenAuthController, mountTokenAuthRoutes, IssuerMetadataController, mountIssuerMetadataRoutes, ProtectedResourceMetadataController, mountProtectedResourceRoutes, protectedResourceRoutePaths, collectProtectedResources, WebAuthnController, mountWebAuthnRoutes, OAuth2Controller, mountOAuth2Routes, CspReportController, mountCspReportRoute, BenchController, mountBenchRoutes, ApiKeyController, mountApiKeyRoutes, TotpController, mountTotpRoutes, createKernelAdminApi, createFrameworkAdminApi, createSyslogAdminApi, buildPlaygroundSnapshot, Eta, route, controller, controllers, Get, Post, Put, Delete, Patch, Options, Head, All, Domain, BypassFirewall, IsGranted, RequireScope, RequireAuthentication, Anonymous, Csp, CsrfProtect, CsrfExempt, Idempotent, CurrentUser, Scope, UseSession, HttpCode, Header, Redirect, Param, Body, Query, Headers, Cookie, Session, Req, Res, UploadedFile, UploadedFiles, routeExpectsBodyStream, buildContextParamArgs, getParamArgsMeta, getRouteSchema, registerIdempotencyStore, getIdempotencyStoreFactory, listIdempotencyStores, };
|
|
83
84
|
export { frameworkConfigSchema } from "./nodefony/config/config.js";
|
|
84
85
|
export type { IdempotencyStoreFactory, IIdempotencyStoreFactoryContext, } from "./nodefony/src/idempotencyStoreRegistry.js";
|
|
85
86
|
export type { SecurityClause, SecurityRequirement, IAuthenticationRequirement, IRequireAuthenticationOptions, CspDirectives, IParamArgSource, ParamMeta, } from "./nodefony/decorators/routerDecorators.js";
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Requêtes conditionnelles des fichiers servis par une action (`streamFile`,
|
|
4
|
+
* `renderFileDownload`, `renderMediaStream`). Le défaut de TOUTE
|
|
5
|
+
* l'application ; un appel le remplace par ses options (`validators`) ou ses
|
|
6
|
+
* en-têtes (`Cache-Control`, `ETag`).
|
|
7
|
+
*/
|
|
8
|
+
export declare const fileServingSchema: z.ZodObject<{
|
|
9
|
+
validators: z.ZodDefault<z.ZodBoolean>;
|
|
10
|
+
cacheControl: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
|
11
|
+
}, z.core.$strict>;
|
|
12
|
+
/** Réglages des fichiers servis, défauts appliqués. */
|
|
13
|
+
export type IFileServingConfig = z.infer<typeof fileServingSchema>;
|
|
2
14
|
export declare const frameworkConfigSchema: z.ZodObject<{
|
|
3
15
|
router: z.ZodOptional<z.ZodObject<{}, z.core.$loose>>;
|
|
4
16
|
adminBroker: z.ZodOptional<z.ZodObject<{}, z.core.$loose>>;
|
|
@@ -7,6 +19,10 @@ export declare const frameworkConfigSchema: z.ZodObject<{
|
|
|
7
19
|
gcIntervalS: z.ZodDefault<z.ZodNumber>;
|
|
8
20
|
gcJitter: z.ZodDefault<z.ZodBoolean>;
|
|
9
21
|
}, z.core.$strict>>;
|
|
22
|
+
fileServing: z.ZodDefault<z.ZodObject<{
|
|
23
|
+
validators: z.ZodDefault<z.ZodBoolean>;
|
|
24
|
+
cacheControl: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
|
25
|
+
}, z.core.$strict>>;
|
|
10
26
|
}, z.core.$strict>;
|
|
11
27
|
/** Type de sortie (config normalisée + défauts appliqués). */
|
|
12
28
|
export type IFrameworkConfig = z.infer<typeof frameworkConfigSchema>;
|
|
@@ -24,6 +40,10 @@ declare const _default: {
|
|
|
24
40
|
gcIntervalS: number;
|
|
25
41
|
gcJitter: boolean;
|
|
26
42
|
};
|
|
43
|
+
fileServing: {
|
|
44
|
+
validators: boolean;
|
|
45
|
+
cacheControl: string | null;
|
|
46
|
+
};
|
|
27
47
|
"module-security": {
|
|
28
48
|
areas: {
|
|
29
49
|
"nodefony-liveness": {
|
|
@@ -35,11 +35,13 @@ declare class AdminApiController extends Controller {
|
|
|
35
35
|
constructor(context: ContextType);
|
|
36
36
|
/**
|
|
37
37
|
* Action générique appelée par le Resolver pour toute route admin — DUPLEX
|
|
38
|
-
* (
|
|
38
|
+
* (une action, deux transports) : même exécution sur les deux transports, seul
|
|
39
39
|
* l'emballage diffère.
|
|
40
40
|
*
|
|
41
|
-
* - **HTTP** :
|
|
42
|
-
*
|
|
41
|
+
* - **HTTP** : `renderJson` + status + header `x-nodefony-instance`. Quand
|
|
42
|
+
* l'application active un codec binaire, le corps passe par le codec texte
|
|
43
|
+
* de la socket (convention `$bytes`, annoncée par `x-nodefony-bytes`) : des
|
|
44
|
+
* octets se relisent alors à l'identique sur les deux transports.
|
|
43
45
|
* - **Pont WS-RPC `api.request`** : valeur **nue** (le pont l'enveloppe
|
|
44
46
|
* `{id, result}` — snapshot ≡ GET par construction) ; statut ≥ 400 →
|
|
45
47
|
* {@link RpcError} (`data.status` + `data.body`), symétrie d'un `fetch`
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type Module } from "nodefony";
|
|
2
|
+
import { type ContextType } from "@nodefony/http";
|
|
3
|
+
import Controller from "../src/Controller.js";
|
|
4
|
+
/**
|
|
5
|
+
* Réception PUBLIQUE des rapports de violation CSP (ADR-0024 §C7) —
|
|
6
|
+
* `POST /nodefony/security/api/csp-report`, adaptateur MINCE au-dessus du
|
|
7
|
+
* service `cspReports`.
|
|
8
|
+
*
|
|
9
|
+
* Le navigateur poste sans identifiant : la route contourne le pare-feu, et la
|
|
10
|
+
* défense CSRF (`@CsrfExempt`) — elle ne change l'état de personne, et un
|
|
11
|
+
* rapport n'a aucune session à détourner. Le corps arrive en FLUX
|
|
12
|
+
* (`@Body({ stream: true })`) pour être borné PENDANT la lecture : au-delà de
|
|
13
|
+
* {@link CSP_REPORT_MAX_BYTES}, 413 sans rien garder. Tout le reste — quota,
|
|
14
|
+
* type de contenu, forme, expurgation — appartient au collecteur, et la
|
|
15
|
+
* réponse est 204 quoi qu'il en pense : l'émetteur n'apprend rien.
|
|
16
|
+
*/
|
|
17
|
+
declare class CspReportController extends Controller {
|
|
18
|
+
constructor(context: ContextType);
|
|
19
|
+
/**
|
|
20
|
+
* Reçoit un rapport, ou un lot de rapports.
|
|
21
|
+
*
|
|
22
|
+
* @param stream - le corps brut, jamais lu par le pipeline.
|
|
23
|
+
* @throws HttpError 413 quand le corps dépasse le plafond.
|
|
24
|
+
*/
|
|
25
|
+
report(stream: AsyncIterable<Uint8Array>): Promise<import("@nodefony/http").Http2Response | import("@nodefony/http").HttpResponse | import("@nodefony/http").WebsocketResponse>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Monte la route de réception — appelé par le module framework à
|
|
29
|
+
* `onKernelReady`, seulement si le service `cspReports` est présent (rapports
|
|
30
|
+
* demandés vers la route du framework).
|
|
31
|
+
*
|
|
32
|
+
* @param frameworkModule - module qui porte le contrôleur.
|
|
33
|
+
*/
|
|
34
|
+
export declare function mountCspReportRoute(frameworkModule: Module): void;
|
|
35
|
+
export default CspReportController;
|
|
@@ -6,8 +6,9 @@ import Controller from "../src/Controller.js";
|
|
|
6
6
|
* feuille `nodefony/login.css`, script `nodefony/login.js`. Aucun front, aucun
|
|
7
7
|
* build côté application.
|
|
8
8
|
*
|
|
9
|
-
* Joignable sans session ; jamais mise en cache (`no-store`)
|
|
10
|
-
*
|
|
9
|
+
* Joignable sans session ; jamais mise en cache (`no-store`). L’encadrement
|
|
10
|
+
* suit `http.securityHeaders.frameOptions`, qui pose `X-Frame-Options` ET
|
|
11
|
+
* `frame-ancestors` sur toute la politique (ADR-0024 §C5).
|
|
11
12
|
*/
|
|
12
13
|
declare class LoginPageController extends Controller {
|
|
13
14
|
constructor(context: ContextType);
|
|
@@ -62,12 +62,11 @@ export interface ISessionAuthFlow {
|
|
|
62
62
|
* (Auth.js, better-auth). Le `401` y écrivait une erreur réseau dans la console
|
|
63
63
|
* de chaque visiteur anonyme, sans rien protéger : la réponse ne révèle rien.
|
|
64
64
|
*
|
|
65
|
-
* @remarks
|
|
66
|
-
*
|
|
67
|
-
* session, et un
|
|
68
|
-
* navigateur
|
|
69
|
-
*
|
|
70
|
-
* déclare un).
|
|
65
|
+
* @remarks Les 401 de `login` portent le défi `Session realm="nodefony"`
|
|
66
|
+
* (RFC 9110 §15.5.2, posé par `SecurityApiController`), celui du pare-feu :
|
|
67
|
+
* aucun schéma HTTP ne décrit un cookie de session, et un `Basic` ouvrirait la
|
|
68
|
+
* fenêtre de connexion native du navigateur — l'anti-pattern que le BFF
|
|
69
|
+
* élimine. Un schéma inconnu des navigateurs n'en ouvre aucune.
|
|
71
70
|
*/
|
|
72
71
|
declare class SessionAuthController extends SecurityApiController {
|
|
73
72
|
#private;
|
|
@@ -33,9 +33,9 @@ export interface ITokenIssuer {
|
|
|
33
33
|
* framework ne peut pas importer les classes d'erreur de security ; un 429
|
|
34
34
|
* reporte le `Retry-After` (RFC 6585) du throttler NIST.
|
|
35
35
|
*
|
|
36
|
-
* @remarks
|
|
37
|
-
*
|
|
38
|
-
* ressource protégée par Bearer, il DÉLIVRE le Bearer.
|
|
36
|
+
* @remarks Ces 401 portent le défi `Session realm="nodefony"` (RFC 9110
|
|
37
|
+
* §15.5.2, posé par `SecurityApiController`) et non un `Bearer` : l'endpoint
|
|
38
|
+
* d'émission n'est pas une ressource protégée par Bearer, il DÉLIVRE le Bearer.
|
|
39
39
|
*/
|
|
40
40
|
declare class TokenAuthController extends SecurityApiController {
|
|
41
41
|
#private;
|
|
@@ -34,6 +34,6 @@ export interface IController {
|
|
|
34
34
|
getFile(file: FileClass | string): FileClass;
|
|
35
35
|
/** Variante async de `getFile` (stat non bloquant via `FileClass.from`). */
|
|
36
36
|
getFileAsync(file: FileClass | string): Promise<FileClass>;
|
|
37
|
-
renderFileDownload(file: unknown, options?: unknown, headers?: OutgoingHttpHeaders): Promise<ReadStream>;
|
|
38
|
-
streamFile(file: FileClass | string, headers?: OutgoingHttpHeaders, options?: Record<string, unknown>): Promise<ReadStream>;
|
|
37
|
+
renderFileDownload(file: unknown, options?: unknown, headers?: OutgoingHttpHeaders): Promise<ReadStream | null>;
|
|
38
|
+
streamFile(file: FileClass | string, headers?: OutgoingHttpHeaders, options?: Record<string, unknown>): Promise<ReadStream | null>;
|
|
39
39
|
}
|
|
@@ -111,8 +111,9 @@ declare class Router extends Service implements IRequestRouter {
|
|
|
111
111
|
* installées du framework feraient échouer `instanceof` en silence. Une
|
|
112
112
|
* lecture de propriété.
|
|
113
113
|
*
|
|
114
|
-
* @param router - le routeur lu sur le contexte (`context.router`)
|
|
114
|
+
* @param router - le routeur lu sur le contexte (`context.router`) ou le
|
|
115
|
+
* service `router` du conteneur.
|
|
115
116
|
* @returns `true` si c'est le Router du framework.
|
|
116
117
|
*/
|
|
117
|
-
export declare function isFrameworkRouter(router:
|
|
118
|
+
export declare function isFrameworkRouter(router: unknown): router is Router;
|
|
118
119
|
export default Router;
|