@ninenity/neutrun 1.2.0 → 1.9.0
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/dist/Shared/Environment.d.ts +10 -0
- package/dist/Shared/Environment.d.ts.map +1 -0
- package/dist/Shared/Environment.js +18 -0
- package/dist/Shared/Environment.js.map +1 -0
- package/dist/Shared/ExpressApi.d.ts +48 -1
- package/dist/Shared/ExpressApi.d.ts.map +1 -1
- package/dist/Shared/ExpressApi.js +361 -28
- package/dist/Shared/ExpressApi.js.map +1 -1
- package/dist/Shared/NinenityApi.d.ts +54 -1
- package/dist/Shared/NinenityApi.d.ts.map +1 -1
- package/dist/Shared/NinenityApi.js +421 -18
- package/dist/Shared/NinenityApi.js.map +1 -1
- package/dist/Shared/Observability.d.ts +59 -0
- package/dist/Shared/Observability.d.ts.map +1 -0
- package/dist/Shared/Observability.js +184 -0
- package/dist/Shared/Observability.js.map +1 -0
- package/dist/Shared/TelemetryService.d.ts +21 -0
- package/dist/Shared/TelemetryService.d.ts.map +1 -1
- package/dist/Shared/TelemetryService.js +94 -1
- package/dist/Shared/TelemetryService.js.map +1 -1
- package/dist/Shared/WebSocket.d.ts.map +1 -1
- package/dist/Shared/WebSocket.js +39 -9
- package/dist/Shared/WebSocket.js.map +1 -1
- package/dist/api.d.ts +2 -2
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +4 -1
- package/dist/api.js.map +1 -1
- package/dist/express.d.ts +2 -0
- package/dist/express.d.ts.map +1 -1
- package/dist/express.js +4 -1
- package/dist/express.js.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/package.json +11 -4
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type BotEnvironment = 'production' | 'development';
|
|
2
|
+
/**
|
|
3
|
+
* Resolve o ambiente de execução do bot.
|
|
4
|
+
*
|
|
5
|
+
* Desenvolvimento só é ativado explicitamente. Isso evita que processos
|
|
6
|
+
* antigos, testes ou variáveis inválidas sejam classificados como DEV e
|
|
7
|
+
* contaminem métricas e logs de produção.
|
|
8
|
+
*/
|
|
9
|
+
export declare const getBotEnvironment: (value?: unknown) => BotEnvironment;
|
|
10
|
+
//# sourceMappingURL=Environment.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Environment.d.ts","sourceRoot":"","sources":["../../src/Shared/Environment.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,cAAc,GAAG,YAAY,GAAG,aAAa,CAAA;AAEzD;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,GAAI,QAAQ,OAAO,KAAG,cAQnD,CAAA"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getBotEnvironment = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Resolve o ambiente de execução do bot.
|
|
6
|
+
*
|
|
7
|
+
* Desenvolvimento só é ativado explicitamente. Isso evita que processos
|
|
8
|
+
* antigos, testes ou variáveis inválidas sejam classificados como DEV e
|
|
9
|
+
* contaminem métricas e logs de produção.
|
|
10
|
+
*/
|
|
11
|
+
const getBotEnvironment = (value) => {
|
|
12
|
+
const raw = String(value ?? process.env.BOT_ENV ?? process.env.API_ENV ?? process.env.NODE_ENV ?? '')
|
|
13
|
+
.trim()
|
|
14
|
+
.toLowerCase();
|
|
15
|
+
return raw === 'development' || raw === 'dev' ? 'development' : 'production';
|
|
16
|
+
};
|
|
17
|
+
exports.getBotEnvironment = getBotEnvironment;
|
|
18
|
+
//# sourceMappingURL=Environment.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Environment.js","sourceRoot":"","sources":["../../src/Shared/Environment.ts"],"names":[],"mappings":";;;AAEA;;;;;;GAMG;AACI,MAAM,iBAAiB,GAAG,CAAC,KAAe,EAAkB,EAAE;IACnE,MAAM,GAAG,GAAG,MAAM,CAChB,KAAK,IAAI,OAAO,CAAC,GAAG,CAAC,OAAO,IAAI,OAAO,CAAC,GAAG,CAAC,OAAO,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,IAAI,EAAE,CAClF;SACE,IAAI,EAAE;SACN,WAAW,EAAE,CAAA;IAEhB,OAAO,GAAG,KAAK,aAAa,IAAI,GAAG,KAAK,KAAK,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,YAAY,CAAA;AAC9E,CAAC,CAAA;AARY,QAAA,iBAAiB,qBAQ7B"}
|
|
@@ -1,19 +1,63 @@
|
|
|
1
|
-
import { type Application } from 'express';
|
|
1
|
+
import { type Application, type Request, type RequestHandler, type Response as ExpressResponse } from 'express';
|
|
2
2
|
export type ApiLifecycleMode = 'always' | 'idle' | 'suspend-resources';
|
|
3
3
|
export interface ApiRateLimitOptions {
|
|
4
4
|
windowMs?: number;
|
|
5
5
|
max?: number;
|
|
6
6
|
}
|
|
7
|
+
export interface ApiCredential {
|
|
8
|
+
token: string;
|
|
9
|
+
id?: string;
|
|
10
|
+
roles?: string[];
|
|
11
|
+
scopes?: string[];
|
|
12
|
+
}
|
|
13
|
+
export interface ApiIdentity {
|
|
14
|
+
authentication: 'none' | 'bearer' | 'api-key';
|
|
15
|
+
credentialId?: string;
|
|
16
|
+
roles: string[];
|
|
17
|
+
scopes: string[];
|
|
18
|
+
}
|
|
19
|
+
export interface ApiAuditEvent {
|
|
20
|
+
type: 'request' | 'authentication' | 'authorization' | 'rate-limit' | 'validation';
|
|
21
|
+
at: string;
|
|
22
|
+
requestId: string;
|
|
23
|
+
method: string;
|
|
24
|
+
path: string;
|
|
25
|
+
outcome: 'allowed' | 'denied' | 'success' | 'error';
|
|
26
|
+
status?: number;
|
|
27
|
+
credentialId?: string;
|
|
28
|
+
durationMs?: number;
|
|
29
|
+
reason?: string;
|
|
30
|
+
}
|
|
31
|
+
export interface ApiProtectionOptions {
|
|
32
|
+
roles?: string[];
|
|
33
|
+
scopes?: string[];
|
|
34
|
+
authorize?: (request: Request, identity: ApiIdentity) => boolean | Promise<boolean>;
|
|
35
|
+
}
|
|
36
|
+
export type ApiValidator = (value: unknown, request: Request) => boolean | string | void | Promise<boolean | string | void>;
|
|
37
|
+
export interface ApiValidationOptions {
|
|
38
|
+
body?: ApiValidator;
|
|
39
|
+
query?: ApiValidator;
|
|
40
|
+
params?: ApiValidator;
|
|
41
|
+
}
|
|
7
42
|
export interface ApiSecurityOptions {
|
|
8
43
|
authentication?: 'none' | 'bearer' | 'api-key';
|
|
9
44
|
token?: string;
|
|
10
45
|
tokenEnv?: string;
|
|
46
|
+
tokens?: string[];
|
|
47
|
+
tokensEnv?: string;
|
|
48
|
+
credentials?: ApiCredential[];
|
|
49
|
+
allowInsecurePublic?: boolean;
|
|
11
50
|
publicPaths?: string[];
|
|
12
51
|
cors?: false | '*' | string[];
|
|
13
52
|
bodyLimit?: string | number;
|
|
14
53
|
rateLimit?: false | ApiRateLimitOptions;
|
|
15
54
|
requestTimeoutMs?: number;
|
|
16
55
|
trustProxy?: boolean | number | string;
|
|
56
|
+
maxHeaderBytes?: number;
|
|
57
|
+
maxQueryParameters?: number;
|
|
58
|
+
maxJsonDepth?: number;
|
|
59
|
+
maxJsonKeys?: number;
|
|
60
|
+
audit?: (event: ApiAuditEvent) => Promise<void> | void;
|
|
17
61
|
}
|
|
18
62
|
export interface ApiLifecycleOptions {
|
|
19
63
|
mode?: ApiLifecycleMode;
|
|
@@ -59,6 +103,9 @@ export interface ExpressApi extends Application {
|
|
|
59
103
|
stop(): Promise<void>;
|
|
60
104
|
fetch(path: string, options?: ApiRequestOptions): Promise<globalThis.Response>;
|
|
61
105
|
fetchJson<T = unknown>(path: string, options?: ApiRequestOptions): Promise<ApiFetchResult<T>>;
|
|
106
|
+
protect(options?: ApiProtectionOptions): RequestHandler;
|
|
107
|
+
validate(options: ApiValidationOptions): RequestHandler;
|
|
108
|
+
getIdentity(response: ExpressResponse): ApiIdentity | undefined;
|
|
62
109
|
readonly isRunning: boolean;
|
|
63
110
|
readonly isSuspended: boolean;
|
|
64
111
|
readonly url: string | null;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ExpressApi.d.ts","sourceRoot":"","sources":["../../src/Shared/ExpressApi.ts"],"names":[],"mappings":"AAAA,OAAgB,EACd,KAAK,WAAW,
|
|
1
|
+
{"version":3,"file":"ExpressApi.d.ts","sourceRoot":"","sources":["../../src/Shared/ExpressApi.ts"],"names":[],"mappings":"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"}
|
|
@@ -10,6 +10,7 @@ const node_crypto_1 = require("node:crypto");
|
|
|
10
10
|
const ConsoleLog_1 = require("./ConsoleLog");
|
|
11
11
|
const ProcessManager_1 = __importDefault(require("./ProcessManager"));
|
|
12
12
|
const TelemetryService_1 = require("./TelemetryService");
|
|
13
|
+
const Observability_1 = __importDefault(require("./Observability"));
|
|
13
14
|
class ApiRequestError extends Error {
|
|
14
15
|
status;
|
|
15
16
|
data;
|
|
@@ -32,6 +33,11 @@ const DEFAULT_RATE_LIMIT_MAX = 60;
|
|
|
32
33
|
const MAX_RATE_LIMIT_ENTRIES = 10_000;
|
|
33
34
|
const SERVER_CLOSE_TIMEOUT_MS = 5_000;
|
|
34
35
|
const MAX_INTERNAL_RESPONSE_BYTES = 2 * 1024 * 1024;
|
|
36
|
+
const DEFAULT_MAX_HEADER_BYTES = 16 * 1024;
|
|
37
|
+
const DEFAULT_MAX_QUERY_PARAMETERS = 100;
|
|
38
|
+
const DEFAULT_MAX_JSON_DEPTH = 16;
|
|
39
|
+
const DEFAULT_MAX_JSON_KEYS = 1_000;
|
|
40
|
+
const REQUEST_ID_PATTERN = /^[a-zA-Z0-9._:-]{8,100}$/;
|
|
35
41
|
class ExpressApiService {
|
|
36
42
|
app;
|
|
37
43
|
settings = normalizeSettings(undefined);
|
|
@@ -59,6 +65,9 @@ class ExpressApiService {
|
|
|
59
65
|
this.app.stop = () => this.stop();
|
|
60
66
|
this.app.fetch = (path, options) => this.fetch(path, options);
|
|
61
67
|
this.app.fetchJson = (path, options) => this.fetchJson(path, options);
|
|
68
|
+
this.app.protect = (options) => this.protectionMiddleware(options);
|
|
69
|
+
this.app.validate = (options) => this.validationMiddleware(options);
|
|
70
|
+
this.app.getIdentity = (response) => response.locals.neutrunIdentity;
|
|
62
71
|
Object.defineProperties(this.app, {
|
|
63
72
|
isRunning: {
|
|
64
73
|
enumerable: true,
|
|
@@ -131,7 +140,9 @@ class ExpressApiService {
|
|
|
131
140
|
validateSettings(this.settings);
|
|
132
141
|
this.finalize();
|
|
133
142
|
this.startPromise = new Promise((resolve, reject) => {
|
|
134
|
-
const server = (0, node_http_1.createServer)(
|
|
143
|
+
const server = (0, node_http_1.createServer)({
|
|
144
|
+
maxHeaderSize: clampInteger(this.settings.security.maxHeaderBytes, 8 * 1024, 64 * 1024, DEFAULT_MAX_HEADER_BYTES)
|
|
145
|
+
}, this.app);
|
|
135
146
|
const requestTimeout = this.settings.security.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
136
147
|
server.requestTimeout = requestTimeout;
|
|
137
148
|
server.headersTimeout = Math.max(requestTimeout + 1_000, 5_000);
|
|
@@ -197,7 +208,7 @@ class ExpressApiService {
|
|
|
197
208
|
await this.ensureStarted();
|
|
198
209
|
const url = this.resolveUrl(path);
|
|
199
210
|
const headers = new Headers(options.headers);
|
|
200
|
-
const token = this.
|
|
211
|
+
const token = this.resolveCredentials()[0]?.token ?? '';
|
|
201
212
|
const authentication = this.settings.security.authentication;
|
|
202
213
|
if (token && authentication !== 'none' && !headers.has('authorization') && !headers.has('x-api-key')) {
|
|
203
214
|
if (authentication === 'api-key')
|
|
@@ -205,6 +216,9 @@ class ExpressApiService {
|
|
|
205
216
|
else
|
|
206
217
|
headers.set('authorization', `Bearer ${token}`);
|
|
207
218
|
}
|
|
219
|
+
if (!headers.has('x-request-id')) {
|
|
220
|
+
headers.set('x-request-id', Observability_1.default.getCorrelationId() ?? (0, node_crypto_1.randomUUID)());
|
|
221
|
+
}
|
|
208
222
|
const body = serializeBody(options.body, headers);
|
|
209
223
|
const { query, ...init } = options;
|
|
210
224
|
const requestInit = {
|
|
@@ -225,9 +239,11 @@ class ExpressApiService {
|
|
|
225
239
|
}
|
|
226
240
|
installBuiltInMiddleware() {
|
|
227
241
|
this.middlewareInstalled = true;
|
|
242
|
+
this.app.use(this.requestContextMiddleware());
|
|
228
243
|
this.app.use(this.securityHeaders());
|
|
229
244
|
this.app.use(this.corsMiddleware());
|
|
230
245
|
this.app.use(this.requestTrackingMiddleware());
|
|
246
|
+
this.app.use(this.requestShapeLimitsMiddleware());
|
|
231
247
|
this.app.use(this.requestTimeoutMiddleware());
|
|
232
248
|
this.app.use(this.rateLimitMiddleware());
|
|
233
249
|
this.app.use(this.authenticationMiddleware());
|
|
@@ -237,6 +253,16 @@ class ExpressApiService {
|
|
|
237
253
|
extended: true,
|
|
238
254
|
limit: this.settings.security.bodyLimit ?? '1mb'
|
|
239
255
|
}));
|
|
256
|
+
this.app.use(this.jsonComplexityMiddleware());
|
|
257
|
+
}
|
|
258
|
+
requestContextMiddleware() {
|
|
259
|
+
return (req, res, next) => {
|
|
260
|
+
const incoming = req.get('x-request-id')?.trim() || '';
|
|
261
|
+
const requestId = REQUEST_ID_PATTERN.test(incoming) ? incoming : (0, node_crypto_1.randomUUID)();
|
|
262
|
+
res.locals.neutrunRequestId = requestId;
|
|
263
|
+
res.setHeader('X-Request-Id', requestId);
|
|
264
|
+
Observability_1.default.run({ correlationId: requestId, requestId, source: 'express-api' }, () => next());
|
|
265
|
+
};
|
|
240
266
|
}
|
|
241
267
|
securityHeaders() {
|
|
242
268
|
return (_req, res, next) => {
|
|
@@ -244,6 +270,9 @@ class ExpressApiService {
|
|
|
244
270
|
res.setHeader('X-Frame-Options', 'DENY');
|
|
245
271
|
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
246
272
|
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
|
|
273
|
+
res.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'; base-uri 'none'");
|
|
274
|
+
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()');
|
|
275
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
247
276
|
next();
|
|
248
277
|
};
|
|
249
278
|
}
|
|
@@ -260,8 +289,8 @@ class ExpressApiService {
|
|
|
260
289
|
: undefined;
|
|
261
290
|
if (allowed) {
|
|
262
291
|
res.setHeader('Access-Control-Allow-Origin', allowed);
|
|
263
|
-
res
|
|
264
|
-
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-API-Key');
|
|
292
|
+
appendVaryHeader(res, 'Origin');
|
|
293
|
+
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-API-Key, X-Request-Id');
|
|
265
294
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
|
266
295
|
}
|
|
267
296
|
if (req.method === 'OPTIONS')
|
|
@@ -271,6 +300,7 @@ class ExpressApiService {
|
|
|
271
300
|
}
|
|
272
301
|
requestTrackingMiddleware() {
|
|
273
302
|
return (req, res, next) => {
|
|
303
|
+
const startedAt = performance.now();
|
|
274
304
|
this.lastActivityAt = Date.now();
|
|
275
305
|
this.activeRequests += 1;
|
|
276
306
|
let completed = false;
|
|
@@ -281,13 +311,49 @@ class ExpressApiService {
|
|
|
281
311
|
this.activeRequests = Math.max(0, this.activeRequests - 1);
|
|
282
312
|
this.lastActivityAt = Date.now();
|
|
283
313
|
this.scheduleIdleCheck();
|
|
284
|
-
this.recordRequest(req, res, res.statusCode);
|
|
314
|
+
this.recordRequest(req, res, res.statusCode, performance.now() - startedAt, !res.writableEnded);
|
|
285
315
|
};
|
|
286
316
|
res.once('finish', complete);
|
|
287
317
|
res.once('close', complete);
|
|
288
318
|
next();
|
|
289
319
|
};
|
|
290
320
|
}
|
|
321
|
+
requestShapeLimitsMiddleware() {
|
|
322
|
+
return (req, res, next) => {
|
|
323
|
+
const maximum = clampInteger(this.settings.security.maxQueryParameters, 1, 1_000, DEFAULT_MAX_QUERY_PARAMETERS);
|
|
324
|
+
if (countObjectKeys(req.query, maximum + 1) > maximum) {
|
|
325
|
+
this.emitAudit(req, res, {
|
|
326
|
+
type: 'validation',
|
|
327
|
+
outcome: 'denied',
|
|
328
|
+
status: 400,
|
|
329
|
+
reason: 'query_limit'
|
|
330
|
+
});
|
|
331
|
+
return res.status(400).json({ success: false, error: 'Too many query parameters' });
|
|
332
|
+
}
|
|
333
|
+
next();
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
jsonComplexityMiddleware() {
|
|
337
|
+
return (req, res, next) => {
|
|
338
|
+
if (req.body === undefined || req.body === null || typeof req.body !== 'object')
|
|
339
|
+
return next();
|
|
340
|
+
const maxDepth = clampInteger(this.settings.security.maxJsonDepth, 1, 100, DEFAULT_MAX_JSON_DEPTH);
|
|
341
|
+
const maxKeys = clampInteger(this.settings.security.maxJsonKeys, 1, 100_000, DEFAULT_MAX_JSON_KEYS);
|
|
342
|
+
const complexity = inspectObjectComplexity(req.body, maxDepth, maxKeys);
|
|
343
|
+
if (complexity.valid)
|
|
344
|
+
return next();
|
|
345
|
+
this.emitAudit(req, res, {
|
|
346
|
+
type: 'validation',
|
|
347
|
+
outcome: 'denied',
|
|
348
|
+
status: 400,
|
|
349
|
+
reason: complexity.reason
|
|
350
|
+
});
|
|
351
|
+
return res.status(400).json({
|
|
352
|
+
success: false,
|
|
353
|
+
error: 'Request body is too complex'
|
|
354
|
+
});
|
|
355
|
+
};
|
|
356
|
+
}
|
|
291
357
|
requestTimeoutMiddleware() {
|
|
292
358
|
return (_req, res, next) => {
|
|
293
359
|
const timeoutMs = this.settings.security.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
@@ -320,11 +386,21 @@ class ExpressApiService {
|
|
|
320
386
|
const current = this.rateLimitEntries.get(key);
|
|
321
387
|
if (!current || now - current.startedAt >= windowMs) {
|
|
322
388
|
this.rateLimitEntries.set(key, { startedAt: now, count: 1 });
|
|
389
|
+
setRateLimitHeaders(res, max, max - 1, windowMs);
|
|
323
390
|
return next();
|
|
324
391
|
}
|
|
325
|
-
current.count
|
|
392
|
+
current.count = Math.min(Number.MAX_SAFE_INTEGER, current.count + 1);
|
|
393
|
+
const remaining = Math.max(0, max - current.count);
|
|
394
|
+
const resetMs = Math.max(0, windowMs - (now - current.startedAt));
|
|
395
|
+
setRateLimitHeaders(res, max, remaining, resetMs);
|
|
326
396
|
if (current.count > max) {
|
|
327
|
-
res.setHeader('Retry-After', Math.ceil(
|
|
397
|
+
res.setHeader('Retry-After', Math.ceil(resetMs / 1_000));
|
|
398
|
+
Observability_1.default.increment('neutrun_api_rate_limit_total');
|
|
399
|
+
this.emitAudit(req, res, {
|
|
400
|
+
type: 'rate-limit',
|
|
401
|
+
outcome: 'denied',
|
|
402
|
+
status: 429
|
|
403
|
+
});
|
|
328
404
|
return res.status(429).json({
|
|
329
405
|
success: false,
|
|
330
406
|
error: 'Too Many Requests'
|
|
@@ -336,27 +412,118 @@ class ExpressApiService {
|
|
|
336
412
|
authenticationMiddleware() {
|
|
337
413
|
return (req, res, next) => {
|
|
338
414
|
const authentication = this.settings.security.authentication;
|
|
339
|
-
|
|
340
|
-
|
|
415
|
+
if (authentication === 'none') {
|
|
416
|
+
res.locals.neutrunIdentity = anonymousIdentity();
|
|
417
|
+
return next();
|
|
418
|
+
}
|
|
419
|
+
if (isPublicPath(req.path, this.settings.security.publicPaths)) {
|
|
420
|
+
res.locals.neutrunIdentity = anonymousIdentity();
|
|
341
421
|
return next();
|
|
342
422
|
}
|
|
343
423
|
const received = authentication === 'api-key'
|
|
344
424
|
? req.get('x-api-key')
|
|
345
425
|
: extractBearerToken(req.get('authorization'));
|
|
346
|
-
|
|
426
|
+
const credential = received
|
|
427
|
+
? this.resolveCredentials().find((entry) => timingSafeEqual(received, entry.token))
|
|
428
|
+
: undefined;
|
|
429
|
+
if (!credential) {
|
|
430
|
+
Observability_1.default.increment('neutrun_api_authentication_total', 1, { outcome: 'denied' });
|
|
431
|
+
this.emitAudit(req, res, {
|
|
432
|
+
type: 'authentication',
|
|
433
|
+
outcome: 'denied',
|
|
434
|
+
status: 401,
|
|
435
|
+
reason: received ? 'invalid_credential' : 'missing_credential'
|
|
436
|
+
});
|
|
347
437
|
return res.status(401).json({
|
|
348
438
|
success: false,
|
|
349
439
|
error: 'Unauthorized'
|
|
350
440
|
});
|
|
351
441
|
}
|
|
442
|
+
const identity = {
|
|
443
|
+
authentication,
|
|
444
|
+
credentialId: credential.id,
|
|
445
|
+
roles: credential.roles,
|
|
446
|
+
scopes: credential.scopes
|
|
447
|
+
};
|
|
448
|
+
res.locals.neutrunIdentity = identity;
|
|
449
|
+
Observability_1.default.increment('neutrun_api_authentication_total', 1, { outcome: 'allowed' });
|
|
450
|
+
this.emitAudit(req, res, {
|
|
451
|
+
type: 'authentication',
|
|
452
|
+
outcome: 'allowed',
|
|
453
|
+
credentialId: credential.id
|
|
454
|
+
});
|
|
352
455
|
next();
|
|
353
456
|
};
|
|
354
457
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
458
|
+
protectionMiddleware(options = {}) {
|
|
459
|
+
return (req, res, next) => {
|
|
460
|
+
const run = async () => {
|
|
461
|
+
const identity = res.locals.neutrunIdentity;
|
|
462
|
+
const roles = new Set(identity?.roles ?? []);
|
|
463
|
+
const scopes = new Set(identity?.scopes ?? []);
|
|
464
|
+
const hasRoles = (options.roles ?? []).every((role) => roles.has(role));
|
|
465
|
+
const hasScopes = (options.scopes ?? []).every((scope) => scopes.has(scope));
|
|
466
|
+
const policyEligible = Boolean(identity && identity.authentication !== 'none' && hasRoles && hasScopes);
|
|
467
|
+
const customAllowed = policyEligible && options.authorize
|
|
468
|
+
? await options.authorize(req, identity)
|
|
469
|
+
: policyEligible;
|
|
470
|
+
if (policyEligible && identity && customAllowed) {
|
|
471
|
+
this.emitAudit(req, res, {
|
|
472
|
+
type: 'authorization',
|
|
473
|
+
outcome: 'allowed',
|
|
474
|
+
credentialId: identity.credentialId
|
|
475
|
+
});
|
|
476
|
+
next();
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
Observability_1.default.increment('neutrun_api_authorization_total', 1, { outcome: 'denied' });
|
|
480
|
+
this.emitAudit(req, res, {
|
|
481
|
+
type: 'authorization',
|
|
482
|
+
outcome: 'denied',
|
|
483
|
+
status: identity?.authentication === 'none' ? 401 : 403,
|
|
484
|
+
credentialId: identity?.credentialId,
|
|
485
|
+
reason: 'policy_denied'
|
|
486
|
+
});
|
|
487
|
+
res.status(identity?.authentication === 'none' ? 401 : 403).json({
|
|
488
|
+
success: false,
|
|
489
|
+
error: identity?.authentication === 'none' ? 'Unauthorized' : 'Forbidden'
|
|
490
|
+
});
|
|
491
|
+
};
|
|
492
|
+
void run().catch(next);
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
validationMiddleware(options) {
|
|
496
|
+
return (req, res, next) => {
|
|
497
|
+
const run = async () => {
|
|
498
|
+
const checks = [
|
|
499
|
+
['body', options.body, req.body],
|
|
500
|
+
['query', options.query, req.query],
|
|
501
|
+
['params', options.params, req.params]
|
|
502
|
+
];
|
|
503
|
+
for (const [part, validator, value] of checks) {
|
|
504
|
+
if (!validator)
|
|
505
|
+
continue;
|
|
506
|
+
const result = await validator(value, req);
|
|
507
|
+
if (result === false || typeof result === 'string') {
|
|
508
|
+
const reason = typeof result === 'string' ? result.slice(0, 200) : `Invalid ${part}`;
|
|
509
|
+
Observability_1.default.increment('neutrun_api_validation_total', 1, { outcome: 'denied', part });
|
|
510
|
+
this.emitAudit(req, res, {
|
|
511
|
+
type: 'validation',
|
|
512
|
+
outcome: 'denied',
|
|
513
|
+
status: 400,
|
|
514
|
+
reason: part
|
|
515
|
+
});
|
|
516
|
+
res.status(400).json({ success: false, error: reason });
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
next();
|
|
521
|
+
};
|
|
522
|
+
void run().catch(next);
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
resolveCredentials() {
|
|
526
|
+
return resolveCredentials(this.settings.security);
|
|
360
527
|
}
|
|
361
528
|
scheduleIdleCheck() {
|
|
362
529
|
this.clearIdleTimer();
|
|
@@ -431,13 +598,42 @@ class ExpressApiService {
|
|
|
431
598
|
this.cleanupRegistered = true;
|
|
432
599
|
ProcessManager_1.default.registerCleanupTask(() => this.stop(), 'mini API Express');
|
|
433
600
|
}
|
|
434
|
-
recordRequest(req, res, status, error = false) {
|
|
435
|
-
const path = req.path
|
|
601
|
+
recordRequest(req, res, status, durationMs, error = false) {
|
|
602
|
+
const path = normalizeTelemetryPath(req.route?.path ?? req.path ?? '/');
|
|
436
603
|
TelemetryService_1.Telemetry.registerCounter('apiRequests');
|
|
437
604
|
TelemetryService_1.Telemetry.registerEvent(`api:${req.method}:${path}`.slice(0, 120), {
|
|
438
|
-
error: error || status >= 400
|
|
605
|
+
error: error || status >= 400,
|
|
606
|
+
durationMs
|
|
607
|
+
});
|
|
608
|
+
Observability_1.default.increment('neutrun_api_requests_total', 1, {
|
|
609
|
+
method: req.method,
|
|
610
|
+
outcome: status >= 400 || error ? 'error' : 'success',
|
|
611
|
+
status: `${Math.floor(status / 100)}xx`
|
|
612
|
+
});
|
|
613
|
+
Observability_1.default.observe('neutrun_api_request_duration_ms', durationMs, {
|
|
614
|
+
method: req.method
|
|
615
|
+
});
|
|
616
|
+
this.emitAudit(req, res, {
|
|
617
|
+
type: 'request',
|
|
618
|
+
outcome: status >= 400 || error ? 'error' : 'success',
|
|
619
|
+
status,
|
|
620
|
+
durationMs
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
emitAudit(req, res, event) {
|
|
624
|
+
const audit = this.settings.security.audit;
|
|
625
|
+
if (!audit)
|
|
626
|
+
return;
|
|
627
|
+
const payload = {
|
|
628
|
+
...event,
|
|
629
|
+
at: new Date().toISOString(),
|
|
630
|
+
requestId: String(res.locals.neutrunRequestId || (0, node_crypto_1.randomUUID)()),
|
|
631
|
+
method: req.method.slice(0, 16),
|
|
632
|
+
path: normalizeTelemetryPath(req.route?.path ?? req.path ?? '/')
|
|
633
|
+
};
|
|
634
|
+
void Promise.resolve(audit(payload)).catch((error) => {
|
|
635
|
+
(0, ConsoleLog_1.logWarn)(`Falha no destino de auditoria da mini API: ${error instanceof Error ? error.message : error}`, 'Api');
|
|
439
636
|
});
|
|
440
|
-
void res;
|
|
441
637
|
}
|
|
442
638
|
resolveUrl(path) {
|
|
443
639
|
const base = this.url;
|
|
@@ -511,32 +707,169 @@ function normalizeSettings(options) {
|
|
|
511
707
|
onWake: lifecycle.onWake ?? serverlessOptions.onWake
|
|
512
708
|
},
|
|
513
709
|
security: {
|
|
514
|
-
authentication: api.security?.authentication ?? (
|
|
710
|
+
authentication: api.security?.authentication ?? (resolveCredentials(api.security).length > 0 || isPublicHost(host) ? 'bearer' : 'none'),
|
|
515
711
|
token: api.security?.token,
|
|
516
712
|
tokenEnv: api.security?.tokenEnv ?? 'NEUTRUN_API_TOKEN',
|
|
713
|
+
tokens: api.security?.tokens,
|
|
714
|
+
tokensEnv: api.security?.tokensEnv ?? 'NEUTRUN_API_TOKENS',
|
|
715
|
+
credentials: api.security?.credentials,
|
|
716
|
+
allowInsecurePublic: api.security?.allowInsecurePublic,
|
|
517
717
|
publicPaths: api.security?.publicPaths ?? [],
|
|
518
718
|
cors: api.security?.cors,
|
|
519
719
|
bodyLimit: api.security?.bodyLimit,
|
|
520
720
|
rateLimit: api.security?.rateLimit,
|
|
521
721
|
requestTimeoutMs: api.security?.requestTimeoutMs,
|
|
522
|
-
trustProxy: api.security?.trustProxy
|
|
722
|
+
trustProxy: api.security?.trustProxy,
|
|
723
|
+
maxHeaderBytes: api.security?.maxHeaderBytes,
|
|
724
|
+
maxQueryParameters: api.security?.maxQueryParameters,
|
|
725
|
+
maxJsonDepth: api.security?.maxJsonDepth,
|
|
726
|
+
maxJsonKeys: api.security?.maxJsonKeys,
|
|
727
|
+
audit: api.security?.audit
|
|
523
728
|
}
|
|
524
729
|
};
|
|
525
730
|
}
|
|
526
731
|
function validateSettings(settings) {
|
|
527
|
-
const
|
|
528
|
-
if (settings.security.authentication !== 'none' &&
|
|
732
|
+
const credentials = resolveCredentials(settings.security);
|
|
733
|
+
if (settings.security.authentication !== 'none' && credentials.length === 0) {
|
|
529
734
|
throw new Error('A mini API autenticada precisa de security.token ou NEUTRUN_API_TOKEN configurado.');
|
|
530
735
|
}
|
|
736
|
+
if (settings.security.authentication === 'none' &&
|
|
737
|
+
isPublicHost(settings.host) &&
|
|
738
|
+
settings.security.allowInsecurePublic !== true) {
|
|
739
|
+
throw new Error('A mini API pública não pode desativar autenticação sem security.allowInsecurePublic=true.');
|
|
740
|
+
}
|
|
741
|
+
if (settings.security.cors === '*' &&
|
|
742
|
+
settings.security.authentication !== 'none') {
|
|
743
|
+
throw new Error('A mini API autenticada exige uma lista explícita de origens CORS.');
|
|
744
|
+
}
|
|
745
|
+
if (isPublicHost(settings.host) &&
|
|
746
|
+
credentials.some((credential) => Buffer.byteLength(credential.token, 'utf8') < 16)) {
|
|
747
|
+
throw new Error('Credenciais da mini API precisam ter pelo menos 16 bytes.');
|
|
748
|
+
}
|
|
531
749
|
}
|
|
532
750
|
function isPublicHost(host) {
|
|
533
751
|
return !['127.0.0.1', 'localhost', '::1'].includes(host);
|
|
534
752
|
}
|
|
535
|
-
function
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
753
|
+
function resolveCredentials(security) {
|
|
754
|
+
const values = [];
|
|
755
|
+
for (const credential of security?.credentials ?? [])
|
|
756
|
+
values.push(credential);
|
|
757
|
+
for (const token of security?.tokens ?? [])
|
|
758
|
+
values.push({ token });
|
|
759
|
+
const tokensEnvironment = process.env[security?.tokensEnv ?? 'NEUTRUN_API_TOKENS'];
|
|
760
|
+
for (const token of tokensEnvironment?.split(/[\r\n,]+/) ?? [])
|
|
761
|
+
values.push({ token });
|
|
762
|
+
const directToken = security?.token?.trim();
|
|
763
|
+
if (directToken)
|
|
764
|
+
values.push({ token: directToken });
|
|
765
|
+
const environmentToken = process.env[security?.tokenEnv ?? 'NEUTRUN_API_TOKEN']?.trim();
|
|
766
|
+
if (environmentToken)
|
|
767
|
+
values.push({ token: environmentToken });
|
|
768
|
+
const seen = new Set();
|
|
769
|
+
const output = [];
|
|
770
|
+
for (const value of values) {
|
|
771
|
+
const token = value.token?.trim();
|
|
772
|
+
if (!token || seen.has(token))
|
|
773
|
+
continue;
|
|
774
|
+
seen.add(token);
|
|
775
|
+
output.push({
|
|
776
|
+
token,
|
|
777
|
+
id: sanitizeCredentialId(value.id) ?? createCredentialId(token),
|
|
778
|
+
roles: sanitizePolicyValues(value.roles),
|
|
779
|
+
scopes: sanitizePolicyValues(value.scopes)
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
return output;
|
|
783
|
+
}
|
|
784
|
+
function anonymousIdentity() {
|
|
785
|
+
return { authentication: 'none', roles: [], scopes: [] };
|
|
786
|
+
}
|
|
787
|
+
function sanitizeCredentialId(value) {
|
|
788
|
+
const normalized = value?.trim();
|
|
789
|
+
if (!normalized)
|
|
790
|
+
return undefined;
|
|
791
|
+
return normalized.replace(/[^a-zA-Z0-9._:-]+/g, '_').slice(0, 100);
|
|
792
|
+
}
|
|
793
|
+
function createCredentialId(token) {
|
|
794
|
+
return `key_${(0, node_crypto_1.createHash)('sha256').update(token).digest('hex').slice(0, 12)}`;
|
|
795
|
+
}
|
|
796
|
+
function sanitizePolicyValues(values) {
|
|
797
|
+
return Array.from(new Set((values ?? [])
|
|
798
|
+
.map((value) => String(value).trim().slice(0, 100))
|
|
799
|
+
.filter(Boolean)))
|
|
800
|
+
.slice(0, 100);
|
|
801
|
+
}
|
|
802
|
+
function appendVaryHeader(response, value) {
|
|
803
|
+
const current = response.getHeader('Vary');
|
|
804
|
+
const entries = String(current ?? '')
|
|
805
|
+
.split(',')
|
|
806
|
+
.map((entry) => entry.trim())
|
|
807
|
+
.filter(Boolean);
|
|
808
|
+
if (!entries.some((entry) => entry.toLowerCase() === value.toLowerCase())) {
|
|
809
|
+
entries.push(value);
|
|
810
|
+
}
|
|
811
|
+
response.setHeader('Vary', entries.join(', '));
|
|
812
|
+
}
|
|
813
|
+
function setRateLimitHeaders(response, limit, remaining, resetMs) {
|
|
814
|
+
response.setHeader('RateLimit-Limit', limit);
|
|
815
|
+
response.setHeader('RateLimit-Remaining', remaining);
|
|
816
|
+
response.setHeader('RateLimit-Reset', Math.ceil(resetMs / 1_000));
|
|
817
|
+
}
|
|
818
|
+
function countObjectKeys(value, stopAfter) {
|
|
819
|
+
if (!value || typeof value !== 'object')
|
|
820
|
+
return 0;
|
|
821
|
+
let count = 0;
|
|
822
|
+
const stack = [value];
|
|
823
|
+
const seen = new WeakSet();
|
|
824
|
+
while (stack.length > 0 && count < stopAfter) {
|
|
825
|
+
const current = stack.pop();
|
|
826
|
+
if (seen.has(current))
|
|
827
|
+
continue;
|
|
828
|
+
seen.add(current);
|
|
829
|
+
for (const entry of Object.values(current)) {
|
|
830
|
+
count += 1;
|
|
831
|
+
if (entry && typeof entry === 'object')
|
|
832
|
+
stack.push(entry);
|
|
833
|
+
if (count >= stopAfter)
|
|
834
|
+
break;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
return count;
|
|
838
|
+
}
|
|
839
|
+
function inspectObjectComplexity(value, maxDepth, maxKeys) {
|
|
840
|
+
const stack = [{ value, depth: 1 }];
|
|
841
|
+
const seen = new WeakSet();
|
|
842
|
+
let keys = 0;
|
|
843
|
+
while (stack.length > 0) {
|
|
844
|
+
const current = stack.pop();
|
|
845
|
+
if (seen.has(current.value))
|
|
846
|
+
continue;
|
|
847
|
+
seen.add(current.value);
|
|
848
|
+
if (current.depth > maxDepth)
|
|
849
|
+
return { valid: false, reason: 'json_depth' };
|
|
850
|
+
for (const entry of Object.values(current.value)) {
|
|
851
|
+
keys += 1;
|
|
852
|
+
if (keys > maxKeys)
|
|
853
|
+
return { valid: false, reason: 'json_keys' };
|
|
854
|
+
if (entry && typeof entry === 'object') {
|
|
855
|
+
stack.push({ value: entry, depth: current.depth + 1 });
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
return { valid: true };
|
|
860
|
+
}
|
|
861
|
+
function normalizeTelemetryPath(value) {
|
|
862
|
+
const raw = String(value || '/').split('?')[0] || '/';
|
|
863
|
+
return raw
|
|
864
|
+
.replace(/\b\d{6,}\b/g, ':id')
|
|
865
|
+
.replace(/\b[0-9a-f]{8}-[0-9a-f-]{27,}\b/gi, ':id')
|
|
866
|
+
.slice(0, 120);
|
|
867
|
+
}
|
|
868
|
+
function clampInteger(value, minimum, maximum, fallback) {
|
|
869
|
+
const parsed = Number(value);
|
|
870
|
+
if (!Number.isFinite(parsed))
|
|
871
|
+
return fallback;
|
|
872
|
+
return Math.max(minimum, Math.min(maximum, Math.floor(parsed)));
|
|
540
873
|
}
|
|
541
874
|
function normalizePort(value) {
|
|
542
875
|
const parsed = Number(value ?? DEFAULT_PORT);
|