te.js 2.1.6 → 2.2.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/auto-docs/analysis/handler-analyzer.test.js +106 -0
- package/auto-docs/analysis/source-resolver.test.js +58 -0
- package/auto-docs/constants.js +13 -2
- package/auto-docs/openapi/generator.js +7 -5
- package/auto-docs/openapi/generator.test.js +132 -0
- package/auto-docs/openapi/spec-builders.js +39 -19
- package/cli/docs-command.js +44 -36
- package/cors/index.test.js +82 -0
- package/database/index.js +3 -1
- package/database/mongodb.js +17 -11
- package/database/redis.js +53 -44
- package/lib/llm/client.js +6 -1
- package/lib/llm/index.js +14 -1
- package/lib/llm/parse.test.js +60 -0
- package/package.json +3 -1
- package/radar/index.js +281 -0
- package/rate-limit/index.js +8 -11
- package/rate-limit/index.test.js +64 -0
- package/server/ammo/body-parser.js +156 -152
- package/server/ammo/body-parser.test.js +79 -0
- package/server/ammo/enhancer.js +8 -4
- package/server/ammo.js +135 -10
- package/server/context/request-context.js +51 -0
- package/server/context/request-context.test.js +53 -0
- package/server/endpoint.js +15 -0
- package/server/error.js +56 -3
- package/server/error.test.js +45 -0
- package/server/errors/channels/channels.test.js +148 -0
- package/server/errors/channels/index.js +1 -1
- package/server/errors/llm-cache.js +1 -1
- package/server/errors/llm-cache.test.js +160 -0
- package/server/errors/llm-error-service.js +1 -1
- package/server/errors/llm-rate-limiter.test.js +105 -0
- package/server/files/uploader.js +38 -26
- package/server/handler.js +1 -1
- package/server/targets/registry.js +3 -3
- package/server/targets/registry.test.js +108 -0
- package/te.js +178 -49
- package/utils/auto-register.js +1 -1
- package/utils/configuration.js +23 -9
- package/utils/configuration.test.js +58 -0
- package/utils/errors-llm-config.js +11 -8
- package/utils/request-logger.js +49 -3
package/te.js
CHANGED
|
@@ -3,6 +3,7 @@ import { env, setEnv } from 'tej-env';
|
|
|
3
3
|
import TejLogger from 'tej-logger';
|
|
4
4
|
import rateLimiter from './rate-limit/index.js';
|
|
5
5
|
import corsMiddleware from './cors/index.js';
|
|
6
|
+
import radarMiddleware from './radar/index.js';
|
|
6
7
|
|
|
7
8
|
import targetRegistry from './server/targets/registry.js';
|
|
8
9
|
import dbManager from './database/index.js';
|
|
@@ -19,9 +20,50 @@ import { pathToFileURL } from 'node:url';
|
|
|
19
20
|
import { readFile } from 'node:fs/promises';
|
|
20
21
|
import { findTargetFiles } from './utils/auto-register.js';
|
|
21
22
|
import { registerDocRoutes } from './auto-docs/ui/docs-ui.js';
|
|
23
|
+
import TejError from './server/error.js';
|
|
22
24
|
|
|
23
25
|
const logger = new TejLogger('Tejas');
|
|
24
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Performs a graceful shutdown: closes the HTTP server (if started), then exits.
|
|
29
|
+
* Invoked by process-level fatal error handlers.
|
|
30
|
+
* @param {number} [exitCode=1]
|
|
31
|
+
*/
|
|
32
|
+
async function gracefulShutdown(exitCode = 1) {
|
|
33
|
+
const instance = Tejas.instance;
|
|
34
|
+
if (instance?.engine) {
|
|
35
|
+
try {
|
|
36
|
+
await new Promise((resolve) => instance.engine.close(resolve));
|
|
37
|
+
} catch {
|
|
38
|
+
// ignore close errors during shutdown
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
process.exit(exitCode);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
process.on('unhandledRejection', (reason) => {
|
|
45
|
+
process.stderr.write(
|
|
46
|
+
JSON.stringify({
|
|
47
|
+
level: 'fatal',
|
|
48
|
+
code: 'ERR_UNHANDLED_REJECTION',
|
|
49
|
+
reason: String(reason),
|
|
50
|
+
}) + '\n',
|
|
51
|
+
);
|
|
52
|
+
gracefulShutdown(1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
process.on('uncaughtException', (error) => {
|
|
56
|
+
process.stderr.write(
|
|
57
|
+
JSON.stringify({
|
|
58
|
+
level: 'fatal',
|
|
59
|
+
code: 'ERR_UNCAUGHT_EXCEPTION',
|
|
60
|
+
message: error.message,
|
|
61
|
+
stack: error.stack,
|
|
62
|
+
}) + '\n',
|
|
63
|
+
);
|
|
64
|
+
gracefulShutdown(1);
|
|
65
|
+
});
|
|
66
|
+
|
|
25
67
|
/**
|
|
26
68
|
* Main Tejas Framework Class
|
|
27
69
|
*
|
|
@@ -52,17 +94,14 @@ class Tejas {
|
|
|
52
94
|
constructor(args) {
|
|
53
95
|
if (Tejas.instance) return Tejas.instance;
|
|
54
96
|
Tejas.instance = this;
|
|
55
|
-
|
|
56
97
|
this.options = args || {};
|
|
57
|
-
|
|
58
|
-
this.generateConfiguration();
|
|
59
|
-
this.registerTargetsDir();
|
|
60
98
|
}
|
|
61
99
|
|
|
62
100
|
/**
|
|
63
101
|
* Generates and loads configuration from multiple sources
|
|
64
102
|
*
|
|
65
103
|
* @private
|
|
104
|
+
* @returns {Promise<void>}
|
|
66
105
|
* @description
|
|
67
106
|
* Loads and merges configuration from:
|
|
68
107
|
* 1. tejas.config.json file (lowest priority)
|
|
@@ -72,15 +111,15 @@ class Tejas {
|
|
|
72
111
|
* All configuration keys are standardized to uppercase and flattened.
|
|
73
112
|
* Sets default values for required configuration if not provided.
|
|
74
113
|
*/
|
|
75
|
-
generateConfiguration() {
|
|
76
|
-
const configVars = standardizeObj(loadConfigFile());
|
|
114
|
+
async generateConfiguration() {
|
|
115
|
+
const configVars = standardizeObj(await loadConfigFile());
|
|
77
116
|
const envVars = standardizeObj(process.env);
|
|
78
117
|
const userVars = standardizeObj(this.options);
|
|
79
118
|
|
|
80
|
-
const config = { ...configVars, ...envVars, ...userVars };
|
|
119
|
+
const config = Object.freeze({ ...configVars, ...envVars, ...userVars });
|
|
81
120
|
|
|
82
121
|
for (const key in config) {
|
|
83
|
-
if (
|
|
122
|
+
if (Object.hasOwn(config, key)) {
|
|
84
123
|
setEnv(key, config[key]);
|
|
85
124
|
}
|
|
86
125
|
}
|
|
@@ -89,6 +128,16 @@ class Tejas {
|
|
|
89
128
|
if (!env('PORT')) setEnv('PORT', 1403);
|
|
90
129
|
if (!env('BODY_MAX_SIZE')) setEnv('BODY_MAX_SIZE', 10 * 1024 * 1024); // 10MB default
|
|
91
130
|
if (!env('BODY_TIMEOUT')) setEnv('BODY_TIMEOUT', 30000); // 30 seconds default
|
|
131
|
+
|
|
132
|
+
// Validate port is a usable integer
|
|
133
|
+
const port = Number(env('PORT'));
|
|
134
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
135
|
+
throw new TejError(
|
|
136
|
+
500,
|
|
137
|
+
`Invalid PORT: "${env('PORT')}" — must be an integer between 1 and 65535`,
|
|
138
|
+
{ cause: new Error(`ERR_CONFIG_INVALID`) },
|
|
139
|
+
);
|
|
140
|
+
}
|
|
92
141
|
}
|
|
93
142
|
|
|
94
143
|
/**
|
|
@@ -114,51 +163,41 @@ class Tejas {
|
|
|
114
163
|
}
|
|
115
164
|
|
|
116
165
|
/**
|
|
117
|
-
* Automatically registers target files from the configured directory
|
|
166
|
+
* Automatically registers target files from the configured directory.
|
|
167
|
+
* Returns a Promise so takeoff() can await it — ensures all targets are
|
|
168
|
+
* fully loaded before the server starts accepting connections.
|
|
118
169
|
*
|
|
119
170
|
* @private
|
|
120
|
-
* @
|
|
121
|
-
* Searches for and registers all files ending in 'target.js' from the
|
|
122
|
-
* directory specified by DIR_TARGETS environment variable.
|
|
123
|
-
* Target files define routes and their handlers.
|
|
124
|
-
*
|
|
171
|
+
* @returns {Promise<void>}
|
|
125
172
|
* @throws {Error} If target files cannot be registered
|
|
126
173
|
*/
|
|
127
|
-
registerTargetsDir() {
|
|
174
|
+
async registerTargetsDir() {
|
|
128
175
|
const baseDir = path.join(process.cwd(), process.env.DIR_TARGETS || '');
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
});
|
|
155
|
-
})
|
|
156
|
-
.catch((err) => {
|
|
157
|
-
logger.error(
|
|
158
|
-
`Tejas could not register target files. Error: ${err}`,
|
|
159
|
-
false,
|
|
160
|
-
);
|
|
161
|
-
});
|
|
176
|
+
try {
|
|
177
|
+
const targetFiles = await findTargetFiles();
|
|
178
|
+
if (!targetFiles?.length) return;
|
|
179
|
+
for (const file of targetFiles) {
|
|
180
|
+
const parentPath = file.path || '';
|
|
181
|
+
const fullPath = path.isAbsolute(parentPath)
|
|
182
|
+
? path.join(parentPath, file.name)
|
|
183
|
+
: path.join(baseDir, parentPath, file.name);
|
|
184
|
+
const relativePath = path.relative(baseDir, fullPath);
|
|
185
|
+
const groupId =
|
|
186
|
+
relativePath.replace(/\.target\.js$/i, '').replace(/\\/g, '/') ||
|
|
187
|
+
'index';
|
|
188
|
+
targetRegistry.setCurrentSourceGroup(groupId);
|
|
189
|
+
try {
|
|
190
|
+
await import(pathToFileURL(fullPath).href);
|
|
191
|
+
} finally {
|
|
192
|
+
targetRegistry.setCurrentSourceGroup(null);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} catch (err) {
|
|
196
|
+
logger.error(
|
|
197
|
+
`Tejas could not register target files. Error: ${err}`,
|
|
198
|
+
false,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
162
201
|
}
|
|
163
202
|
|
|
164
203
|
/**
|
|
@@ -193,7 +232,10 @@ class Tejas {
|
|
|
193
232
|
* // Start server without databases
|
|
194
233
|
* app.takeoff(); // Server starts on default port 1403
|
|
195
234
|
*/
|
|
196
|
-
takeoff({ withRedis, withMongo } = {}) {
|
|
235
|
+
async takeoff({ withRedis, withMongo } = {}) {
|
|
236
|
+
// Load configuration first (async file read)
|
|
237
|
+
await this.generateConfiguration();
|
|
238
|
+
|
|
197
239
|
validateErrorsLlmAtTakeoff();
|
|
198
240
|
const errorsLlm = getErrorsLlmConfig();
|
|
199
241
|
if (errorsLlm.enabled) {
|
|
@@ -201,6 +243,11 @@ class Tejas {
|
|
|
201
243
|
`errors.llm enabled successfully — baseURL: ${errorsLlm.baseURL}, model: ${errorsLlm.model}, messageType: ${errorsLlm.messageType}, apiKey: ${errorsLlm.apiKey ? '***' : '(missing)'}`,
|
|
202
244
|
);
|
|
203
245
|
}
|
|
246
|
+
|
|
247
|
+
// Register target files before the server starts listening so no request
|
|
248
|
+
// can arrive before all routes are fully registered.
|
|
249
|
+
await this.registerTargetsDir();
|
|
250
|
+
|
|
204
251
|
this.engine = createServer(targetHandler);
|
|
205
252
|
this.engine.listen(env('PORT'), async () => {
|
|
206
253
|
logger.info(`Took off from port ${env('PORT')}`);
|
|
@@ -408,6 +455,82 @@ class Tejas {
|
|
|
408
455
|
return this;
|
|
409
456
|
}
|
|
410
457
|
|
|
458
|
+
/**
|
|
459
|
+
* Enables Tejas Radar telemetry — captures HTTP request metrics and forwards
|
|
460
|
+
* them to a Radar collector for real-time observability.
|
|
461
|
+
*
|
|
462
|
+
* All options fall back to environment variables and sensible defaults, so
|
|
463
|
+
* the minimum viable call is just `app.withRadar({ apiKey: 'rdr_xxx' })`.
|
|
464
|
+
* The project name is auto-detected from `package.json` if not supplied.
|
|
465
|
+
*
|
|
466
|
+
* @param {Object} [config] - Radar configuration
|
|
467
|
+
* @param {string} [config.collectorUrl] Collector base URL (default: RADAR_COLLECTOR_URL env or http://localhost:3100)
|
|
468
|
+
* @param {string} [config.apiKey] Bearer token `rdr_xxx` (default: RADAR_API_KEY env)
|
|
469
|
+
* @param {string} [config.projectName] Project identifier (default: RADAR_PROJECT_NAME env → package.json name → "tejas-app")
|
|
470
|
+
* @param {number} [config.flushInterval] Milliseconds between periodic flushes (default: 2000)
|
|
471
|
+
* @param {number} [config.batchSize] Flush immediately when batch reaches this size (default: 100)
|
|
472
|
+
* @param {string[]} [config.ignore] Request paths to skip (default: ['/health'])
|
|
473
|
+
*
|
|
474
|
+
* @param {Object} [config.capture] Controls what additional data is captured and sent to the collector.
|
|
475
|
+
* All capture options default to `false` — nothing beyond standard
|
|
476
|
+
* metrics is sent unless explicitly enabled.
|
|
477
|
+
* @param {boolean} [config.capture.request=false]
|
|
478
|
+
* Capture and send the request body. The body is a shallow copy of parsed
|
|
479
|
+
* query params and request body fields merged together. Only JSON-serialisable
|
|
480
|
+
* content is sent. The collector applies non-bypassable GDPR field masking
|
|
481
|
+
* server-side regardless of this setting.
|
|
482
|
+
* @param {boolean} [config.capture.response=false]
|
|
483
|
+
* Capture and send the response body. The response must be valid JSON;
|
|
484
|
+
* non-JSON responses are recorded as `null`. The collector applies
|
|
485
|
+
* non-bypassable GDPR field masking server-side.
|
|
486
|
+
* @param {boolean|string[]} [config.capture.headers=false]
|
|
487
|
+
* Capture request headers. Pass `true` to send all headers, or a `string[]`
|
|
488
|
+
* allowlist of specific header names to send (e.g. `['content-type', 'x-request-id']`).
|
|
489
|
+
* The collector always strips sensitive headers (`authorization`, `cookie`,
|
|
490
|
+
* `set-cookie`, `x-api-key`, etc.) server-side regardless of what is sent.
|
|
491
|
+
*
|
|
492
|
+
* @param {Object} [config.mask] Client-side masking applied to request/response bodies
|
|
493
|
+
* before data is sent to the collector.
|
|
494
|
+
* @param {string[]} [config.mask.fields] Extra field names (case-insensitive) to mask client-side.
|
|
495
|
+
* Matched field values are replaced with `"*"` before leaving
|
|
496
|
+
* the process. Use this for application-specific sensitive fields
|
|
497
|
+
* that are not on the collector's built-in GDPR blocklist.
|
|
498
|
+
* Note: the collector enforces its own non-bypassable masking
|
|
499
|
+
* layer server-side regardless of this setting.
|
|
500
|
+
*
|
|
501
|
+
* @returns {Tejas} The Tejas instance for chaining
|
|
502
|
+
*
|
|
503
|
+
* @example
|
|
504
|
+
* app.withRadar({ apiKey: process.env.RADAR_API_KEY });
|
|
505
|
+
* app.takeoff();
|
|
506
|
+
*
|
|
507
|
+
* @example
|
|
508
|
+
* app.withRadar({
|
|
509
|
+
* collectorUrl: 'https://collector.example.com',
|
|
510
|
+
* apiKey: process.env.RADAR_API_KEY,
|
|
511
|
+
* projectName: 'my-api',
|
|
512
|
+
* });
|
|
513
|
+
*
|
|
514
|
+
* @example
|
|
515
|
+
* // Capture request/response bodies and selected headers,
|
|
516
|
+
* // with extra client-side masking for app-specific fields.
|
|
517
|
+
* app.withRadar({
|
|
518
|
+
* apiKey: process.env.RADAR_API_KEY,
|
|
519
|
+
* capture: {
|
|
520
|
+
* request: true,
|
|
521
|
+
* response: true,
|
|
522
|
+
* headers: ['content-type', 'x-request-id'],
|
|
523
|
+
* },
|
|
524
|
+
* mask: {
|
|
525
|
+
* fields: ['account_number', 'internal_id'],
|
|
526
|
+
* },
|
|
527
|
+
* });
|
|
528
|
+
*/
|
|
529
|
+
withRadar(config = {}) {
|
|
530
|
+
this.midair(radarMiddleware(config));
|
|
531
|
+
return this;
|
|
532
|
+
}
|
|
533
|
+
|
|
411
534
|
/**
|
|
412
535
|
* Serves the API documentation at GET /docs and GET /docs/openapi.json from a pre-generated spec file.
|
|
413
536
|
* Generate the spec with `tejas generate:docs`, then call this to serve it on your app.
|
|
@@ -449,6 +572,12 @@ export { default as Target } from './server/target.js';
|
|
|
449
572
|
export { default as TejFileUploader } from './server/files/uploader.js';
|
|
450
573
|
export { default as TejError } from './server/error.js';
|
|
451
574
|
export { listAllEndpoints };
|
|
575
|
+
export {
|
|
576
|
+
contextMiddleware,
|
|
577
|
+
getRequestId,
|
|
578
|
+
getRequestStore,
|
|
579
|
+
requestContext,
|
|
580
|
+
} from './server/context/request-context.js';
|
|
452
581
|
|
|
453
582
|
export default Tejas;
|
|
454
583
|
|
package/utils/auto-register.js
CHANGED
package/utils/configuration.js
CHANGED
|
@@ -1,21 +1,35 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Configuration loading and normalization utilities.
|
|
3
|
+
*
|
|
4
|
+
* Loads `tejas.config.json` from `process.cwd()` and provides helpers to
|
|
5
|
+
* standardize and flatten config objects so they can be merged with env vars
|
|
6
|
+
* and constructor options.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from 'node:fs';
|
|
2
10
|
import { getAllEnv } from 'tej-env';
|
|
3
11
|
|
|
4
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Asynchronously read and parse `tejas.config.json` from the current working directory.
|
|
14
|
+
* Returns an empty null-prototype object if the file is missing or unreadable.
|
|
15
|
+
*
|
|
16
|
+
* @returns {Promise<Object>} Parsed config object (may be empty)
|
|
17
|
+
*/
|
|
18
|
+
const loadConfigFile = async () => {
|
|
5
19
|
try {
|
|
6
|
-
const data = fs.
|
|
20
|
+
const data = await fs.promises.readFile('tejas.config.json', 'utf8');
|
|
7
21
|
return JSON.parse(data);
|
|
8
22
|
} catch (err) {
|
|
9
|
-
return
|
|
23
|
+
return Object.create(null);
|
|
10
24
|
}
|
|
11
25
|
};
|
|
12
26
|
|
|
13
27
|
const keysToUpperCase = (obj) => {
|
|
14
|
-
if (!obj) return
|
|
15
|
-
const standardObj =
|
|
28
|
+
if (!obj) return Object.create(null);
|
|
29
|
+
const standardObj = Object.create(null);
|
|
16
30
|
|
|
17
31
|
for (const key in obj) {
|
|
18
|
-
if (
|
|
32
|
+
if (Object.hasOwn(obj, key)) {
|
|
19
33
|
const value = obj[key];
|
|
20
34
|
const upperKey = key.toUpperCase();
|
|
21
35
|
|
|
@@ -31,10 +45,10 @@ const keysToUpperCase = (obj) => {
|
|
|
31
45
|
};
|
|
32
46
|
|
|
33
47
|
const flattenObject = (obj, prefix = '') => {
|
|
34
|
-
let flattened =
|
|
48
|
+
let flattened = Object.create(null);
|
|
35
49
|
|
|
36
50
|
for (const key in obj) {
|
|
37
|
-
if (
|
|
51
|
+
if (Object.hasOwn(obj, key)) {
|
|
38
52
|
const value = obj[key];
|
|
39
53
|
const newKey = prefix ? `${prefix}_${key}` : key;
|
|
40
54
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Tests for utils/configuration.js
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
5
|
+
|
|
6
|
+
describe('loadConfigFile', () => {
|
|
7
|
+
let loadConfigFile;
|
|
8
|
+
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
// Use dynamic import and mock fs to avoid hitting real filesystem
|
|
11
|
+
vi.resetModules();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
vi.restoreAllMocks();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should return a null-prototype object on file not found', async () => {
|
|
19
|
+
vi.doMock('node:fs', () => ({
|
|
20
|
+
default: {},
|
|
21
|
+
promises: {
|
|
22
|
+
readFile: vi.fn().mockRejectedValue(new Error('ENOENT')),
|
|
23
|
+
},
|
|
24
|
+
}));
|
|
25
|
+
const mod = await import('./configuration.js');
|
|
26
|
+
const result = await mod.loadConfigFile();
|
|
27
|
+
expect(Object.getPrototypeOf(result)).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should parse valid JSON config file', async () => {
|
|
31
|
+
const config = { port: 8080, db: { url: 'mongodb://localhost' } };
|
|
32
|
+
vi.doMock('node:fs', () => ({
|
|
33
|
+
default: {},
|
|
34
|
+
promises: {
|
|
35
|
+
readFile: vi.fn().mockResolvedValue(JSON.stringify(config)),
|
|
36
|
+
},
|
|
37
|
+
}));
|
|
38
|
+
const mod = await import('./configuration.js');
|
|
39
|
+
const result = await mod.loadConfigFile();
|
|
40
|
+
expect(result.port).toBe(8080);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('standardizeObj', () => {
|
|
45
|
+
it('should uppercase keys and flatten nested objects', async () => {
|
|
46
|
+
vi.resetModules();
|
|
47
|
+
const { standardizeObj } = await import('./configuration.js');
|
|
48
|
+
const result = standardizeObj({ db: { url: 'test', port: 5432 } });
|
|
49
|
+
expect(result['DB_URL']).toBe('test');
|
|
50
|
+
expect(result['DB_PORT']).toBe(5432);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should handle null/undefined gracefully', async () => {
|
|
54
|
+
const { standardizeObj } = await import('./configuration.js');
|
|
55
|
+
expect(() => standardizeObj(null)).not.toThrow();
|
|
56
|
+
expect(() => standardizeObj(undefined)).not.toThrow();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { env } from 'tej-env';
|
|
8
|
+
import TejLogger from 'tej-logger';
|
|
9
|
+
|
|
10
|
+
const logger = new TejLogger('Tejas.ErrorsLlm');
|
|
8
11
|
|
|
9
12
|
const MESSAGE_TYPES = /** @type {const} */ (['endUser', 'developer']);
|
|
10
13
|
const LLM_MODES = /** @type {const} */ (['sync', 'async']);
|
|
@@ -134,7 +137,7 @@ export function getErrorsLlmConfig() {
|
|
|
134
137
|
? 3600000
|
|
135
138
|
: cacheTTLNum;
|
|
136
139
|
|
|
137
|
-
return {
|
|
140
|
+
return Object.freeze({
|
|
138
141
|
enabled: Boolean(enabled),
|
|
139
142
|
baseURL: String(baseURL ?? '').trim(),
|
|
140
143
|
apiKey: String(apiKey ?? '').trim(),
|
|
@@ -147,7 +150,7 @@ export function getErrorsLlmConfig() {
|
|
|
147
150
|
rateLimit,
|
|
148
151
|
cache,
|
|
149
152
|
cacheTTL,
|
|
150
|
-
};
|
|
153
|
+
});
|
|
151
154
|
}
|
|
152
155
|
|
|
153
156
|
export { MESSAGE_TYPES, LLM_MODES, LLM_CHANNELS };
|
|
@@ -187,8 +190,8 @@ export function validateErrorsLlmAtTakeoff() {
|
|
|
187
190
|
env('ERRORS_LLM_CHANNEL') ?? env('LLM_CHANNEL') ?? '',
|
|
188
191
|
).trim();
|
|
189
192
|
if (mode === 'sync' && channelRaw) {
|
|
190
|
-
|
|
191
|
-
`
|
|
193
|
+
logger.warn(
|
|
194
|
+
`errors.llm: channel="${channel}" is set but mode is "sync" — channel output only applies in async mode. Set ERRORS_LLM_MODE=async to use it.`,
|
|
192
195
|
);
|
|
193
196
|
}
|
|
194
197
|
|
|
@@ -200,15 +203,15 @@ export function validateErrorsLlmAtTakeoff() {
|
|
|
200
203
|
rateLimitRaw &&
|
|
201
204
|
(isNaN(Number(rateLimitRaw)) || Number(rateLimitRaw) <= 0)
|
|
202
205
|
) {
|
|
203
|
-
|
|
204
|
-
`
|
|
206
|
+
logger.warn(
|
|
207
|
+
`errors.llm: rateLimit value "${rateLimitRaw}" is invalid; defaulting to 10.`,
|
|
205
208
|
);
|
|
206
209
|
}
|
|
207
210
|
|
|
208
211
|
const cacheTTLRaw = String(env('ERRORS_LLM_CACHE_TTL') ?? '').trim();
|
|
209
212
|
if (cacheTTLRaw && (isNaN(Number(cacheTTLRaw)) || Number(cacheTTLRaw) <= 0)) {
|
|
210
|
-
|
|
211
|
-
`
|
|
213
|
+
logger.warn(
|
|
214
|
+
`errors.llm: cacheTTL value "${cacheTTLRaw}" is invalid; defaulting to 3600000.`,
|
|
212
215
|
);
|
|
213
216
|
}
|
|
214
217
|
}
|
package/utils/request-logger.js
CHANGED
|
@@ -5,11 +5,46 @@ import TejLogger from 'tej-logger';
|
|
|
5
5
|
const logger = new TejLogger('Tejas.Request');
|
|
6
6
|
const { italic, bold, blue, white, bgGreen, bgRed, whiteBright } = ansi;
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Best-effort field names to mask when logging request/response bodies to the
|
|
10
|
+
* console. This is a hardcoded safety net — it does not replace the
|
|
11
|
+
* non-bypassable scrubbing enforced by the Radar collector on telemetry data.
|
|
12
|
+
*/
|
|
13
|
+
const CONSOLE_MASK_FIELDS = new Set([
|
|
14
|
+
'password',
|
|
15
|
+
'passwd',
|
|
16
|
+
'secret',
|
|
17
|
+
'token',
|
|
18
|
+
'authorization',
|
|
19
|
+
'api_key',
|
|
20
|
+
'apikey',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Recursively mask sensitive fields in a value for safe console output.
|
|
25
|
+
* Replaces matched key values with `"*"`.
|
|
26
|
+
*
|
|
27
|
+
* @param {unknown} value
|
|
28
|
+
* @returns {unknown}
|
|
29
|
+
*/
|
|
30
|
+
function maskForLog(value) {
|
|
31
|
+
if (value === null || typeof value !== 'object') return value;
|
|
32
|
+
if (Array.isArray(value)) return value.map(maskForLog);
|
|
33
|
+
|
|
34
|
+
const result = Object.create(null);
|
|
35
|
+
for (const [k, v] of Object.entries(value)) {
|
|
36
|
+
result[k] = CONSOLE_MASK_FIELDS.has(k.toLowerCase()) ? '*' : maskForLog(v);
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
|
|
8
41
|
function logHttpRequest(ammo, next) {
|
|
9
42
|
if (!env('LOG_HTTP_REQUESTS')) return;
|
|
10
43
|
|
|
11
44
|
const startTime = new Date();
|
|
12
|
-
|
|
45
|
+
const controller = new AbortController();
|
|
46
|
+
ammo.res.on('finish', { signal: controller.signal }, () => {
|
|
47
|
+
controller.abort();
|
|
13
48
|
const res = ammo.res;
|
|
14
49
|
const method = italic(whiteBright(ammo.method));
|
|
15
50
|
const endpoint = bold(ammo.endpoint);
|
|
@@ -19,10 +54,21 @@ function logHttpRequest(ammo, next) {
|
|
|
19
54
|
: bgGreen(whiteBright(bold(`✔ ${res.statusCode}`)));
|
|
20
55
|
|
|
21
56
|
const duration = white(`${new Date() - startTime}ms`);
|
|
57
|
+
|
|
58
|
+
const maskedPayload = maskForLog(ammo.payload);
|
|
22
59
|
const payload = `${blue('Request')}: ${white(
|
|
23
|
-
JSON.stringify(
|
|
60
|
+
JSON.stringify(maskedPayload),
|
|
24
61
|
)}`;
|
|
25
|
-
|
|
62
|
+
|
|
63
|
+
let maskedResponse = ammo.dispatchedData;
|
|
64
|
+
try {
|
|
65
|
+
maskedResponse = JSON.stringify(
|
|
66
|
+
maskForLog(JSON.parse(ammo.dispatchedData)),
|
|
67
|
+
);
|
|
68
|
+
} catch {
|
|
69
|
+
// Non-JSON response — log as-is
|
|
70
|
+
}
|
|
71
|
+
const dispatchedData = `${blue('Response')}: ${white(maskedResponse)}`;
|
|
26
72
|
const nextLine = '\n';
|
|
27
73
|
|
|
28
74
|
logger.log(
|