te.js 2.1.5 → 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.
Files changed (49) hide show
  1. package/auto-docs/analysis/handler-analyzer.test.js +106 -0
  2. package/auto-docs/analysis/source-resolver.test.js +58 -0
  3. package/auto-docs/constants.js +13 -2
  4. package/auto-docs/openapi/generator.js +7 -5
  5. package/auto-docs/openapi/generator.test.js +132 -0
  6. package/auto-docs/openapi/spec-builders.js +39 -19
  7. package/cli/docs-command.js +44 -36
  8. package/cors/index.test.js +82 -0
  9. package/database/index.js +3 -1
  10. package/database/mongodb.js +17 -11
  11. package/database/redis.js +53 -44
  12. package/docs/configuration.md +24 -10
  13. package/docs/error-handling.md +134 -50
  14. package/lib/llm/client.js +40 -10
  15. package/lib/llm/index.js +14 -1
  16. package/lib/llm/parse.test.js +60 -0
  17. package/package.json +3 -1
  18. package/radar/index.js +281 -0
  19. package/rate-limit/index.js +8 -11
  20. package/rate-limit/index.test.js +64 -0
  21. package/server/ammo/body-parser.js +156 -152
  22. package/server/ammo/body-parser.test.js +79 -0
  23. package/server/ammo/enhancer.js +8 -4
  24. package/server/ammo.js +216 -17
  25. package/server/context/request-context.js +51 -0
  26. package/server/context/request-context.test.js +53 -0
  27. package/server/endpoint.js +15 -0
  28. package/server/error.js +56 -3
  29. package/server/error.test.js +45 -0
  30. package/server/errors/channels/base.js +31 -0
  31. package/server/errors/channels/channels.test.js +148 -0
  32. package/server/errors/channels/console.js +64 -0
  33. package/server/errors/channels/index.js +111 -0
  34. package/server/errors/channels/log.js +27 -0
  35. package/server/errors/llm-cache.js +102 -0
  36. package/server/errors/llm-cache.test.js +160 -0
  37. package/server/errors/llm-error-service.js +77 -16
  38. package/server/errors/llm-rate-limiter.js +72 -0
  39. package/server/errors/llm-rate-limiter.test.js +105 -0
  40. package/server/files/uploader.js +38 -26
  41. package/server/handler.js +5 -3
  42. package/server/targets/registry.js +9 -9
  43. package/server/targets/registry.test.js +108 -0
  44. package/te.js +214 -57
  45. package/utils/auto-register.js +1 -1
  46. package/utils/configuration.js +23 -9
  47. package/utils/configuration.test.js +58 -0
  48. package/utils/errors-llm-config.js +142 -9
  49. 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;
@@ -47,12 +47,10 @@ function rateLimiter(options) {
47
47
  );
48
48
  }
49
49
 
50
- // Map algorithm names to their config property names
51
- const configMap = {
52
- 'token-bucket': 'tokenBucketConfig',
53
- 'sliding-window': 'slidingWindowConfig',
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
- // Create algorithm-specific config
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, // Pass the store type to the limiter
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
+ });