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/radar/index.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import TejLogger from 'tej-logger';
|
|
5
|
+
|
|
6
|
+
const logger = new TejLogger('Tejas.Radar');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Attempt to read the `name` field from the nearest package.json at startup.
|
|
10
|
+
* Returns null if the file cannot be read or parsed.
|
|
11
|
+
* @returns {Promise<string|null>}
|
|
12
|
+
*/
|
|
13
|
+
async function readPackageJsonName() {
|
|
14
|
+
try {
|
|
15
|
+
const raw = await readFile(join(process.cwd(), 'package.json'), 'utf8');
|
|
16
|
+
return JSON.parse(raw).name ?? null;
|
|
17
|
+
} catch (err) {
|
|
18
|
+
logger.warn(`Could not read package.json name: ${err?.message ?? err}`);
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Recursively walk a plain object/array and replace the value of any key whose
|
|
25
|
+
* lowercase name appears in `blocklist` with the string `"*"`. Returns a new
|
|
26
|
+
* deep-cloned structure; the original is never mutated.
|
|
27
|
+
*
|
|
28
|
+
* Non-object values (strings, numbers, null, …) are returned as-is.
|
|
29
|
+
*
|
|
30
|
+
* @param {unknown} value
|
|
31
|
+
* @param {Set<string>} blocklist Lower-cased field names to mask.
|
|
32
|
+
* @returns {unknown}
|
|
33
|
+
*/
|
|
34
|
+
function deepMask(value, blocklist) {
|
|
35
|
+
if (value === null || typeof value !== 'object') return value;
|
|
36
|
+
|
|
37
|
+
if (Array.isArray(value)) {
|
|
38
|
+
return value.map((item) => deepMask(item, blocklist));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const result = Object.create(null);
|
|
42
|
+
for (const [k, v] of Object.entries(value)) {
|
|
43
|
+
result[k] = blocklist.has(k.toLowerCase()) ? '*' : deepMask(v, blocklist);
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the headers object to include in the metric record based on the
|
|
50
|
+
* `capture.headers` configuration value:
|
|
51
|
+
* - `false` → null (default; nothing sent)
|
|
52
|
+
* - `true` → shallow copy of all headers
|
|
53
|
+
* - `string[]` → object containing only the listed header names
|
|
54
|
+
*
|
|
55
|
+
* @param {Record<string, string>|undefined} rawHeaders
|
|
56
|
+
* @param {boolean|string[]} captureHeaders
|
|
57
|
+
* @returns {Record<string, string>|null}
|
|
58
|
+
*/
|
|
59
|
+
function buildHeaders(rawHeaders, captureHeaders) {
|
|
60
|
+
if (!captureHeaders || !rawHeaders) return null;
|
|
61
|
+
if (captureHeaders === true) return { ...rawHeaders };
|
|
62
|
+
return Object.fromEntries(
|
|
63
|
+
captureHeaders
|
|
64
|
+
.map((k) => [k, rawHeaders[k.toLowerCase()]])
|
|
65
|
+
.filter(([, v]) => v != null),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Attempt to parse a JSON string. Returns the parsed value on success, or
|
|
71
|
+
* `null` on failure. Used for response bodies which may not always be JSON.
|
|
72
|
+
*
|
|
73
|
+
* @param {string|undefined|null} raw
|
|
74
|
+
* @returns {unknown}
|
|
75
|
+
*/
|
|
76
|
+
function parseJsonSafe(raw) {
|
|
77
|
+
if (!raw) return null;
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(raw);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
logger.warn(`parseJsonSafe: JSON parse failed — ${err?.message ?? err}`);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Factory that returns a te.js-compatible `(ammo, next)` middleware which
|
|
88
|
+
* captures HTTP request metrics and forwards them to the Tejas Radar collector.
|
|
89
|
+
*
|
|
90
|
+
* @param {Object} [config]
|
|
91
|
+
* @param {string} [config.apiKey] Bearer token (rdr_xxx). Falls back to RADAR_API_KEY env. Required.
|
|
92
|
+
* @param {string} [config.projectName] Project identifier. Falls back to RADAR_PROJECT_NAME env, then package.json `name`, then "tejas-app".
|
|
93
|
+
* @param {number} [config.flushInterval] Milliseconds between periodic flushes (default 2000).
|
|
94
|
+
* @param {number} [config.batchSize] Flush immediately when batch reaches this size (default 100).
|
|
95
|
+
* @param {string[]} [config.ignore] Request paths to skip (default ['/health']).
|
|
96
|
+
* @param {Object} [config.capture] Controls what additional data is captured beyond metrics.
|
|
97
|
+
* @param {boolean} [config.capture.request] Capture and send request body (default false).
|
|
98
|
+
* @param {boolean} [config.capture.response] Capture and send response body (default false).
|
|
99
|
+
* @param {boolean|string[]} [config.capture.headers] Capture request headers. `true` sends all headers;
|
|
100
|
+
* a `string[]` sends only the named headers (allowlist);
|
|
101
|
+
* `false` (default) sends nothing.
|
|
102
|
+
* @param {Object} [config.mask] Client-side masking applied before data is sent.
|
|
103
|
+
* @param {string[]} [config.mask.fields] Extra field names to mask in request/response bodies.
|
|
104
|
+
* These are merged with the collector's server-side GDPR blocklist.
|
|
105
|
+
* Note: the collector enforces its own non-bypassable masking
|
|
106
|
+
* regardless of this setting.
|
|
107
|
+
* @returns {Promise<Function>} Middleware function `(ammo, next)`
|
|
108
|
+
*/
|
|
109
|
+
async function radarMiddleware(config = {}) {
|
|
110
|
+
// RADAR_COLLECTOR_URL is an undocumented internal escape hatch used only
|
|
111
|
+
// during local development. In production, telemetry always goes to the
|
|
112
|
+
// hosted collector and this env var should not be set.
|
|
113
|
+
const collectorUrl =
|
|
114
|
+
process.env.RADAR_COLLECTOR_URL ?? 'http://localhost:3100';
|
|
115
|
+
|
|
116
|
+
const apiKey = config.apiKey ?? process.env.RADAR_API_KEY ?? null;
|
|
117
|
+
|
|
118
|
+
const projectName =
|
|
119
|
+
config.projectName ??
|
|
120
|
+
process.env.RADAR_PROJECT_NAME ??
|
|
121
|
+
(await readPackageJsonName()) ??
|
|
122
|
+
'tejas-app';
|
|
123
|
+
|
|
124
|
+
const flushInterval = config.flushInterval ?? 2000;
|
|
125
|
+
const batchSize = config.batchSize ?? 100;
|
|
126
|
+
const ignorePaths = new Set(config.ignore ?? ['/health']);
|
|
127
|
+
|
|
128
|
+
const capture = Object.freeze({
|
|
129
|
+
request: config.capture?.request === true,
|
|
130
|
+
response: config.capture?.response === true,
|
|
131
|
+
headers: config.capture?.headers ?? false,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Build the client-side field blocklist from developer-supplied extra fields.
|
|
135
|
+
// The collector enforces its own non-bypassable GDPR blocklist server-side;
|
|
136
|
+
// this is an additional best-effort layer for application-specific fields.
|
|
137
|
+
const clientMaskBlocklist = new Set(
|
|
138
|
+
(config.mask?.fields ?? []).map((f) => f.toLowerCase()),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
if (!apiKey) {
|
|
142
|
+
logger.warn(
|
|
143
|
+
'No API key provided (config.apiKey or RADAR_API_KEY). Radar telemetry disabled.',
|
|
144
|
+
);
|
|
145
|
+
return (_ammo, next) => next();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const ingestUrl = `${collectorUrl}/ingest`;
|
|
149
|
+
const healthUrl = `${collectorUrl}/health`;
|
|
150
|
+
const authHeader = `Bearer ${apiKey}`;
|
|
151
|
+
|
|
152
|
+
/** @type {Array<Object>} */
|
|
153
|
+
let batch = [];
|
|
154
|
+
let connected = false;
|
|
155
|
+
|
|
156
|
+
logger.info(`Checking Radar connectivity — ${collectorUrl}`);
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const healthRes = await fetch(healthUrl);
|
|
160
|
+
if (healthRes.ok) {
|
|
161
|
+
logger.info(`Radar collector reachable at ${collectorUrl}`);
|
|
162
|
+
} else {
|
|
163
|
+
logger.warn(
|
|
164
|
+
`Radar collector responded with ${healthRes.status} on /health — check collector status.`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
} catch (err) {
|
|
168
|
+
logger.warn(
|
|
169
|
+
`Radar collector unreachable at ${collectorUrl}: ${err.message}`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function flush() {
|
|
174
|
+
if (batch.length === 0) return;
|
|
175
|
+
const payload = batch;
|
|
176
|
+
batch = [];
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const res = await fetch(ingestUrl, {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
headers: {
|
|
182
|
+
'Content-Type': 'application/json',
|
|
183
|
+
Authorization: authHeader,
|
|
184
|
+
},
|
|
185
|
+
body: JSON.stringify(payload),
|
|
186
|
+
});
|
|
187
|
+
if (res.ok && !connected) {
|
|
188
|
+
connected = true;
|
|
189
|
+
logger.info(
|
|
190
|
+
`Connected — project: "${projectName}", collector: ${collectorUrl}`,
|
|
191
|
+
);
|
|
192
|
+
} else if (res.status === 401 && connected) {
|
|
193
|
+
connected = false;
|
|
194
|
+
logger.warn('Radar API key rejected by collector.');
|
|
195
|
+
} else if (res.status === 401) {
|
|
196
|
+
logger.warn(
|
|
197
|
+
'Radar API key rejected by collector. Telemetry will not be recorded.',
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
} catch (err) {
|
|
201
|
+
logger.warn(`Radar flush failed: ${err.message}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const timer = setInterval(flush, flushInterval);
|
|
206
|
+
if (timer.unref) timer.unref();
|
|
207
|
+
|
|
208
|
+
return function radarCapture(ammo, next) {
|
|
209
|
+
const startTime = Date.now();
|
|
210
|
+
|
|
211
|
+
ammo.res.on('finish', () => {
|
|
212
|
+
const path = ammo.endpoint ?? ammo.path ?? '/';
|
|
213
|
+
|
|
214
|
+
if (ammo.method === 'OPTIONS' || ignorePaths.has(path)) return;
|
|
215
|
+
|
|
216
|
+
const status = ammo.res.statusCode;
|
|
217
|
+
const errorInfo = ammo._errorInfo ?? null;
|
|
218
|
+
|
|
219
|
+
// Build structured error JSON for the logs table when an error occurred.
|
|
220
|
+
let errorField = null;
|
|
221
|
+
if (status >= 400 && errorInfo) {
|
|
222
|
+
errorField = JSON.stringify({
|
|
223
|
+
message: errorInfo.message ?? null,
|
|
224
|
+
type: errorInfo.type ?? null,
|
|
225
|
+
devInsight: errorInfo.devInsight ?? null,
|
|
226
|
+
codeContext: errorInfo.codeContext ?? null,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
batch.push({
|
|
231
|
+
type: 'log',
|
|
232
|
+
projectName,
|
|
233
|
+
method: ammo.method,
|
|
234
|
+
path,
|
|
235
|
+
status,
|
|
236
|
+
duration_ms: Date.now() - startTime,
|
|
237
|
+
payload_size: Buffer.byteLength(
|
|
238
|
+
JSON.stringify(ammo.payload ?? {}),
|
|
239
|
+
'utf8',
|
|
240
|
+
),
|
|
241
|
+
response_size: Buffer.byteLength(ammo.dispatchedData ?? '', 'utf8'),
|
|
242
|
+
timestamp: Date.now(),
|
|
243
|
+
ip: ammo.ip ?? null,
|
|
244
|
+
traceId: null,
|
|
245
|
+
user_agent: ammo.headers?.['user-agent'] ?? null,
|
|
246
|
+
headers: buildHeaders(ammo.headers, capture.headers),
|
|
247
|
+
request_body: capture.request
|
|
248
|
+
? deepMask(ammo.payload ?? null, clientMaskBlocklist)
|
|
249
|
+
: null,
|
|
250
|
+
response_body: capture.response
|
|
251
|
+
? deepMask(parseJsonSafe(ammo.dispatchedData), clientMaskBlocklist)
|
|
252
|
+
: null,
|
|
253
|
+
error: errorField,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Emit a separate ErrorEvent for error grouping and tracking when status >= 400.
|
|
257
|
+
if (status >= 400) {
|
|
258
|
+
const message = errorInfo?.message ?? `HTTP ${status}`;
|
|
259
|
+
const fingerprint = createHash('sha256')
|
|
260
|
+
.update(`${message}:${path}`)
|
|
261
|
+
.digest('hex');
|
|
262
|
+
batch.push({
|
|
263
|
+
type: 'error',
|
|
264
|
+
projectName,
|
|
265
|
+
fingerprint,
|
|
266
|
+
message,
|
|
267
|
+
stack: errorInfo?.stack ?? null,
|
|
268
|
+
endpoint: `${ammo.method} ${path}`,
|
|
269
|
+
traceId: null,
|
|
270
|
+
timestamp: Date.now(),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (batch.length >= batchSize) flush();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
next();
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export default radarMiddleware;
|
package/rate-limit/index.js
CHANGED
|
@@ -47,12 +47,10 @@ function rateLimiter(options) {
|
|
|
47
47
|
);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
'fixed-window': 'fixedWindowConfig',
|
|
55
|
-
};
|
|
50
|
+
const configMap = Object.create(null);
|
|
51
|
+
configMap['token-bucket'] = 'tokenBucketConfig';
|
|
52
|
+
configMap['sliding-window'] = 'slidingWindowConfig';
|
|
53
|
+
configMap['fixed-window'] = 'fixedWindowConfig';
|
|
56
54
|
|
|
57
55
|
const configKey = configMap[algorithm];
|
|
58
56
|
if (!configKey) {
|
|
@@ -62,13 +60,12 @@ function rateLimiter(options) {
|
|
|
62
60
|
);
|
|
63
61
|
}
|
|
64
62
|
|
|
65
|
-
|
|
66
|
-
const limiterConfig = {
|
|
63
|
+
const limiterConfig = Object.freeze({
|
|
67
64
|
maxRequests: limiterOptions.maxRequests,
|
|
68
65
|
timeWindowSeconds: limiterOptions.timeWindowSeconds,
|
|
69
66
|
[configKey]: limiterOptions.algorithmOptions || {},
|
|
70
|
-
store,
|
|
71
|
-
};
|
|
67
|
+
store,
|
|
68
|
+
});
|
|
72
69
|
|
|
73
70
|
// Create the appropriate limiter instance
|
|
74
71
|
let limiter;
|
|
@@ -128,7 +125,7 @@ function rateLimiter(options) {
|
|
|
128
125
|
|
|
129
126
|
// Return middleware function
|
|
130
127
|
return async (ammo, next) => {
|
|
131
|
-
const key = keyGenerator(ammo);
|
|
128
|
+
const key = keyGenerator(ammo) ?? ammo.ip ?? 'unknown';
|
|
132
129
|
const result = await limiter.consume(key);
|
|
133
130
|
|
|
134
131
|
setRateLimitHeaders(ammo, result);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Tests for the rate limiter middleware factory.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
5
|
+
import rateLimiter from './index.js';
|
|
6
|
+
|
|
7
|
+
// Mock dbManager.hasConnection so we can test without a real DB
|
|
8
|
+
vi.mock('../database/index.js', () => ({
|
|
9
|
+
default: {
|
|
10
|
+
hasConnection: vi.fn(() => false),
|
|
11
|
+
initializeConnection: vi.fn(),
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
function makeAmmo(ip = '127.0.0.1') {
|
|
16
|
+
const headers = {};
|
|
17
|
+
return {
|
|
18
|
+
ip,
|
|
19
|
+
res: {
|
|
20
|
+
setHeader: vi.fn(),
|
|
21
|
+
},
|
|
22
|
+
throw: vi.fn(),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('rateLimiter', () => {
|
|
27
|
+
it('should throw TejError when redis store selected but no connection', async () => {
|
|
28
|
+
const TejError = (await import('../server/error.js')).default;
|
|
29
|
+
expect(() =>
|
|
30
|
+
rateLimiter({ maxRequests: 10, timeWindowSeconds: 60, store: 'redis' }),
|
|
31
|
+
).toThrow();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should throw on invalid algorithm', () => {
|
|
35
|
+
expect(() =>
|
|
36
|
+
rateLimiter({
|
|
37
|
+
maxRequests: 10,
|
|
38
|
+
timeWindowSeconds: 60,
|
|
39
|
+
algorithm: 'invalid-algo',
|
|
40
|
+
}),
|
|
41
|
+
).toThrow();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should return a middleware function', () => {
|
|
45
|
+
const mw = rateLimiter({ maxRequests: 100, timeWindowSeconds: 60 });
|
|
46
|
+
expect(typeof mw).toBe('function');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should call next when under limit', async () => {
|
|
50
|
+
const mw = rateLimiter({ maxRequests: 100, timeWindowSeconds: 60 });
|
|
51
|
+
const ammo = makeAmmo();
|
|
52
|
+
const next = vi.fn().mockResolvedValue(undefined);
|
|
53
|
+
await mw(ammo, next);
|
|
54
|
+
expect(next).toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should use fallback key "unknown" when ammo.ip is undefined', async () => {
|
|
58
|
+
const mw = rateLimiter({ maxRequests: 100, timeWindowSeconds: 60 });
|
|
59
|
+
const ammo = makeAmmo(undefined);
|
|
60
|
+
const next = vi.fn().mockResolvedValue(undefined);
|
|
61
|
+
await mw(ammo, next);
|
|
62
|
+
expect(next).toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
});
|