@nodefony/http 10.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +544 -0
- package/README.md +77 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateParam.js +8 -0
- package/dist/index.js +108 -0
- package/dist/nodefony/command/assetsPublishCommand.js +102 -0
- package/dist/nodefony/command/certificatesCommand.js +47 -0
- package/dist/nodefony/command/networkCommand.js +27 -0
- package/dist/nodefony/command/proxyGenerateCommand.js +66 -0
- package/dist/nodefony/config/config.js +335 -0
- package/dist/nodefony/config/defineModuleConfig.js +93 -0
- package/dist/nodefony/interfaces/IContext.js +1 -0
- package/dist/nodefony/interfaces/ICookie.js +1 -0
- package/dist/nodefony/interfaces/IErrorRenderer.js +1 -0
- package/dist/nodefony/interfaces/IHttpConfig.js +1 -0
- package/dist/nodefony/interfaces/IHttpKernel.js +1 -0
- package/dist/nodefony/interfaces/IRequest.js +1 -0
- package/dist/nodefony/interfaces/IRequestLogger.js +1 -0
- package/dist/nodefony/interfaces/IResponse.js +1 -0
- package/dist/nodefony/interfaces/ISession.js +1 -0
- package/dist/nodefony/interfaces/IUpload.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/service/HttpAdminApi.js +376 -0
- package/dist/nodefony/service/ProfilerAdminApi.js +73 -0
- package/dist/nodefony/service/audit-logger.js +159 -0
- package/dist/nodefony/service/certificates.js +545 -0
- package/dist/nodefony/service/error-renderer.js +320 -0
- package/dist/nodefony/service/http-kernel.js +948 -0
- package/dist/nodefony/service/pretty-request-logger.js +72 -0
- package/dist/nodefony/service/request-logger.js +54 -0
- package/dist/nodefony/service/servers/clientError.js +20 -0
- package/dist/nodefony/service/servers/server-http.js +135 -0
- package/dist/nodefony/service/servers/server-https.js +204 -0
- package/dist/nodefony/service/servers/server-static.js +192 -0
- package/dist/nodefony/service/servers/server-websocket-secure.js +104 -0
- package/dist/nodefony/service/servers/server-websocket.js +104 -0
- package/dist/nodefony/service/servers/serverShutdown.js +31 -0
- package/dist/nodefony/service/servers/wsHeartbeat.js +64 -0
- package/dist/nodefony/service/sessions/sessions-service.js +580 -0
- package/dist/nodefony/service/trace.js +72 -0
- package/dist/nodefony/service/upload/upload-service.js +171 -0
- package/dist/nodefony/src/assets/collectAssets.js +34 -0
- package/dist/nodefony/src/assets/prebuiltUi.js +125 -0
- package/dist/nodefony/src/context/Context.js +415 -0
- package/dist/nodefony/src/context/domainMatcher.js +88 -0
- package/dist/nodefony/src/context/forwarded.js +185 -0
- package/dist/nodefony/src/context/http/HttpContext.js +309 -0
- package/dist/nodefony/src/context/http/Request.js +543 -0
- package/dist/nodefony/src/context/http/Response.js +368 -0
- package/dist/nodefony/src/context/http/parser.js +188 -0
- package/dist/nodefony/src/context/http/urlFastPath.js +103 -0
- package/dist/nodefony/src/context/http2/Request.js +29 -0
- package/dist/nodefony/src/context/http2/Response.js +97 -0
- package/dist/nodefony/src/context/metaData.js +47 -0
- package/dist/nodefony/src/context/requestId.js +41 -0
- package/dist/nodefony/src/context/trustProxy.js +167 -0
- package/dist/nodefony/src/context/websocket/Response.js +181 -0
- package/dist/nodefony/src/context/websocket/WebsocketContext.js +389 -0
- package/dist/nodefony/src/context/websocket/wsBackpressure.js +56 -0
- package/dist/nodefony/src/context/websocket/wsLogContent.js +68 -0
- package/dist/nodefony/src/cookies/cookie.js +258 -0
- package/dist/nodefony/src/errors/httpError.js +69 -0
- package/dist/nodefony/src/profiler/FrameProfile.js +95 -0
- package/dist/nodefony/src/profiler/Profiler.js +139 -0
- package/dist/nodefony/src/proxy/generateProxyConfig.js +157 -0
- package/dist/nodefony/src/rateLimit/IRateLimitStore.js +1 -0
- package/dist/nodefony/src/rateLimit/MemoryRateLimitStore.js +146 -0
- package/dist/nodefony/src/rateLimit/WsConnectionCounter.js +64 -0
- package/dist/nodefony/src/rateLimit/rateLimitFilters.js +20 -0
- package/dist/nodefony/src/servers/portBinder.js +114 -0
- package/dist/nodefony/src/session/session.js +390 -0
- package/dist/nodefony/src/session/storage/MemorySessionStorage.js +185 -0
- package/dist/nodefony/src/session/storage/RevocationGuardStorage.js +137 -0
- package/dist/nodefony/src/session/storage/sessionFilters.js +83 -0
- package/dist/nodefony/src/session/storage/sessionSort.js +53 -0
- package/dist/types/index.d.ts +83 -0
- package/dist/types/nodefony/command/assetsPublishCommand.d.ts +23 -0
- package/dist/types/nodefony/command/certificatesCommand.d.ts +17 -0
- package/dist/types/nodefony/command/networkCommand.d.ts +8 -0
- package/dist/types/nodefony/command/proxyGenerateCommand.d.ts +19 -0
- package/dist/types/nodefony/config/config.d.ts +197 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +39 -0
- package/dist/types/nodefony/interfaces/IContext.d.ts +138 -0
- package/dist/types/nodefony/interfaces/ICookie.d.ts +47 -0
- package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +55 -0
- package/dist/types/nodefony/interfaces/IHttpConfig.d.ts +12 -0
- package/dist/types/nodefony/interfaces/IHttpKernel.d.ts +10 -0
- package/dist/types/nodefony/interfaces/IRequest.d.ts +35 -0
- package/dist/types/nodefony/interfaces/IRequestLogger.d.ts +31 -0
- package/dist/types/nodefony/interfaces/IResponse.d.ts +39 -0
- package/dist/types/nodefony/interfaces/ISession.d.ts +283 -0
- package/dist/types/nodefony/interfaces/IUpload.d.ts +66 -0
- package/dist/types/nodefony/interfaces/index.d.ts +7 -0
- package/dist/types/nodefony/service/HttpAdminApi.d.ts +18 -0
- package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +23 -0
- package/dist/types/nodefony/service/audit-logger.d.ts +143 -0
- package/dist/types/nodefony/service/certificates.d.ts +246 -0
- package/dist/types/nodefony/service/error-renderer.d.ts +74 -0
- package/dist/types/nodefony/service/http-kernel.d.ts +377 -0
- package/dist/types/nodefony/service/pretty-request-logger.d.ts +25 -0
- package/dist/types/nodefony/service/request-logger.d.ts +18 -0
- package/dist/types/nodefony/service/servers/clientError.d.ts +14 -0
- package/dist/types/nodefony/service/servers/server-http.d.ts +42 -0
- package/dist/types/nodefony/service/servers/server-https.d.ts +41 -0
- package/dist/types/nodefony/service/servers/server-static.d.ts +62 -0
- package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +29 -0
- package/dist/types/nodefony/service/servers/server-websocket.d.ts +29 -0
- package/dist/types/nodefony/service/servers/serverShutdown.d.ts +27 -0
- package/dist/types/nodefony/service/servers/wsHeartbeat.d.ts +46 -0
- package/dist/types/nodefony/service/sessions/sessions-service.d.ts +218 -0
- package/dist/types/nodefony/service/trace.d.ts +39 -0
- package/dist/types/nodefony/service/upload/upload-service.d.ts +61 -0
- package/dist/types/nodefony/src/assets/collectAssets.d.ts +35 -0
- package/dist/types/nodefony/src/assets/prebuiltUi.d.ts +99 -0
- package/dist/types/nodefony/src/context/Context.d.ts +195 -0
- package/dist/types/nodefony/src/context/domainMatcher.d.ts +67 -0
- package/dist/types/nodefony/src/context/forwarded.d.ts +95 -0
- package/dist/types/nodefony/src/context/http/HttpContext.d.ts +85 -0
- package/dist/types/nodefony/src/context/http/Request.d.ts +203 -0
- package/dist/types/nodefony/src/context/http/Response.d.ts +68 -0
- package/dist/types/nodefony/src/context/http/parser.d.ts +65 -0
- package/dist/types/nodefony/src/context/http/urlFastPath.d.ts +52 -0
- package/dist/types/nodefony/src/context/http2/Request.d.ts +14 -0
- package/dist/types/nodefony/src/context/http2/Response.d.ts +20 -0
- package/dist/types/nodefony/src/context/metaData.d.ts +58 -0
- package/dist/types/nodefony/src/context/requestId.d.ts +28 -0
- package/dist/types/nodefony/src/context/trustProxy.d.ts +77 -0
- package/dist/types/nodefony/src/context/websocket/Response.d.ts +53 -0
- package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +125 -0
- package/dist/types/nodefony/src/context/websocket/wsBackpressure.d.ts +73 -0
- package/dist/types/nodefony/src/context/websocket/wsLogContent.d.ts +37 -0
- package/dist/types/nodefony/src/cookies/cookie.d.ts +88 -0
- package/dist/types/nodefony/src/errors/httpError.d.ts +15 -0
- package/dist/types/nodefony/src/profiler/FrameProfile.d.ts +110 -0
- package/dist/types/nodefony/src/profiler/Profiler.d.ts +192 -0
- package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +76 -0
- package/dist/types/nodefony/src/rateLimit/IRateLimitStore.d.ts +98 -0
- package/dist/types/nodefony/src/rateLimit/MemoryRateLimitStore.d.ts +40 -0
- package/dist/types/nodefony/src/rateLimit/WsConnectionCounter.d.ts +37 -0
- package/dist/types/nodefony/src/rateLimit/rateLimitFilters.d.ts +18 -0
- package/dist/types/nodefony/src/servers/portBinder.d.ts +102 -0
- package/dist/types/nodefony/src/session/session.d.ts +171 -0
- package/dist/types/nodefony/src/session/storage/MemorySessionStorage.d.ts +77 -0
- package/dist/types/nodefony/src/session/storage/RevocationGuardStorage.d.ts +81 -0
- package/dist/types/nodefony/src/session/storage/sessionFilters.d.ts +102 -0
- package/dist/types/nodefony/src/session/storage/sessionSort.d.ts +45 -0
- package/docs/cookies.md +365 -0
- package/docs/index.md +163 -0
- package/docs/observabilite.md +460 -0
- package/docs/rate-limit.md +372 -0
- package/docs/servers.md +935 -0
- package/docs/session.md +768 -0
- package/docs/upload.md +460 -0
- package/package.json +101 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { SESSION_FACETS, SESSION_FILTERS, SESSION_STATS_FILTERS } from "../src/session/storage/sessionFilters.js";
|
|
2
|
+
import { RATE_LIMIT_FILTERS } from "../src/rateLimit/rateLimitFilters.js";
|
|
3
|
+
import { parseFilters, parsePageQuery } from "nodefony";
|
|
4
|
+
//#region nodefony/service/HttpAdminApi.ts
|
|
5
|
+
/** Nom du service HttpKernel dans le container (source unique). */
|
|
6
|
+
const HTTP_KERNEL_SERVICE = "HttpKernel";
|
|
7
|
+
/**
|
|
8
|
+
* `limit`/`offset` du contrat de page, avec l'`offset` **matérialisé** : ces
|
|
9
|
+
* endpoints le renvoient dans leur réponse, où l'absence n'a pas de sens (le
|
|
10
|
+
* client lit « page 1 », pas « pas de décalage »). Le traducteur, lui, laisse
|
|
11
|
+
* `offset` absent quand le client n'en demande pas — c'est le contrat.
|
|
12
|
+
*/
|
|
13
|
+
function pageParams(query) {
|
|
14
|
+
const parsed = parsePageQuery(query, { searchable: true });
|
|
15
|
+
return {
|
|
16
|
+
limit: parsed.limit,
|
|
17
|
+
offset: parsed.offset ?? 0,
|
|
18
|
+
...parsed.q !== void 0 ? { q: parsed.q } : {}
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Libellé d'identité de l'admin appelant (pour l'audit) — duck-typing prudent
|
|
23
|
+
* sur l'`IUser` projeté dans `IAdminRequest.user`. Repli `"admin"`.
|
|
24
|
+
*/
|
|
25
|
+
function adminActor(user) {
|
|
26
|
+
if (user && typeof user === "object") {
|
|
27
|
+
const u = user;
|
|
28
|
+
if (typeof u.username === "string" && u.username) return u.username;
|
|
29
|
+
if (typeof u.identifier === "string" && u.identifier) return u.identifier;
|
|
30
|
+
}
|
|
31
|
+
return "admin";
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Identifiant de l'appelant authentifié pour le SCOPE self-service — lu sur l'IUser
|
|
35
|
+
* projeté dans `IAdminRequest.user` (= `session.user`, posé au login). `null` si
|
|
36
|
+
* absent/vide → le handler répond 401 (jamais de scope vide qui listerait les
|
|
37
|
+
* sessions anonymes). **Jamais** dérivé d'un paramètre client (anti-IDOR).
|
|
38
|
+
*/
|
|
39
|
+
function currentIdentifier(user) {
|
|
40
|
+
if (user && typeof user === "object") {
|
|
41
|
+
const u = user;
|
|
42
|
+
if (typeof u.identifier === "string" && u.identifier.length > 0) return u.identifier;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Producteur `IAdminApi` du module **http** — exposé sous `/nodefony/http/api/*`.
|
|
48
|
+
*
|
|
49
|
+
* 2ᵉ producteur du data plane admin (le 1er étant le kernel). Démontre le
|
|
50
|
+
* pattern multi-modules : `@nodefony/http` n'importe QUE le contrat core
|
|
51
|
+
* (`IAdminApi`) — jamais `@nodefony/framework` (dépendance circulaire). Il
|
|
52
|
+
* s'enregistre auprès du broker via `IAdminRegistry` récupéré du container.
|
|
53
|
+
*
|
|
54
|
+
* Endpoints :
|
|
55
|
+
* - `GET /nodefony/http/api/servers` → liste des serveurs réseau + leur état
|
|
56
|
+
* - `GET /nodefony/http/api/info` → résumé (serveurs prêts, ports, schemes)
|
|
57
|
+
*
|
|
58
|
+
* @param module - le module http (accès aux services serveur du container).
|
|
59
|
+
* @returns le contrat admin de http, prêt à `registry.register()`.
|
|
60
|
+
*/
|
|
61
|
+
function createHttpAdminApi(module) {
|
|
62
|
+
/** Noms des services serveur enregistrés par le module http. */
|
|
63
|
+
const serverServices = [
|
|
64
|
+
"server-http",
|
|
65
|
+
"server-https",
|
|
66
|
+
"server-websocket",
|
|
67
|
+
"server-websocket-secure",
|
|
68
|
+
"server-static"
|
|
69
|
+
];
|
|
70
|
+
const readServer = (name) => {
|
|
71
|
+
const svc = module.get(name);
|
|
72
|
+
if (!svc) return null;
|
|
73
|
+
return {
|
|
74
|
+
service: name,
|
|
75
|
+
type: svc.type,
|
|
76
|
+
scheme: svc.scheme,
|
|
77
|
+
protocol: svc.protocol,
|
|
78
|
+
address: svc.address,
|
|
79
|
+
port: svc.port,
|
|
80
|
+
family: svc.family ?? null,
|
|
81
|
+
ready: svc.ready ?? false
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
const listServers = () => serverServices.map((name) => readServer(name)).filter((s) => s !== null);
|
|
85
|
+
const descriptor = {
|
|
86
|
+
label: "HTTP",
|
|
87
|
+
icon: "network",
|
|
88
|
+
order: 1
|
|
89
|
+
};
|
|
90
|
+
const endpoints = [
|
|
91
|
+
{
|
|
92
|
+
path: "servers",
|
|
93
|
+
summary: "Network servers (http/https/ws/wss/static) with listening state",
|
|
94
|
+
handler: () => listServers()
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
path: "info",
|
|
98
|
+
summary: "HTTP layer summary — ready servers, ports, schemes",
|
|
99
|
+
handler: () => {
|
|
100
|
+
const servers = listServers();
|
|
101
|
+
const ready = servers.filter((s) => s.ready);
|
|
102
|
+
return {
|
|
103
|
+
serversTotal: servers.length,
|
|
104
|
+
serversReady: ready.length,
|
|
105
|
+
ports: [...new Set(ready.map((s) => s.port).filter(Boolean))],
|
|
106
|
+
schemes: [...new Set(ready.map((s) => s.scheme).filter(Boolean))],
|
|
107
|
+
protocols: [...new Set(ready.map((s) => s.protocol).filter(Boolean))]
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
path: "rate-limit/list",
|
|
113
|
+
method: "GET",
|
|
114
|
+
role: "ROLE_NODEFONY_ADMIN",
|
|
115
|
+
summary: "Clés (IP) suivies par le rate-limit général, les plus bruyantes d'abord. Paginé serveur : ?limited&q&limit&offset. `enabled:false` = rate-limit désarmé en config (liste vide, pas une erreur).",
|
|
116
|
+
handler: async (request) => {
|
|
117
|
+
const { limit, offset, q } = pageParams(request.query);
|
|
118
|
+
const store = module.get(HTTP_KERNEL_SERVICE)?.rateLimitStore;
|
|
119
|
+
if (!store) return {
|
|
120
|
+
enabled: false,
|
|
121
|
+
trackedCount: 0,
|
|
122
|
+
rejectedTotal: 0,
|
|
123
|
+
items: [],
|
|
124
|
+
total: 0,
|
|
125
|
+
limit,
|
|
126
|
+
offset
|
|
127
|
+
};
|
|
128
|
+
const page = await store.listPage({
|
|
129
|
+
limit,
|
|
130
|
+
offset,
|
|
131
|
+
...parseFilters(request.query, RATE_LIMIT_FILTERS),
|
|
132
|
+
...q !== void 0 ? { q } : {}
|
|
133
|
+
});
|
|
134
|
+
return {
|
|
135
|
+
enabled: true,
|
|
136
|
+
trackedCount: store.trackedCount,
|
|
137
|
+
rejectedTotal: store.rejectedTotal,
|
|
138
|
+
items: page.items,
|
|
139
|
+
total: page.total,
|
|
140
|
+
limit,
|
|
141
|
+
offset
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
path: "sessions",
|
|
147
|
+
summary: "Session subsystem status + active count (web BFF auth)",
|
|
148
|
+
handler: async () => {
|
|
149
|
+
const svc = module.get("sessions");
|
|
150
|
+
if (!svc) return {
|
|
151
|
+
enabled: false,
|
|
152
|
+
active: 0
|
|
153
|
+
};
|
|
154
|
+
const inner = svc.storage?.inner ?? null;
|
|
155
|
+
const storage = inner?.constructor?.name ?? svc.storage?.constructor?.name ?? "none";
|
|
156
|
+
const driver = svc.options?.store ?? null;
|
|
157
|
+
const revocationHardened = inner !== null;
|
|
158
|
+
return {
|
|
159
|
+
enabled: true,
|
|
160
|
+
strategy: svc.sessionStrategy ?? null,
|
|
161
|
+
activation: "intent",
|
|
162
|
+
name: svc.defaultSessionName ?? null,
|
|
163
|
+
driver,
|
|
164
|
+
storage,
|
|
165
|
+
revocationHardened,
|
|
166
|
+
idleTimeoutS: svc.options?.idleTimeoutS ?? null,
|
|
167
|
+
absoluteTimeoutS: svc.options?.absoluteTimeoutS ?? null,
|
|
168
|
+
savePath: null,
|
|
169
|
+
active: null
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
path: "sessions/list",
|
|
175
|
+
method: "GET",
|
|
176
|
+
role: "ROLE_NODEFONY_ADMIN",
|
|
177
|
+
summary: "Sessions actives (ref/user/ip/ua/dates — jamais l'id de session). Paginé côté serveur : ?user&limit&offset. `total` absent et `nextCursor` présent sur un backend à curseur (Redis).",
|
|
178
|
+
page: {
|
|
179
|
+
sortable: () => {
|
|
180
|
+
const svc = module.get("sessions");
|
|
181
|
+
return svc?.supportsEnumeration() ? svc.sortableFields() : [];
|
|
182
|
+
},
|
|
183
|
+
filters: SESSION_FILTERS
|
|
184
|
+
},
|
|
185
|
+
handler: async (request) => {
|
|
186
|
+
const svc = module.get("sessions");
|
|
187
|
+
if (!svc) return {
|
|
188
|
+
status: 503,
|
|
189
|
+
body: { error: "session service unavailable" }
|
|
190
|
+
};
|
|
191
|
+
if (!svc.supportsEnumeration()) return {
|
|
192
|
+
status: 501,
|
|
193
|
+
body: { error: "session enumeration not supported by storage" }
|
|
194
|
+
};
|
|
195
|
+
const pageQuery = parsePageQuery(request.query, { sortable: svc.sortableFields() });
|
|
196
|
+
const { limit } = pageQuery;
|
|
197
|
+
const offset = pageQuery.offset ?? 0;
|
|
198
|
+
const page = await svc.listSessionsPage({
|
|
199
|
+
limit,
|
|
200
|
+
offset,
|
|
201
|
+
...pageQuery.cursor ? { cursor: pageQuery.cursor } : {},
|
|
202
|
+
...parseFilters(request.query, SESSION_FILTERS),
|
|
203
|
+
...pageQuery.order ? { order: pageQuery.order } : {}
|
|
204
|
+
});
|
|
205
|
+
return {
|
|
206
|
+
items: page.items,
|
|
207
|
+
total: page.total,
|
|
208
|
+
limit,
|
|
209
|
+
offset,
|
|
210
|
+
...page.nextCursor !== void 0 ? { nextCursor: page.nextCursor } : {}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
path: "sessions/stats",
|
|
216
|
+
method: "GET",
|
|
217
|
+
role: "ROLE_NODEFONY_ADMIN",
|
|
218
|
+
summary: "Compteurs des sessions sur la collection ENTIÈRE (total, authentifiées, anonymes, utilisateurs distincts) — mêmes filtres que sessions/list. Un compteur `null` = le backend ne sait pas le calculer (Redis).",
|
|
219
|
+
page: {
|
|
220
|
+
filters: SESSION_STATS_FILTERS,
|
|
221
|
+
facets: SESSION_FACETS
|
|
222
|
+
},
|
|
223
|
+
handler: async (request) => {
|
|
224
|
+
const svc = module.get("sessions");
|
|
225
|
+
if (!svc) return {
|
|
226
|
+
status: 503,
|
|
227
|
+
body: { error: "session service unavailable" }
|
|
228
|
+
};
|
|
229
|
+
if (!svc.supportsEnumeration()) return {
|
|
230
|
+
status: 501,
|
|
231
|
+
body: { error: "session enumeration not supported by storage" }
|
|
232
|
+
};
|
|
233
|
+
parsePageQuery(request.query, {});
|
|
234
|
+
return svc.countSessionFacets(parseFilters(request.query, SESSION_STATS_FILTERS));
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
path: "sessions/{ref}/revoke",
|
|
239
|
+
method: "POST",
|
|
240
|
+
role: "ROLE_NODEFONY_ADMIN",
|
|
241
|
+
summary: "Révoque une session par sa référence publique (sess_…). Audité. 404 si la référence ne correspond à aucune session.",
|
|
242
|
+
handler: async (request) => {
|
|
243
|
+
const svc = module.get("sessions");
|
|
244
|
+
if (!svc) return {
|
|
245
|
+
status: 503,
|
|
246
|
+
body: { error: "session service unavailable" }
|
|
247
|
+
};
|
|
248
|
+
if (!svc.supportsEnumeration()) return {
|
|
249
|
+
status: 501,
|
|
250
|
+
body: { error: "session enumeration not supported by storage" }
|
|
251
|
+
};
|
|
252
|
+
const ref = request.params.ref;
|
|
253
|
+
if (typeof ref !== "string" || ref.length === 0) return {
|
|
254
|
+
status: 404,
|
|
255
|
+
body: { error: "not found" }
|
|
256
|
+
};
|
|
257
|
+
if (!await svc.destroyByRef(ref, adminActor(request.user))) return {
|
|
258
|
+
status: 404,
|
|
259
|
+
body: { error: "not found" }
|
|
260
|
+
};
|
|
261
|
+
return { ok: true };
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
path: "sessions/revoke-user/{identifier}",
|
|
266
|
+
method: "POST",
|
|
267
|
+
role: "ROLE_NODEFONY_ADMIN",
|
|
268
|
+
summary: "Déconnecte TOUTES les sessions d'un utilisateur (logout everywhere). Audité. Renvoie le nombre de sessions détruites.",
|
|
269
|
+
handler: async (request) => {
|
|
270
|
+
const svc = module.get("sessions");
|
|
271
|
+
if (!svc) return {
|
|
272
|
+
status: 503,
|
|
273
|
+
body: { error: "session service unavailable" }
|
|
274
|
+
};
|
|
275
|
+
if (!svc.supportsEnumeration()) return {
|
|
276
|
+
status: 501,
|
|
277
|
+
body: { error: "session enumeration not supported by storage" }
|
|
278
|
+
};
|
|
279
|
+
const identifier = request.params.identifier;
|
|
280
|
+
if (typeof identifier !== "string" || identifier.length === 0) return {
|
|
281
|
+
status: 400,
|
|
282
|
+
body: { error: "identifier required" }
|
|
283
|
+
};
|
|
284
|
+
return {
|
|
285
|
+
ok: true,
|
|
286
|
+
count: await svc.destroyByUser(identifier, adminActor(request.user))
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
path: "sessions/mine",
|
|
292
|
+
method: "GET",
|
|
293
|
+
public: true,
|
|
294
|
+
summary: "MES sessions (self-service) — ref/ip/ua/dates, scopées à l'appelant. Paginé côté serveur : ?limit&offset.",
|
|
295
|
+
page: {
|
|
296
|
+
sortable: () => {
|
|
297
|
+
const svc = module.get("sessions");
|
|
298
|
+
return svc?.supportsEnumeration() ? svc.sortableFields() : [];
|
|
299
|
+
},
|
|
300
|
+
filters: {}
|
|
301
|
+
},
|
|
302
|
+
handler: async (request) => {
|
|
303
|
+
const svc = module.get("sessions");
|
|
304
|
+
if (!svc) return {
|
|
305
|
+
status: 503,
|
|
306
|
+
body: { error: "session service unavailable" }
|
|
307
|
+
};
|
|
308
|
+
if (!svc.supportsEnumeration()) return {
|
|
309
|
+
status: 501,
|
|
310
|
+
body: { error: "session enumeration not supported by storage" }
|
|
311
|
+
};
|
|
312
|
+
const identifier = currentIdentifier(request.user);
|
|
313
|
+
if (!identifier) return {
|
|
314
|
+
status: 401,
|
|
315
|
+
body: { error: "unauthenticated" }
|
|
316
|
+
};
|
|
317
|
+
const ownQuery = parsePageQuery(request.query, { sortable: svc.sortableFields() });
|
|
318
|
+
parseFilters(request.query, {});
|
|
319
|
+
const { limit } = ownQuery;
|
|
320
|
+
const offset = ownQuery.offset ?? 0;
|
|
321
|
+
const page = await svc.listOwnSessionsPage(identifier, {
|
|
322
|
+
limit,
|
|
323
|
+
offset,
|
|
324
|
+
...ownQuery.cursor ? { cursor: ownQuery.cursor } : {},
|
|
325
|
+
...ownQuery.order ? { order: ownQuery.order } : {}
|
|
326
|
+
});
|
|
327
|
+
return {
|
|
328
|
+
items: page.items,
|
|
329
|
+
total: page.total,
|
|
330
|
+
limit,
|
|
331
|
+
offset,
|
|
332
|
+
...page.nextCursor !== void 0 ? { nextCursor: page.nextCursor } : {}
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
path: "sessions/mine/{ref}/revoke",
|
|
338
|
+
method: "POST",
|
|
339
|
+
public: true,
|
|
340
|
+
summary: "Révoque UNE de MES sessions par sa référence (sess_…). 404 si la référence n'est pas une de mes sessions. Audité.",
|
|
341
|
+
handler: async (request) => {
|
|
342
|
+
const svc = module.get("sessions");
|
|
343
|
+
if (!svc) return {
|
|
344
|
+
status: 503,
|
|
345
|
+
body: { error: "session service unavailable" }
|
|
346
|
+
};
|
|
347
|
+
if (!svc.supportsEnumeration()) return {
|
|
348
|
+
status: 501,
|
|
349
|
+
body: { error: "session enumeration not supported by storage" }
|
|
350
|
+
};
|
|
351
|
+
const identifier = currentIdentifier(request.user);
|
|
352
|
+
if (!identifier) return {
|
|
353
|
+
status: 401,
|
|
354
|
+
body: { error: "unauthenticated" }
|
|
355
|
+
};
|
|
356
|
+
const ref = request.params.ref;
|
|
357
|
+
if (typeof ref !== "string" || ref.length === 0) return {
|
|
358
|
+
status: 404,
|
|
359
|
+
body: { error: "not found" }
|
|
360
|
+
};
|
|
361
|
+
if (!await svc.destroyOwnByRef(identifier, ref, identifier)) return {
|
|
362
|
+
status: 404,
|
|
363
|
+
body: { error: "not found" }
|
|
364
|
+
};
|
|
365
|
+
return { ok: true };
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
];
|
|
369
|
+
return {
|
|
370
|
+
adminNamespace: "http",
|
|
371
|
+
adminDescriptor: () => descriptor,
|
|
372
|
+
adminEndpoints: () => endpoints
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
//#endregion
|
|
376
|
+
export { createHttpAdminApi };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
//#region nodefony/service/ProfilerAdminApi.ts
|
|
2
|
+
/**
|
|
3
|
+
* Producteur `IAdminApi` du **profiler** — exposé sous `/nodefony/profiler/api/*`.
|
|
4
|
+
*
|
|
5
|
+
* Namespace dédié (≠ replié dans `http`) car le profiling par requête est un
|
|
6
|
+
* concern transverse : timing par phase, route, user, futur SQL/audit. Il a sa
|
|
7
|
+
* propre entrée Studio et n'est monté qu'en **dev** (le module n'instancie le
|
|
8
|
+
* {@link Profiler} qu'hors prod).
|
|
9
|
+
*
|
|
10
|
+
* Endpoints :
|
|
11
|
+
* - `GET /nodefony/profiler/api/recent` → derniers profils (résumés, récent → ancien)
|
|
12
|
+
* - `GET /nodefony/profiler/api/{id}` → profil complet (phases) d'un requestId
|
|
13
|
+
* - `DELETE /nodefony/profiler/api/recent` → vide le ring buffer
|
|
14
|
+
*
|
|
15
|
+
* La debug bar (toute page, dev) lit `X-Request-Id` de SON appel AJAX puis
|
|
16
|
+
* fetch `/{id}` — corrélation client↔serveur gratuite.
|
|
17
|
+
*
|
|
18
|
+
* @param profiler - l'instance partagée du ring buffer (même que le hook kernel).
|
|
19
|
+
* @returns le contrat admin du profiler, prêt à `registry.register()`.
|
|
20
|
+
*/
|
|
21
|
+
function createProfilerAdminApi(profiler) {
|
|
22
|
+
const descriptor = {
|
|
23
|
+
label: "Profiler",
|
|
24
|
+
icon: "bug",
|
|
25
|
+
order: 9
|
|
26
|
+
};
|
|
27
|
+
const endpoints = [
|
|
28
|
+
{
|
|
29
|
+
path: "recent",
|
|
30
|
+
summary: "Recent request profiles (summaries, newest first)",
|
|
31
|
+
handler: ({ query }) => {
|
|
32
|
+
const raw = query.limit;
|
|
33
|
+
const n = typeof raw === "string" ? parseInt(raw, 10) : NaN;
|
|
34
|
+
const limit = Number.isFinite(n) && n > 0 ? Math.min(n, 200) : 60;
|
|
35
|
+
return {
|
|
36
|
+
count: profiler.size,
|
|
37
|
+
entries: profiler.recent(limit)
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
path: "recent",
|
|
43
|
+
method: "DELETE",
|
|
44
|
+
summary: "Clear the profiler ring buffer",
|
|
45
|
+
handler: () => {
|
|
46
|
+
profiler.clear();
|
|
47
|
+
return { cleared: true };
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
path: "{id}",
|
|
52
|
+
summary: "Full profile (phase timeline) for a requestId",
|
|
53
|
+
handler: ({ params }) => {
|
|
54
|
+
const entry = profiler.get(params.id);
|
|
55
|
+
if (!entry) return {
|
|
56
|
+
status: 404,
|
|
57
|
+
body: {
|
|
58
|
+
error: "Profile not found",
|
|
59
|
+
requestId: params.id
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
return entry;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
];
|
|
66
|
+
return {
|
|
67
|
+
adminNamespace: "profiler",
|
|
68
|
+
adminDescriptor: () => descriptor,
|
|
69
|
+
adminEndpoints: () => endpoints
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
73
|
+
export { createProfilerAdminApi, createProfilerAdminApi as default };
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { RequestContext } from "nodefony";
|
|
2
|
+
import { performance } from "node:perf_hooks";
|
|
3
|
+
//#region nodefony/service/audit-logger.ts
|
|
4
|
+
/**
|
|
5
|
+
* Severity derived from HTTP status code — RFC 9110 categories.
|
|
6
|
+
* 1xx/2xx/3xx → INFO ; 4xx → WARNING ; 5xx → ERROR.
|
|
7
|
+
* Unknown/missing status → INFO.
|
|
8
|
+
*/
|
|
9
|
+
function severityFromStatus(status) {
|
|
10
|
+
if (!status) return "INFO";
|
|
11
|
+
if (status >= 500) return "ERROR";
|
|
12
|
+
if (status >= 400) return "WARNING";
|
|
13
|
+
return "INFO";
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* JSON audit logger — implements IRequestLogger so it slots into
|
|
17
|
+
* `httpKernel.setRequestLogger(new JsonAuditLogger())`.
|
|
18
|
+
*
|
|
19
|
+
* Stateless singleton. Allocates one plain object + one JSON.stringify per
|
|
20
|
+
* request — acceptable since this is the terminal log path (1 per req).
|
|
21
|
+
*/
|
|
22
|
+
var JsonAuditLogger = class {
|
|
23
|
+
includeStack;
|
|
24
|
+
maxCauseDepth;
|
|
25
|
+
/** Sampling divisor for 2xx/3xx logs (`1` = log all). Always ≥ 1. */
|
|
26
|
+
sampleRate;
|
|
27
|
+
/** Deterministic 0-based counter for `1/sampleRate` selection (no RNG). */
|
|
28
|
+
sampleCounter = 0;
|
|
29
|
+
/** T1 — `false` = audit nominal coupé (erreurs/4xx/5xx toujours audités). */
|
|
30
|
+
nominalEnabled;
|
|
31
|
+
constructor(opts = {}) {
|
|
32
|
+
this.includeStack = opts.includeStack ?? process.env.NODE_ENV !== "production";
|
|
33
|
+
this.maxCauseDepth = opts.maxCauseDepth ?? 5;
|
|
34
|
+
const rate = opts.sampleRate ?? 1;
|
|
35
|
+
this.sampleRate = Number.isFinite(rate) && rate >= 1 ? Math.floor(rate) : 1;
|
|
36
|
+
this.nominalEnabled = opts.nominal ?? true;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Decide whether the current HTTP request must be logged (audit sampling).
|
|
40
|
+
*
|
|
41
|
+
* Always `true` when `sampleRate <= 1`, on errors, and for `status >= 400`
|
|
42
|
+
* (failures are never sampled out). Otherwise selects 1 in `sampleRate` of
|
|
43
|
+
* the 2xx/3xx requests with a deterministic counter.
|
|
44
|
+
*
|
|
45
|
+
* Called by `Context.logRequest()` **before** `renderHttp`, so a sampled-out
|
|
46
|
+
* request allocates nothing and runs no `JSON.stringify`.
|
|
47
|
+
*
|
|
48
|
+
* @param context - the HTTP context being finalised
|
|
49
|
+
* @param error - error captured for this request, if any
|
|
50
|
+
* @returns `true` to render+log the entry, `false` to skip it
|
|
51
|
+
*/
|
|
52
|
+
shouldSample(context, error) {
|
|
53
|
+
const ctx = context;
|
|
54
|
+
if (!this.nominalEnabled) {
|
|
55
|
+
if (error ?? ctx.error) return true;
|
|
56
|
+
const status = ctx.response?.statusCode ?? null;
|
|
57
|
+
return status !== null && status >= 400;
|
|
58
|
+
}
|
|
59
|
+
if (this.sampleRate <= 1) return true;
|
|
60
|
+
if (error ?? ctx.error) return true;
|
|
61
|
+
const status = ctx.response?.statusCode ?? null;
|
|
62
|
+
if (status !== null && status >= 400) return true;
|
|
63
|
+
this.sampleCounter = (this.sampleCounter + 1) % this.sampleRate;
|
|
64
|
+
return this.sampleCounter === 0;
|
|
65
|
+
}
|
|
66
|
+
renderHttp(context, error) {
|
|
67
|
+
const ctx = context;
|
|
68
|
+
const status = ctx.response?.statusCode ?? null;
|
|
69
|
+
const headers = ctx.request?.headers ?? {};
|
|
70
|
+
const err = error ?? ctx.error ?? null;
|
|
71
|
+
const entry = {
|
|
72
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
73
|
+
requestId: ctx.requestId,
|
|
74
|
+
userId: RequestContext.getUserId() ?? null,
|
|
75
|
+
type: "http",
|
|
76
|
+
scheme: ctx.scheme,
|
|
77
|
+
method: ctx.method,
|
|
78
|
+
url: ctx.url,
|
|
79
|
+
status,
|
|
80
|
+
durationMs: this.computeDurationMs(ctx.phases),
|
|
81
|
+
remoteAddress: ctx.remoteAddress ?? null,
|
|
82
|
+
host: ctx.getHost?.() ?? null,
|
|
83
|
+
userAgent: ctx.getUserAgent?.() ?? null,
|
|
84
|
+
hasAuthorization: Boolean(headers["authorization"]),
|
|
85
|
+
hasCookie: Boolean(headers["cookie"]),
|
|
86
|
+
phases: ctx.phases.length ? ctx.phases.map((p) => ({
|
|
87
|
+
name: p.name,
|
|
88
|
+
durationMs: p.durationMs ?? null
|
|
89
|
+
})) : void 0,
|
|
90
|
+
error: err ? this.serializeError(err, 0) : void 0
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
text: JSON.stringify(entry),
|
|
94
|
+
severity: severityFromStatus(status ?? (err ? 500 : null)),
|
|
95
|
+
msgid: "audit"
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
renderWebsocket(context, error, acceptedProtocol) {
|
|
99
|
+
const ctx = context;
|
|
100
|
+
const status = ctx.response?.statusCode ?? null;
|
|
101
|
+
const headers = ctx.request?.headers ?? {};
|
|
102
|
+
const entry = {
|
|
103
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
104
|
+
requestId: ctx.requestId,
|
|
105
|
+
userId: RequestContext.getUserId() ?? null,
|
|
106
|
+
type: "ws",
|
|
107
|
+
scheme: ctx.scheme,
|
|
108
|
+
method: ctx.method,
|
|
109
|
+
url: ctx.url,
|
|
110
|
+
status,
|
|
111
|
+
durationMs: this.computeDurationMs(ctx.phases),
|
|
112
|
+
remoteAddress: ctx.remoteAddress ?? null,
|
|
113
|
+
host: ctx.getHost?.() ?? null,
|
|
114
|
+
userAgent: ctx.getUserAgent?.() ?? null,
|
|
115
|
+
hasAuthorization: Boolean(headers["authorization"]),
|
|
116
|
+
hasCookie: Boolean(headers["cookie"]),
|
|
117
|
+
phases: ctx.phases.length ? ctx.phases.map((p) => ({
|
|
118
|
+
name: p.name,
|
|
119
|
+
durationMs: p.durationMs ?? null
|
|
120
|
+
})) : void 0,
|
|
121
|
+
protocol: acceptedProtocol ?? null,
|
|
122
|
+
error: error ? this.serializeError(error, 0) : void 0
|
|
123
|
+
};
|
|
124
|
+
return {
|
|
125
|
+
text: JSON.stringify(entry),
|
|
126
|
+
severity: error ? "ERROR" : "INFO",
|
|
127
|
+
msgid: "audit"
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Total request duration computed from the first phase startMs to now.
|
|
132
|
+
* Returns null if timing is disabled (no phases recorded).
|
|
133
|
+
*/
|
|
134
|
+
computeDurationMs(phases) {
|
|
135
|
+
if (!phases.length) return null;
|
|
136
|
+
const first = phases[0];
|
|
137
|
+
if (typeof first.startMs !== "number") return null;
|
|
138
|
+
return performance.now() - first.startMs;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Serialise an Error (recursively for `cause` chain) into an AuditErrorEntry.
|
|
142
|
+
* Stack is included only when `includeStack === true` (dev default).
|
|
143
|
+
* Cause chain is capped at `maxCauseDepth`.
|
|
144
|
+
*/
|
|
145
|
+
serializeError(err, depth) {
|
|
146
|
+
const e = err;
|
|
147
|
+
const entry = {
|
|
148
|
+
name: e.name ?? "Error",
|
|
149
|
+
message: e.message ?? String(err)
|
|
150
|
+
};
|
|
151
|
+
if (typeof e.code === "number") entry.code = e.code;
|
|
152
|
+
if (typeof e.errorType === "string") entry.errorType = e.errorType;
|
|
153
|
+
if (this.includeStack && typeof e.stack === "string") entry.stack = e.stack;
|
|
154
|
+
if (e.cause && depth + 1 < this.maxCauseDepth) entry.cause = this.serializeError(e.cause, depth + 1);
|
|
155
|
+
return entry;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
//#endregion
|
|
159
|
+
export { JsonAuditLogger as default, severityFromStatus };
|