@ninenity/neutrun 1.2.0 → 1.9.2
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 +2 -2
- package/dist/CLI/Localize.d.ts +6 -0
- package/dist/CLI/Localize.d.ts.map +1 -1
- package/dist/CLI/Localize.js +63 -14
- package/dist/CLI/Localize.js.map +1 -1
- package/dist/Shared/Client.d.ts +1 -0
- package/dist/Shared/Client.d.ts.map +1 -1
- package/dist/Shared/Client.js +69 -2
- package/dist/Shared/Client.js.map +1 -1
- package/dist/Shared/ComponentRuntime.d.ts.map +1 -1
- package/dist/Shared/ComponentRuntime.js +2 -0
- package/dist/Shared/ComponentRuntime.js.map +1 -1
- package/dist/Shared/Config.js +2 -2
- package/dist/Shared/Config.js.map +1 -1
- package/dist/Shared/Definer.d.ts.map +1 -1
- package/dist/Shared/Definer.js +2 -0
- package/dist/Shared/Definer.js.map +1 -1
- 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 +60 -1
- package/dist/Shared/ExpressApi.d.ts.map +1 -1
- package/dist/Shared/ExpressApi.js +393 -29
- package/dist/Shared/ExpressApi.js.map +1 -1
- package/dist/Shared/InteractionResponse.d.ts +12 -0
- package/dist/Shared/InteractionResponse.d.ts.map +1 -0
- package/dist/Shared/InteractionResponse.js +72 -0
- package/dist/Shared/InteractionResponse.js.map +1 -0
- package/dist/Shared/Neutrun.d.ts +1 -0
- package/dist/Shared/Neutrun.d.ts.map +1 -1
- package/dist/Shared/Neutrun.js +13 -5
- package/dist/Shared/Neutrun.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/ProcessManager.d.ts +1 -0
- package/dist/Shared/ProcessManager.d.ts.map +1 -1
- package/dist/Shared/ProcessManager.js +21 -6
- package/dist/Shared/ProcessManager.js.map +1 -1
- package/dist/Shared/ServerMetricsService.d.ts +85 -0
- package/dist/Shared/ServerMetricsService.d.ts.map +1 -0
- package/dist/Shared/ServerMetricsService.js +284 -0
- package/dist/Shared/ServerMetricsService.js.map +1 -0
- package/dist/Shared/TelemetryPacket.d.ts +28 -0
- package/dist/Shared/TelemetryPacket.d.ts.map +1 -0
- package/dist/Shared/TelemetryPacket.js +22 -0
- package/dist/Shared/TelemetryPacket.js.map +1 -0
- package/dist/Shared/TelemetryService.d.ts +34 -0
- package/dist/Shared/TelemetryService.d.ts.map +1 -1
- package/dist/Shared/TelemetryService.js +235 -19
- 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 +12 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -2
- package/dist/index.js.map +1 -1
- package/package.json +10 -2
|
@@ -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;
|
|
@@ -25,13 +26,18 @@ class ApiRequestError extends Error {
|
|
|
25
26
|
exports.ApiRequestError = ApiRequestError;
|
|
26
27
|
const DEFAULT_HOST = '127.0.0.1';
|
|
27
28
|
const DEFAULT_PORT = 3000;
|
|
28
|
-
const DEFAULT_IDLE_TIMEOUT_MS =
|
|
29
|
+
const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
29
30
|
const DEFAULT_REQUEST_TIMEOUT_MS = 30 * 1000;
|
|
30
31
|
const DEFAULT_RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
|
31
32
|
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,11 @@ 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;
|
|
71
|
+
this.app.getStatus = () => this.getStatus();
|
|
72
|
+
TelemetryService_1.Telemetry.setApiStatusProvider(() => this.getTelemetryStatus());
|
|
62
73
|
Object.defineProperties(this.app, {
|
|
63
74
|
isRunning: {
|
|
64
75
|
enumerable: true,
|
|
@@ -95,6 +106,19 @@ class ExpressApiService {
|
|
|
95
106
|
return;
|
|
96
107
|
this.installBuiltInMiddleware();
|
|
97
108
|
}
|
|
109
|
+
getStatus() {
|
|
110
|
+
return {
|
|
111
|
+
configured: this.configured,
|
|
112
|
+
enabled: this.settings.enabled,
|
|
113
|
+
running: this.server !== null,
|
|
114
|
+
suspended: this.resourcesSuspended,
|
|
115
|
+
hibernating: this.isHibernating,
|
|
116
|
+
lifecycle: this.settings.lifecycle.mode,
|
|
117
|
+
host: this.settings.host,
|
|
118
|
+
port: this.settings.port,
|
|
119
|
+
url: this.url
|
|
120
|
+
};
|
|
121
|
+
}
|
|
98
122
|
finalize() {
|
|
99
123
|
if (this.finalized)
|
|
100
124
|
return;
|
|
@@ -131,7 +155,9 @@ class ExpressApiService {
|
|
|
131
155
|
validateSettings(this.settings);
|
|
132
156
|
this.finalize();
|
|
133
157
|
this.startPromise = new Promise((resolve, reject) => {
|
|
134
|
-
const server = (0, node_http_1.createServer)(
|
|
158
|
+
const server = (0, node_http_1.createServer)({
|
|
159
|
+
maxHeaderSize: clampInteger(this.settings.security.maxHeaderBytes, 8 * 1024, 64 * 1024, DEFAULT_MAX_HEADER_BYTES)
|
|
160
|
+
}, this.app);
|
|
135
161
|
const requestTimeout = this.settings.security.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
136
162
|
server.requestTimeout = requestTimeout;
|
|
137
163
|
server.headersTimeout = Math.max(requestTimeout + 1_000, 5_000);
|
|
@@ -197,7 +223,7 @@ class ExpressApiService {
|
|
|
197
223
|
await this.ensureStarted();
|
|
198
224
|
const url = this.resolveUrl(path);
|
|
199
225
|
const headers = new Headers(options.headers);
|
|
200
|
-
const token = this.
|
|
226
|
+
const token = this.resolveCredentials()[0]?.token ?? '';
|
|
201
227
|
const authentication = this.settings.security.authentication;
|
|
202
228
|
if (token && authentication !== 'none' && !headers.has('authorization') && !headers.has('x-api-key')) {
|
|
203
229
|
if (authentication === 'api-key')
|
|
@@ -205,6 +231,9 @@ class ExpressApiService {
|
|
|
205
231
|
else
|
|
206
232
|
headers.set('authorization', `Bearer ${token}`);
|
|
207
233
|
}
|
|
234
|
+
if (!headers.has('x-request-id')) {
|
|
235
|
+
headers.set('x-request-id', Observability_1.default.getCorrelationId() ?? (0, node_crypto_1.randomUUID)());
|
|
236
|
+
}
|
|
208
237
|
const body = serializeBody(options.body, headers);
|
|
209
238
|
const { query, ...init } = options;
|
|
210
239
|
const requestInit = {
|
|
@@ -225,9 +254,11 @@ class ExpressApiService {
|
|
|
225
254
|
}
|
|
226
255
|
installBuiltInMiddleware() {
|
|
227
256
|
this.middlewareInstalled = true;
|
|
257
|
+
this.app.use(this.requestContextMiddleware());
|
|
228
258
|
this.app.use(this.securityHeaders());
|
|
229
259
|
this.app.use(this.corsMiddleware());
|
|
230
260
|
this.app.use(this.requestTrackingMiddleware());
|
|
261
|
+
this.app.use(this.requestShapeLimitsMiddleware());
|
|
231
262
|
this.app.use(this.requestTimeoutMiddleware());
|
|
232
263
|
this.app.use(this.rateLimitMiddleware());
|
|
233
264
|
this.app.use(this.authenticationMiddleware());
|
|
@@ -237,6 +268,16 @@ class ExpressApiService {
|
|
|
237
268
|
extended: true,
|
|
238
269
|
limit: this.settings.security.bodyLimit ?? '1mb'
|
|
239
270
|
}));
|
|
271
|
+
this.app.use(this.jsonComplexityMiddleware());
|
|
272
|
+
}
|
|
273
|
+
requestContextMiddleware() {
|
|
274
|
+
return (req, res, next) => {
|
|
275
|
+
const incoming = req.get('x-request-id')?.trim() || '';
|
|
276
|
+
const requestId = REQUEST_ID_PATTERN.test(incoming) ? incoming : (0, node_crypto_1.randomUUID)();
|
|
277
|
+
res.locals.neutrunRequestId = requestId;
|
|
278
|
+
res.setHeader('X-Request-Id', requestId);
|
|
279
|
+
Observability_1.default.run({ correlationId: requestId, requestId, source: 'express-api' }, () => next());
|
|
280
|
+
};
|
|
240
281
|
}
|
|
241
282
|
securityHeaders() {
|
|
242
283
|
return (_req, res, next) => {
|
|
@@ -244,6 +285,9 @@ class ExpressApiService {
|
|
|
244
285
|
res.setHeader('X-Frame-Options', 'DENY');
|
|
245
286
|
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
246
287
|
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
|
|
288
|
+
res.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'; base-uri 'none'");
|
|
289
|
+
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()');
|
|
290
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
247
291
|
next();
|
|
248
292
|
};
|
|
249
293
|
}
|
|
@@ -260,8 +304,8 @@ class ExpressApiService {
|
|
|
260
304
|
: undefined;
|
|
261
305
|
if (allowed) {
|
|
262
306
|
res.setHeader('Access-Control-Allow-Origin', allowed);
|
|
263
|
-
res
|
|
264
|
-
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-API-Key');
|
|
307
|
+
appendVaryHeader(res, 'Origin');
|
|
308
|
+
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-API-Key, X-Request-Id');
|
|
265
309
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
|
266
310
|
}
|
|
267
311
|
if (req.method === 'OPTIONS')
|
|
@@ -271,6 +315,7 @@ class ExpressApiService {
|
|
|
271
315
|
}
|
|
272
316
|
requestTrackingMiddleware() {
|
|
273
317
|
return (req, res, next) => {
|
|
318
|
+
const startedAt = performance.now();
|
|
274
319
|
this.lastActivityAt = Date.now();
|
|
275
320
|
this.activeRequests += 1;
|
|
276
321
|
let completed = false;
|
|
@@ -281,13 +326,49 @@ class ExpressApiService {
|
|
|
281
326
|
this.activeRequests = Math.max(0, this.activeRequests - 1);
|
|
282
327
|
this.lastActivityAt = Date.now();
|
|
283
328
|
this.scheduleIdleCheck();
|
|
284
|
-
this.recordRequest(req, res, res.statusCode);
|
|
329
|
+
this.recordRequest(req, res, res.statusCode, performance.now() - startedAt, !res.writableEnded);
|
|
285
330
|
};
|
|
286
331
|
res.once('finish', complete);
|
|
287
332
|
res.once('close', complete);
|
|
288
333
|
next();
|
|
289
334
|
};
|
|
290
335
|
}
|
|
336
|
+
requestShapeLimitsMiddleware() {
|
|
337
|
+
return (req, res, next) => {
|
|
338
|
+
const maximum = clampInteger(this.settings.security.maxQueryParameters, 1, 1_000, DEFAULT_MAX_QUERY_PARAMETERS);
|
|
339
|
+
if (countObjectKeys(req.query, maximum + 1) > maximum) {
|
|
340
|
+
this.emitAudit(req, res, {
|
|
341
|
+
type: 'validation',
|
|
342
|
+
outcome: 'denied',
|
|
343
|
+
status: 400,
|
|
344
|
+
reason: 'query_limit'
|
|
345
|
+
});
|
|
346
|
+
return res.status(400).json({ success: false, error: 'Too many query parameters' });
|
|
347
|
+
}
|
|
348
|
+
next();
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
jsonComplexityMiddleware() {
|
|
352
|
+
return (req, res, next) => {
|
|
353
|
+
if (req.body === undefined || req.body === null || typeof req.body !== 'object')
|
|
354
|
+
return next();
|
|
355
|
+
const maxDepth = clampInteger(this.settings.security.maxJsonDepth, 1, 100, DEFAULT_MAX_JSON_DEPTH);
|
|
356
|
+
const maxKeys = clampInteger(this.settings.security.maxJsonKeys, 1, 100_000, DEFAULT_MAX_JSON_KEYS);
|
|
357
|
+
const complexity = inspectObjectComplexity(req.body, maxDepth, maxKeys);
|
|
358
|
+
if (complexity.valid)
|
|
359
|
+
return next();
|
|
360
|
+
this.emitAudit(req, res, {
|
|
361
|
+
type: 'validation',
|
|
362
|
+
outcome: 'denied',
|
|
363
|
+
status: 400,
|
|
364
|
+
reason: complexity.reason
|
|
365
|
+
});
|
|
366
|
+
return res.status(400).json({
|
|
367
|
+
success: false,
|
|
368
|
+
error: 'Request body is too complex'
|
|
369
|
+
});
|
|
370
|
+
};
|
|
371
|
+
}
|
|
291
372
|
requestTimeoutMiddleware() {
|
|
292
373
|
return (_req, res, next) => {
|
|
293
374
|
const timeoutMs = this.settings.security.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
@@ -320,11 +401,21 @@ class ExpressApiService {
|
|
|
320
401
|
const current = this.rateLimitEntries.get(key);
|
|
321
402
|
if (!current || now - current.startedAt >= windowMs) {
|
|
322
403
|
this.rateLimitEntries.set(key, { startedAt: now, count: 1 });
|
|
404
|
+
setRateLimitHeaders(res, max, max - 1, windowMs);
|
|
323
405
|
return next();
|
|
324
406
|
}
|
|
325
|
-
current.count
|
|
407
|
+
current.count = Math.min(Number.MAX_SAFE_INTEGER, current.count + 1);
|
|
408
|
+
const remaining = Math.max(0, max - current.count);
|
|
409
|
+
const resetMs = Math.max(0, windowMs - (now - current.startedAt));
|
|
410
|
+
setRateLimitHeaders(res, max, remaining, resetMs);
|
|
326
411
|
if (current.count > max) {
|
|
327
|
-
res.setHeader('Retry-After', Math.ceil(
|
|
412
|
+
res.setHeader('Retry-After', Math.ceil(resetMs / 1_000));
|
|
413
|
+
Observability_1.default.increment('neutrun_api_rate_limit_total');
|
|
414
|
+
this.emitAudit(req, res, {
|
|
415
|
+
type: 'rate-limit',
|
|
416
|
+
outcome: 'denied',
|
|
417
|
+
status: 429
|
|
418
|
+
});
|
|
328
419
|
return res.status(429).json({
|
|
329
420
|
success: false,
|
|
330
421
|
error: 'Too Many Requests'
|
|
@@ -336,27 +427,118 @@ class ExpressApiService {
|
|
|
336
427
|
authenticationMiddleware() {
|
|
337
428
|
return (req, res, next) => {
|
|
338
429
|
const authentication = this.settings.security.authentication;
|
|
339
|
-
|
|
340
|
-
|
|
430
|
+
if (authentication === 'none') {
|
|
431
|
+
res.locals.neutrunIdentity = anonymousIdentity();
|
|
432
|
+
return next();
|
|
433
|
+
}
|
|
434
|
+
if (isPublicPath(req.path, this.settings.security.publicPaths)) {
|
|
435
|
+
res.locals.neutrunIdentity = anonymousIdentity();
|
|
341
436
|
return next();
|
|
342
437
|
}
|
|
343
438
|
const received = authentication === 'api-key'
|
|
344
439
|
? req.get('x-api-key')
|
|
345
440
|
: extractBearerToken(req.get('authorization'));
|
|
346
|
-
|
|
441
|
+
const credential = received
|
|
442
|
+
? this.resolveCredentials().find((entry) => timingSafeEqual(received, entry.token))
|
|
443
|
+
: undefined;
|
|
444
|
+
if (!credential) {
|
|
445
|
+
Observability_1.default.increment('neutrun_api_authentication_total', 1, { outcome: 'denied' });
|
|
446
|
+
this.emitAudit(req, res, {
|
|
447
|
+
type: 'authentication',
|
|
448
|
+
outcome: 'denied',
|
|
449
|
+
status: 401,
|
|
450
|
+
reason: received ? 'invalid_credential' : 'missing_credential'
|
|
451
|
+
});
|
|
347
452
|
return res.status(401).json({
|
|
348
453
|
success: false,
|
|
349
454
|
error: 'Unauthorized'
|
|
350
455
|
});
|
|
351
456
|
}
|
|
457
|
+
const identity = {
|
|
458
|
+
authentication,
|
|
459
|
+
credentialId: credential.id,
|
|
460
|
+
roles: credential.roles,
|
|
461
|
+
scopes: credential.scopes
|
|
462
|
+
};
|
|
463
|
+
res.locals.neutrunIdentity = identity;
|
|
464
|
+
Observability_1.default.increment('neutrun_api_authentication_total', 1, { outcome: 'allowed' });
|
|
465
|
+
this.emitAudit(req, res, {
|
|
466
|
+
type: 'authentication',
|
|
467
|
+
outcome: 'allowed',
|
|
468
|
+
credentialId: credential.id
|
|
469
|
+
});
|
|
352
470
|
next();
|
|
353
471
|
};
|
|
354
472
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
473
|
+
protectionMiddleware(options = {}) {
|
|
474
|
+
return (req, res, next) => {
|
|
475
|
+
const run = async () => {
|
|
476
|
+
const identity = res.locals.neutrunIdentity;
|
|
477
|
+
const roles = new Set(identity?.roles ?? []);
|
|
478
|
+
const scopes = new Set(identity?.scopes ?? []);
|
|
479
|
+
const hasRoles = (options.roles ?? []).every((role) => roles.has(role));
|
|
480
|
+
const hasScopes = (options.scopes ?? []).every((scope) => scopes.has(scope));
|
|
481
|
+
const policyEligible = Boolean(identity && identity.authentication !== 'none' && hasRoles && hasScopes);
|
|
482
|
+
const customAllowed = policyEligible && options.authorize
|
|
483
|
+
? await options.authorize(req, identity)
|
|
484
|
+
: policyEligible;
|
|
485
|
+
if (policyEligible && identity && customAllowed) {
|
|
486
|
+
this.emitAudit(req, res, {
|
|
487
|
+
type: 'authorization',
|
|
488
|
+
outcome: 'allowed',
|
|
489
|
+
credentialId: identity.credentialId
|
|
490
|
+
});
|
|
491
|
+
next();
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
Observability_1.default.increment('neutrun_api_authorization_total', 1, { outcome: 'denied' });
|
|
495
|
+
this.emitAudit(req, res, {
|
|
496
|
+
type: 'authorization',
|
|
497
|
+
outcome: 'denied',
|
|
498
|
+
status: identity?.authentication === 'none' ? 401 : 403,
|
|
499
|
+
credentialId: identity?.credentialId,
|
|
500
|
+
reason: 'policy_denied'
|
|
501
|
+
});
|
|
502
|
+
res.status(identity?.authentication === 'none' ? 401 : 403).json({
|
|
503
|
+
success: false,
|
|
504
|
+
error: identity?.authentication === 'none' ? 'Unauthorized' : 'Forbidden'
|
|
505
|
+
});
|
|
506
|
+
};
|
|
507
|
+
void run().catch(next);
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
validationMiddleware(options) {
|
|
511
|
+
return (req, res, next) => {
|
|
512
|
+
const run = async () => {
|
|
513
|
+
const checks = [
|
|
514
|
+
['body', options.body, req.body],
|
|
515
|
+
['query', options.query, req.query],
|
|
516
|
+
['params', options.params, req.params]
|
|
517
|
+
];
|
|
518
|
+
for (const [part, validator, value] of checks) {
|
|
519
|
+
if (!validator)
|
|
520
|
+
continue;
|
|
521
|
+
const result = await validator(value, req);
|
|
522
|
+
if (result === false || typeof result === 'string') {
|
|
523
|
+
const reason = typeof result === 'string' ? result.slice(0, 200) : `Invalid ${part}`;
|
|
524
|
+
Observability_1.default.increment('neutrun_api_validation_total', 1, { outcome: 'denied', part });
|
|
525
|
+
this.emitAudit(req, res, {
|
|
526
|
+
type: 'validation',
|
|
527
|
+
outcome: 'denied',
|
|
528
|
+
status: 400,
|
|
529
|
+
reason: part
|
|
530
|
+
});
|
|
531
|
+
res.status(400).json({ success: false, error: reason });
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
next();
|
|
536
|
+
};
|
|
537
|
+
void run().catch(next);
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
resolveCredentials() {
|
|
541
|
+
return resolveCredentials(this.settings.security);
|
|
360
542
|
}
|
|
361
543
|
scheduleIdleCheck() {
|
|
362
544
|
this.clearIdleTimer();
|
|
@@ -431,13 +613,42 @@ class ExpressApiService {
|
|
|
431
613
|
this.cleanupRegistered = true;
|
|
432
614
|
ProcessManager_1.default.registerCleanupTask(() => this.stop(), 'mini API Express');
|
|
433
615
|
}
|
|
434
|
-
recordRequest(req, res, status, error = false) {
|
|
435
|
-
const path = req.path
|
|
616
|
+
recordRequest(req, res, status, durationMs, error = false) {
|
|
617
|
+
const path = normalizeTelemetryPath(req.route?.path ?? req.path ?? '/');
|
|
436
618
|
TelemetryService_1.Telemetry.registerCounter('apiRequests');
|
|
437
619
|
TelemetryService_1.Telemetry.registerEvent(`api:${req.method}:${path}`.slice(0, 120), {
|
|
438
|
-
error: error || status >= 400
|
|
620
|
+
error: error || status >= 400,
|
|
621
|
+
durationMs
|
|
622
|
+
});
|
|
623
|
+
Observability_1.default.increment('neutrun_api_requests_total', 1, {
|
|
624
|
+
method: req.method,
|
|
625
|
+
outcome: status >= 400 || error ? 'error' : 'success',
|
|
626
|
+
status: `${Math.floor(status / 100)}xx`
|
|
627
|
+
});
|
|
628
|
+
Observability_1.default.observe('neutrun_api_request_duration_ms', durationMs, {
|
|
629
|
+
method: req.method
|
|
630
|
+
});
|
|
631
|
+
this.emitAudit(req, res, {
|
|
632
|
+
type: 'request',
|
|
633
|
+
outcome: status >= 400 || error ? 'error' : 'success',
|
|
634
|
+
status,
|
|
635
|
+
durationMs
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
emitAudit(req, res, event) {
|
|
639
|
+
const audit = this.settings.security.audit;
|
|
640
|
+
if (!audit)
|
|
641
|
+
return;
|
|
642
|
+
const payload = {
|
|
643
|
+
...event,
|
|
644
|
+
at: new Date().toISOString(),
|
|
645
|
+
requestId: String(res.locals.neutrunRequestId || (0, node_crypto_1.randomUUID)()),
|
|
646
|
+
method: req.method.slice(0, 16),
|
|
647
|
+
path: normalizeTelemetryPath(req.route?.path ?? req.path ?? '/')
|
|
648
|
+
};
|
|
649
|
+
void Promise.resolve(audit(payload)).catch((error) => {
|
|
650
|
+
(0, ConsoleLog_1.logWarn)(`Falha no destino de auditoria da mini API: ${error instanceof Error ? error.message : error}`, 'Api');
|
|
439
651
|
});
|
|
440
|
-
void res;
|
|
441
652
|
}
|
|
442
653
|
resolveUrl(path) {
|
|
443
654
|
const base = this.url;
|
|
@@ -464,6 +675,22 @@ class ExpressApiService {
|
|
|
464
675
|
const port = typeof address === 'object' && address ? address.port : this.settings.port;
|
|
465
676
|
return this.publicUrl(port);
|
|
466
677
|
}
|
|
678
|
+
get isHibernating() {
|
|
679
|
+
return Boolean(this.configured &&
|
|
680
|
+
this.settings.enabled &&
|
|
681
|
+
this.settings.lifecycle.mode !== 'always' &&
|
|
682
|
+
(!this.server || this.resourcesSuspended));
|
|
683
|
+
}
|
|
684
|
+
getTelemetryStatus() {
|
|
685
|
+
return {
|
|
686
|
+
configured: this.configured,
|
|
687
|
+
enabled: this.settings.enabled,
|
|
688
|
+
running: this.server !== null,
|
|
689
|
+
suspended: this.resourcesSuspended,
|
|
690
|
+
hibernating: this.isHibernating,
|
|
691
|
+
lifecycle: this.settings.lifecycle.mode
|
|
692
|
+
};
|
|
693
|
+
}
|
|
467
694
|
clearIdleTimer() {
|
|
468
695
|
if (!this.idleTimer)
|
|
469
696
|
return;
|
|
@@ -511,32 +738,169 @@ function normalizeSettings(options) {
|
|
|
511
738
|
onWake: lifecycle.onWake ?? serverlessOptions.onWake
|
|
512
739
|
},
|
|
513
740
|
security: {
|
|
514
|
-
authentication: api.security?.authentication ?? (
|
|
741
|
+
authentication: api.security?.authentication ?? (resolveCredentials(api.security).length > 0 || isPublicHost(host) ? 'bearer' : 'none'),
|
|
515
742
|
token: api.security?.token,
|
|
516
743
|
tokenEnv: api.security?.tokenEnv ?? 'NEUTRUN_API_TOKEN',
|
|
744
|
+
tokens: api.security?.tokens,
|
|
745
|
+
tokensEnv: api.security?.tokensEnv ?? 'NEUTRUN_API_TOKENS',
|
|
746
|
+
credentials: api.security?.credentials,
|
|
747
|
+
allowInsecurePublic: api.security?.allowInsecurePublic,
|
|
517
748
|
publicPaths: api.security?.publicPaths ?? [],
|
|
518
749
|
cors: api.security?.cors,
|
|
519
750
|
bodyLimit: api.security?.bodyLimit,
|
|
520
751
|
rateLimit: api.security?.rateLimit,
|
|
521
752
|
requestTimeoutMs: api.security?.requestTimeoutMs,
|
|
522
|
-
trustProxy: api.security?.trustProxy
|
|
753
|
+
trustProxy: api.security?.trustProxy,
|
|
754
|
+
maxHeaderBytes: api.security?.maxHeaderBytes,
|
|
755
|
+
maxQueryParameters: api.security?.maxQueryParameters,
|
|
756
|
+
maxJsonDepth: api.security?.maxJsonDepth,
|
|
757
|
+
maxJsonKeys: api.security?.maxJsonKeys,
|
|
758
|
+
audit: api.security?.audit
|
|
523
759
|
}
|
|
524
760
|
};
|
|
525
761
|
}
|
|
526
762
|
function validateSettings(settings) {
|
|
527
|
-
const
|
|
528
|
-
if (settings.security.authentication !== 'none' &&
|
|
763
|
+
const credentials = resolveCredentials(settings.security);
|
|
764
|
+
if (settings.security.authentication !== 'none' && credentials.length === 0) {
|
|
529
765
|
throw new Error('A mini API autenticada precisa de security.token ou NEUTRUN_API_TOKEN configurado.');
|
|
530
766
|
}
|
|
767
|
+
if (settings.security.authentication === 'none' &&
|
|
768
|
+
isPublicHost(settings.host) &&
|
|
769
|
+
settings.security.allowInsecurePublic !== true) {
|
|
770
|
+
throw new Error('A mini API pública não pode desativar autenticação sem security.allowInsecurePublic=true.');
|
|
771
|
+
}
|
|
772
|
+
if (settings.security.cors === '*' &&
|
|
773
|
+
settings.security.authentication !== 'none') {
|
|
774
|
+
throw new Error('A mini API autenticada exige uma lista explícita de origens CORS.');
|
|
775
|
+
}
|
|
776
|
+
if (isPublicHost(settings.host) &&
|
|
777
|
+
credentials.some((credential) => Buffer.byteLength(credential.token, 'utf8') < 16)) {
|
|
778
|
+
throw new Error('Credenciais da mini API precisam ter pelo menos 16 bytes.');
|
|
779
|
+
}
|
|
531
780
|
}
|
|
532
781
|
function isPublicHost(host) {
|
|
533
782
|
return !['127.0.0.1', 'localhost', '::1'].includes(host);
|
|
534
783
|
}
|
|
535
|
-
function
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
784
|
+
function resolveCredentials(security) {
|
|
785
|
+
const values = [];
|
|
786
|
+
for (const credential of security?.credentials ?? [])
|
|
787
|
+
values.push(credential);
|
|
788
|
+
for (const token of security?.tokens ?? [])
|
|
789
|
+
values.push({ token });
|
|
790
|
+
const tokensEnvironment = process.env[security?.tokensEnv ?? 'NEUTRUN_API_TOKENS'];
|
|
791
|
+
for (const token of tokensEnvironment?.split(/[\r\n,]+/) ?? [])
|
|
792
|
+
values.push({ token });
|
|
793
|
+
const directToken = security?.token?.trim();
|
|
794
|
+
if (directToken)
|
|
795
|
+
values.push({ token: directToken });
|
|
796
|
+
const environmentToken = process.env[security?.tokenEnv ?? 'NEUTRUN_API_TOKEN']?.trim();
|
|
797
|
+
if (environmentToken)
|
|
798
|
+
values.push({ token: environmentToken });
|
|
799
|
+
const seen = new Set();
|
|
800
|
+
const output = [];
|
|
801
|
+
for (const value of values) {
|
|
802
|
+
const token = value.token?.trim();
|
|
803
|
+
if (!token || seen.has(token))
|
|
804
|
+
continue;
|
|
805
|
+
seen.add(token);
|
|
806
|
+
output.push({
|
|
807
|
+
token,
|
|
808
|
+
id: sanitizeCredentialId(value.id) ?? createCredentialId(token),
|
|
809
|
+
roles: sanitizePolicyValues(value.roles),
|
|
810
|
+
scopes: sanitizePolicyValues(value.scopes)
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
return output;
|
|
814
|
+
}
|
|
815
|
+
function anonymousIdentity() {
|
|
816
|
+
return { authentication: 'none', roles: [], scopes: [] };
|
|
817
|
+
}
|
|
818
|
+
function sanitizeCredentialId(value) {
|
|
819
|
+
const normalized = value?.trim();
|
|
820
|
+
if (!normalized)
|
|
821
|
+
return undefined;
|
|
822
|
+
return normalized.replace(/[^a-zA-Z0-9._:-]+/g, '_').slice(0, 100);
|
|
823
|
+
}
|
|
824
|
+
function createCredentialId(token) {
|
|
825
|
+
return `key_${(0, node_crypto_1.createHash)('sha256').update(token).digest('hex').slice(0, 12)}`;
|
|
826
|
+
}
|
|
827
|
+
function sanitizePolicyValues(values) {
|
|
828
|
+
return Array.from(new Set((values ?? [])
|
|
829
|
+
.map((value) => String(value).trim().slice(0, 100))
|
|
830
|
+
.filter(Boolean)))
|
|
831
|
+
.slice(0, 100);
|
|
832
|
+
}
|
|
833
|
+
function appendVaryHeader(response, value) {
|
|
834
|
+
const current = response.getHeader('Vary');
|
|
835
|
+
const entries = String(current ?? '')
|
|
836
|
+
.split(',')
|
|
837
|
+
.map((entry) => entry.trim())
|
|
838
|
+
.filter(Boolean);
|
|
839
|
+
if (!entries.some((entry) => entry.toLowerCase() === value.toLowerCase())) {
|
|
840
|
+
entries.push(value);
|
|
841
|
+
}
|
|
842
|
+
response.setHeader('Vary', entries.join(', '));
|
|
843
|
+
}
|
|
844
|
+
function setRateLimitHeaders(response, limit, remaining, resetMs) {
|
|
845
|
+
response.setHeader('RateLimit-Limit', limit);
|
|
846
|
+
response.setHeader('RateLimit-Remaining', remaining);
|
|
847
|
+
response.setHeader('RateLimit-Reset', Math.ceil(resetMs / 1_000));
|
|
848
|
+
}
|
|
849
|
+
function countObjectKeys(value, stopAfter) {
|
|
850
|
+
if (!value || typeof value !== 'object')
|
|
851
|
+
return 0;
|
|
852
|
+
let count = 0;
|
|
853
|
+
const stack = [value];
|
|
854
|
+
const seen = new WeakSet();
|
|
855
|
+
while (stack.length > 0 && count < stopAfter) {
|
|
856
|
+
const current = stack.pop();
|
|
857
|
+
if (seen.has(current))
|
|
858
|
+
continue;
|
|
859
|
+
seen.add(current);
|
|
860
|
+
for (const entry of Object.values(current)) {
|
|
861
|
+
count += 1;
|
|
862
|
+
if (entry && typeof entry === 'object')
|
|
863
|
+
stack.push(entry);
|
|
864
|
+
if (count >= stopAfter)
|
|
865
|
+
break;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
return count;
|
|
869
|
+
}
|
|
870
|
+
function inspectObjectComplexity(value, maxDepth, maxKeys) {
|
|
871
|
+
const stack = [{ value, depth: 1 }];
|
|
872
|
+
const seen = new WeakSet();
|
|
873
|
+
let keys = 0;
|
|
874
|
+
while (stack.length > 0) {
|
|
875
|
+
const current = stack.pop();
|
|
876
|
+
if (seen.has(current.value))
|
|
877
|
+
continue;
|
|
878
|
+
seen.add(current.value);
|
|
879
|
+
if (current.depth > maxDepth)
|
|
880
|
+
return { valid: false, reason: 'json_depth' };
|
|
881
|
+
for (const entry of Object.values(current.value)) {
|
|
882
|
+
keys += 1;
|
|
883
|
+
if (keys > maxKeys)
|
|
884
|
+
return { valid: false, reason: 'json_keys' };
|
|
885
|
+
if (entry && typeof entry === 'object') {
|
|
886
|
+
stack.push({ value: entry, depth: current.depth + 1 });
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
return { valid: true };
|
|
891
|
+
}
|
|
892
|
+
function normalizeTelemetryPath(value) {
|
|
893
|
+
const raw = String(value || '/').split('?')[0] || '/';
|
|
894
|
+
return raw
|
|
895
|
+
.replace(/\b\d{6,}\b/g, ':id')
|
|
896
|
+
.replace(/\b[0-9a-f]{8}-[0-9a-f-]{27,}\b/gi, ':id')
|
|
897
|
+
.slice(0, 120);
|
|
898
|
+
}
|
|
899
|
+
function clampInteger(value, minimum, maximum, fallback) {
|
|
900
|
+
const parsed = Number(value);
|
|
901
|
+
if (!Number.isFinite(parsed))
|
|
902
|
+
return fallback;
|
|
903
|
+
return Math.max(minimum, Math.min(maximum, Math.floor(parsed)));
|
|
540
904
|
}
|
|
541
905
|
function normalizePort(value) {
|
|
542
906
|
const parsed = Number(value ?? DEFAULT_PORT);
|