tina4-nodejs 3.13.38 → 3.13.40
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/CLAUDE.md +54 -5
- package/README.md +6 -6
- package/package.json +1 -1
- package/packages/core/src/api.ts +64 -1
- package/packages/core/src/auth.ts +4 -1
- package/packages/core/src/devAdmin.ts +91 -21
- package/packages/core/src/index.ts +9 -4
- package/packages/core/src/logger.ts +84 -27
- package/packages/core/src/mcp.ts +105 -12
- package/packages/core/src/metrics.ts +330 -70
- package/packages/core/src/middleware.ts +1 -1
- package/packages/core/src/queueBackends/kafkaBackend.ts +97 -0
- package/packages/core/src/router.ts +54 -6
- package/packages/core/src/server.ts +120 -22
- package/packages/core/src/sessionHandlers/mongoHandler.ts +2 -0
- package/packages/core/src/types.ts +21 -2
- package/packages/core/src/websocket.ts +419 -9
- package/packages/core/src/websocketConnection.ts +6 -0
- package/packages/orm/src/baseModel.ts +167 -22
- package/packages/orm/src/docstore.ts +819 -0
- package/packages/orm/src/index.ts +14 -0
- package/packages/orm/src/migration.ts +149 -22
- package/packages/orm/src/queryBuilder.ts +14 -2
- package/packages/orm/src/types.ts +7 -0
- package/packages/orm/src/validation.ts +14 -0
- package/packages/swagger/src/generator.ts +119 -16
- package/packages/swagger/src/ui.ts +10 -2
|
@@ -136,18 +136,23 @@ function resolveLogFilePath(logDir: string, logFile: string): string {
|
|
|
136
136
|
/**
|
|
137
137
|
* Structured logger for Tina4.
|
|
138
138
|
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
139
|
+
* Development (TINA4_DEBUG=true): colorized human-readable to stdout + file.
|
|
140
|
+
* Production (TINA4_DEBUG not truthy): clean structured JSON to stdout ONLY —
|
|
141
|
+
* no log file by default (writing logs/tina4.log inside a container bloats the
|
|
142
|
+
* writable layer + disk; 12-factor wants logs on stdout). stdout is ALWAYS on.
|
|
143
|
+
*
|
|
144
|
+
* Default file-output rule (TINA4_LOG_OUTPUT unset): the log FILE is written
|
|
145
|
+
* only in development. An explicit TINA4_LOG_OUTPUT=file/both, OR an explicit
|
|
146
|
+
* TINA4_LOG_FILE path, always forces a file (explicit wins).
|
|
141
147
|
*
|
|
142
148
|
* Env vars:
|
|
143
|
-
* TINA4_LOG_FILE — explicit log file (absolute or relative). Empty = use TINA4_LOG_DIR + tina4.log
|
|
149
|
+
* TINA4_LOG_FILE — explicit log file (absolute or relative). Setting it forces a file even in production. Empty = use TINA4_LOG_DIR + tina4.log
|
|
144
150
|
* TINA4_LOG_DIR — directory for log files (default: "logs")
|
|
145
151
|
* TINA4_LOG_FORMAT — "text" | "json" (default: "text")
|
|
146
|
-
* TINA4_LOG_OUTPUT — "stdout" | "file" | "both" (default: "stdout")
|
|
147
|
-
* TINA4_LOG_CRITICAL — "true" to enable CRITICAL level shortcut (default: "false")
|
|
152
|
+
* TINA4_LOG_OUTPUT — "stdout" | "file" | "both" (default: "stdout" → file only in dev)
|
|
148
153
|
* TINA4_LOG_ROTATE_SIZE — bytes; 0 disables rotation (default: 10485760 = 10MB)
|
|
149
154
|
* TINA4_LOG_ROTATE_KEEP — number of historical files to keep (default: 5)
|
|
150
|
-
* TINA4_LOG_LEVEL — minimum console level (default: "
|
|
155
|
+
* TINA4_LOG_LEVEL — minimum console level: DEBUG | INFO | WARNING | ERROR | CRITICAL (default: "INFO")
|
|
151
156
|
*
|
|
152
157
|
* Rotation is stdlib roll-your-own:
|
|
153
158
|
* - On each write, statSync the file. If size >= TINA4_LOG_ROTATE_SIZE, rotate.
|
|
@@ -171,10 +176,11 @@ export class Log {
|
|
|
171
176
|
minLevel: number;
|
|
172
177
|
format: "text" | "json";
|
|
173
178
|
output: "stdout" | "file" | "both";
|
|
174
|
-
|
|
179
|
+
fileEnabled: boolean;
|
|
175
180
|
} {
|
|
176
181
|
const logDir = process.env.TINA4_LOG_DIR ?? DEFAULT_LOG_DIR;
|
|
177
|
-
const
|
|
182
|
+
const explicitFile = (process.env.TINA4_LOG_FILE ?? "").trim();
|
|
183
|
+
const logFile = explicitFile || DEFAULT_LOG_FILE;
|
|
178
184
|
|
|
179
185
|
const rawSize = process.env.TINA4_LOG_ROTATE_SIZE;
|
|
180
186
|
let rotateSize = DEFAULT_ROTATE_SIZE;
|
|
@@ -203,9 +209,57 @@ export class Log {
|
|
|
203
209
|
if (out === "file") output = "file";
|
|
204
210
|
else if (out === "both") output = "both";
|
|
205
211
|
|
|
206
|
-
|
|
212
|
+
// v3.13.39: dev/prod-aware default file output (Python master, 4c6d881).
|
|
213
|
+
// When TINA4_LOG_OUTPUT is unset (default "stdout"), the log FILE is written
|
|
214
|
+
// only in development (TINA4_DEBUG truthy). In production / containers the
|
|
215
|
+
// logger is stdout-only — writing logs/tina4.log inside a container just
|
|
216
|
+
// bloats the writable layer + disk, and 12-factor wants logs on stdout for
|
|
217
|
+
// the platform to capture. Explicit TINA4_LOG_OUTPUT=file/both OR an explicit
|
|
218
|
+
// TINA4_LOG_FILE path always wins (explicit forces a file regardless of env).
|
|
219
|
+
let fileEnabled: boolean;
|
|
220
|
+
if (output === "file" || output === "both") {
|
|
221
|
+
fileEnabled = true;
|
|
222
|
+
} else if (explicitFile !== "") {
|
|
223
|
+
fileEnabled = true;
|
|
224
|
+
} else {
|
|
225
|
+
fileEnabled = !Log.isProduction();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { logDir, logFile, rotateSize, rotateKeep, minLevel, format, output, fileEnabled };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The single console-threshold predicate: does a message at `level` clear
|
|
233
|
+
* the configured minimum console level? This is the ONE place level
|
|
234
|
+
* comparison lives — both the live log() gate and the public isEnabled()
|
|
235
|
+
* predicate call it, so they can never disagree about what actually prints.
|
|
236
|
+
*/
|
|
237
|
+
private static passesThreshold(level: LogLevel, minLevel: number): boolean {
|
|
238
|
+
return (LEVEL_PRIORITY[level] ?? 0) >= minLevel;
|
|
239
|
+
}
|
|
207
240
|
|
|
208
|
-
|
|
241
|
+
/**
|
|
242
|
+
* Return true if a message at `level` would pass the configured minimum
|
|
243
|
+
* console level (TINA4_LOG_LEVEL) — the same threshold that gates stdout.
|
|
244
|
+
*
|
|
245
|
+
* This reflects CONSOLE (stdout) visibility only. The log file always
|
|
246
|
+
* records every level regardless of this threshold, so don't use it to
|
|
247
|
+
* decide whether something gets persisted — use it to skip building an
|
|
248
|
+
* expensive payload that would not be shown:
|
|
249
|
+
*
|
|
250
|
+
* if (Log.isEnabled("debug")) {
|
|
251
|
+
* Log.debug("state", expensiveSnapshot());
|
|
252
|
+
* }
|
|
253
|
+
*
|
|
254
|
+
* `level` is case-insensitive. "critical" is the highest severity (priority
|
|
255
|
+
* 4 > error 3) and flows through the ordinary threshold check like every
|
|
256
|
+
* other level — there is no toggle. It reuses the same passesThreshold()
|
|
257
|
+
* check the logger itself uses, so it never drifts from what print does.
|
|
258
|
+
*/
|
|
259
|
+
static isEnabled(level: string): boolean {
|
|
260
|
+
const cfg = Log.readEnv();
|
|
261
|
+
const lvl = (level ?? "").toUpperCase() as LogLevel;
|
|
262
|
+
return Log.passesThreshold(lvl, cfg.minLevel);
|
|
209
263
|
}
|
|
210
264
|
|
|
211
265
|
/**
|
|
@@ -257,9 +311,12 @@ export class Log {
|
|
|
257
311
|
}
|
|
258
312
|
|
|
259
313
|
/**
|
|
260
|
-
* Log a critical message.
|
|
261
|
-
*
|
|
262
|
-
*
|
|
314
|
+
* Log a critical message. CRITICAL is the highest severity (priority 4 >
|
|
315
|
+
* error 3) and ALWAYS emits like every other level — subject only to the
|
|
316
|
+
* console threshold, which it always passes at normal levels — and is always
|
|
317
|
+
* persisted to the log file (Node tees every level to a single tina4.log;
|
|
318
|
+
* critical 4 >= warning 2 so it would be in error.log on a split-file model).
|
|
319
|
+
* Matches Python master parity — there is no enable toggle.
|
|
263
320
|
*/
|
|
264
321
|
static critical(message: string, data?: unknown): void {
|
|
265
322
|
Log.log("CRITICAL", message, data);
|
|
@@ -355,9 +412,6 @@ export class Log {
|
|
|
355
412
|
private static log(level: LogLevel, message: string, data?: unknown): void {
|
|
356
413
|
const cfg = Log.readEnv();
|
|
357
414
|
|
|
358
|
-
// Critical level is opt-in; treat as no-op when disabled.
|
|
359
|
-
if (level === "CRITICAL" && !cfg.criticalEnabled) return;
|
|
360
|
-
|
|
361
415
|
const entry: LogEntry = {
|
|
362
416
|
timestamp: Log.timestamp(),
|
|
363
417
|
level,
|
|
@@ -393,7 +447,7 @@ export class Log {
|
|
|
393
447
|
const fileLine =
|
|
394
448
|
cfg.format === "json" || Log.isProduction() ? JSON.stringify(entry) : humanLine;
|
|
395
449
|
|
|
396
|
-
const shouldLog = (
|
|
450
|
+
const shouldLog = Log.passesThreshold(level, cfg.minLevel);
|
|
397
451
|
|
|
398
452
|
// Console output. v3.13.14: stdout is NOT suppressed in production —
|
|
399
453
|
// containers read PID 1 stdout (docker logs / k8s) and the old
|
|
@@ -410,17 +464,20 @@ export class Log {
|
|
|
410
464
|
}
|
|
411
465
|
}
|
|
412
466
|
|
|
413
|
-
// File output
|
|
414
|
-
// (production default) or honoured per output mode.
|
|
467
|
+
// File output (v3.13.39 — Python master, 4c6d881): gated on cfg.fileEnabled.
|
|
415
468
|
//
|
|
416
|
-
// output=stdout (default): file in dev
|
|
417
|
-
//
|
|
418
|
-
//
|
|
469
|
+
// output=stdout (default): file ONLY in dev (TINA4_DEBUG truthy);
|
|
470
|
+
// production / containers are stdout-only — no
|
|
471
|
+
// file to bloat the writable layer / disk.
|
|
472
|
+
// output=file file only — no console (always writes a file)
|
|
473
|
+
// output=both file + console (always writes a file)
|
|
474
|
+
// explicit TINA4_LOG_FILE always writes a file (explicit wins)
|
|
419
475
|
//
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
476
|
+
// readEnv() resolves all of the above into cfg.fileEnabled, so flipping
|
|
477
|
+
// that one flag gates the whole file writer (the only persisted sink).
|
|
478
|
+
if (cfg.fileEnabled) {
|
|
479
|
+
const filePath = resolveLogFilePath(cfg.logDir, cfg.logFile);
|
|
480
|
+
Log.writeToFile(filePath, fileLine, cfg.rotateSize, cfg.rotateKeep);
|
|
481
|
+
}
|
|
425
482
|
}
|
|
426
483
|
}
|
package/packages/core/src/mcp.ts
CHANGED
|
@@ -187,6 +187,14 @@ export function schemaFromParams(params: McpToolParam[]): JsonSchema {
|
|
|
187
187
|
|
|
188
188
|
// ── Localhost detection ──────────────────────────────────────
|
|
189
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Informational only — whether the CONFIGURED host looks local.
|
|
192
|
+
*
|
|
193
|
+
* NOT the security gate. Reads `TINA4_HOST_NAME` (the configured bind address),
|
|
194
|
+
* which on a 0.0.0.0 bind looks "local" while still accepting remote clients.
|
|
195
|
+
* Trust decisions use {@link isRequestAllowed} with the RAW socket peer instead.
|
|
196
|
+
* Kept for diagnostics / back-compat.
|
|
197
|
+
*/
|
|
190
198
|
export function isLocalhost(): boolean {
|
|
191
199
|
const hostEnv = process.env.TINA4_HOST_NAME || "localhost:7148";
|
|
192
200
|
const host = hostEnv.split(":")[0];
|
|
@@ -202,18 +210,61 @@ function envTruthy(val: string | undefined): boolean {
|
|
|
202
210
|
}
|
|
203
211
|
|
|
204
212
|
/**
|
|
205
|
-
* Whether
|
|
213
|
+
* Whether an address is a loopback (in-process / same-host) peer.
|
|
214
|
+
*
|
|
215
|
+
* Operates on the RAW socket peer, never X-Forwarded-For. Empty/undefined means
|
|
216
|
+
* an in-process / synthetic request (no socket) and is trusted. The `::ffff:`
|
|
217
|
+
* IPv4-mapped prefix is stripped. NOTE: 0.0.0.0 is a BIND address, never a
|
|
218
|
+
* client address, so it is deliberately NOT loopback.
|
|
219
|
+
*
|
|
220
|
+
* Python master parity: tina4_python.mcp.is_loopback.
|
|
221
|
+
*/
|
|
222
|
+
export function isLoopback(ip: string | undefined | null): boolean {
|
|
223
|
+
if (ip == null || ip === "") return true;
|
|
224
|
+
let addr = ip.trim().toLowerCase();
|
|
225
|
+
if (addr.startsWith("::ffff:")) addr = addr.slice(7);
|
|
226
|
+
return addr === "::1" || addr === "localhost" || addr.startsWith("127.");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Capability gate — whether MCP may run at all.
|
|
206
231
|
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
232
|
+
* Pure capability, host-INDEPENDENT (Python master parity):
|
|
233
|
+
* 1. `TINA4_MCP` explicit on/off override (sysadmin, any host).
|
|
234
|
+
* 2. Else `TINA4_DEBUG=true` → MCP is a capability of this deployment.
|
|
235
|
+
* 3. Otherwise off.
|
|
236
|
+
*
|
|
237
|
+
* This NO LONGER consults the host. A debug box bound to 0.0.0.0 still "has"
|
|
238
|
+
* the capability, but {@link isRequestAllowed} decides whether a given CALLER
|
|
239
|
+
* may use it — loopback always, remote only with an explicit opt-in plus a
|
|
240
|
+
* valid token. Splitting capability from per-request authorisation closes the
|
|
241
|
+
* hole where a 0.0.0.0 bind auto-exposed DB/file tools to remote
|
|
242
|
+
* unauthenticated callers (the pre-3.13.40 isLocalhost() treated 0.0.0.0 local).
|
|
210
243
|
*/
|
|
211
244
|
export function mcpEnabled(): boolean {
|
|
212
|
-
const
|
|
213
|
-
if (
|
|
214
|
-
return envTruthy(
|
|
245
|
+
const explicit = process.env.TINA4_MCP;
|
|
246
|
+
if (explicit !== undefined && explicit.trim() !== "") {
|
|
247
|
+
return envTruthy(explicit);
|
|
215
248
|
}
|
|
216
|
-
return envTruthy(
|
|
249
|
+
return envTruthy(process.env.TINA4_DEBUG);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Per-request authorisation — whether THIS caller may use MCP.
|
|
254
|
+
*
|
|
255
|
+
* @param remoteIp Raw socket peer (`req.socket.remoteAddress`), never XFF.
|
|
256
|
+
* @param hasValidToken True when the request carried a token matching TINA4_MCP_TOKEN.
|
|
257
|
+
*
|
|
258
|
+
* Rules (Python master parity, tina4_python.mcp.is_request_allowed):
|
|
259
|
+
* - Capability off ({@link mcpEnabled} false) → deny.
|
|
260
|
+
* - Loopback peer → allow.
|
|
261
|
+
* - Remote peer → only when TINA4_MCP_REMOTE is truthy AND a valid token was
|
|
262
|
+
* presented. No configured token ⇒ remote can never pass.
|
|
263
|
+
*/
|
|
264
|
+
export function isRequestAllowed(remoteIp: string | undefined | null, hasValidToken = false): boolean {
|
|
265
|
+
if (!mcpEnabled()) return false;
|
|
266
|
+
if (isLoopback(remoteIp)) return true;
|
|
267
|
+
return envTruthy(process.env.TINA4_MCP_REMOTE) && hasValidToken;
|
|
217
268
|
}
|
|
218
269
|
|
|
219
270
|
/**
|
|
@@ -603,9 +654,30 @@ export function mcpResource(
|
|
|
603
654
|
*/
|
|
604
655
|
function safePath(projectRoot: string, relPath: string): string {
|
|
605
656
|
const resolved = path.resolve(projectRoot, relPath);
|
|
606
|
-
|
|
657
|
+
// Compare against root + separator, not a bare prefix: a plain
|
|
658
|
+
// startsWith(projectRoot) also accepts a sibling like "<root>-evil".
|
|
659
|
+
// path.resolve collapses ".." so a climb-out lands outside root.
|
|
660
|
+
const rootPrefix = projectRoot.endsWith(path.sep) ? projectRoot : projectRoot + path.sep;
|
|
661
|
+
if (resolved !== projectRoot && !resolved.startsWith(rootPrefix)) {
|
|
607
662
|
throw new Error(`Path escapes project directory: ${relPath}`);
|
|
608
663
|
}
|
|
664
|
+
// Belt-and-braces against symlink escapes: if the path exists, canonicalise
|
|
665
|
+
// it and re-check containment. A symlink inside the tree pointing outside
|
|
666
|
+
// would otherwise slip past the textual check. New paths (parent not yet
|
|
667
|
+
// created) have no realpath and rely on the resolve() containment above.
|
|
668
|
+
let real: string | null = null;
|
|
669
|
+
try {
|
|
670
|
+
real = fs.realpathSync(resolved);
|
|
671
|
+
} catch {
|
|
672
|
+
real = null; // not created yet — textual guard above holds
|
|
673
|
+
}
|
|
674
|
+
if (real !== null) {
|
|
675
|
+
const realRoot = fs.realpathSync(projectRoot);
|
|
676
|
+
const realPrefix = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep;
|
|
677
|
+
if (real !== realRoot && !real.startsWith(realPrefix)) {
|
|
678
|
+
throw new Error(`Path escapes project directory: ${relPath}`);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
609
681
|
return resolved;
|
|
610
682
|
}
|
|
611
683
|
|
|
@@ -848,8 +920,22 @@ export function registerDevTools(server: McpServer): void {
|
|
|
848
920
|
try {
|
|
849
921
|
const db = (globalThis as any).__tina4_db;
|
|
850
922
|
if (!db) return { error: "No database connection" };
|
|
851
|
-
|
|
852
|
-
|
|
923
|
+
let params = typeof args.params === "string" ? JSON.parse(args.params as string) : (args.params || []);
|
|
924
|
+
if (!Array.isArray(params)) params = [];
|
|
925
|
+
// Defense-in-depth: this tool is read-only. Strip comments, reject
|
|
926
|
+
// multiple statements, and require a leading SELECT/WITH so it can never
|
|
927
|
+
// mutate data even if reached (database_execute is the write surface,
|
|
928
|
+
// gated separately). Mirrors the Python master.
|
|
929
|
+
const cleaned = (args.sql as string)
|
|
930
|
+
.replace(/--[^\r\n]*/g, " ")
|
|
931
|
+
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
|
932
|
+
.trim()
|
|
933
|
+
.replace(/[;\s]+$/, "");
|
|
934
|
+
if (cleaned.includes(";")) return { error: "database_query rejects multiple statements" };
|
|
935
|
+
if (!/^(select|with)\b/i.test(cleaned)) {
|
|
936
|
+
return { error: "database_query is read-only (SELECT/WITH only)" };
|
|
937
|
+
}
|
|
938
|
+
const result = await db.fetch(cleaned, params);
|
|
853
939
|
return { records: result.records || [], count: result.count || 0 };
|
|
854
940
|
} catch (e) {
|
|
855
941
|
return { error: (e as Error).message };
|
|
@@ -904,7 +990,14 @@ export function registerDevTools(server: McpServer): void {
|
|
|
904
990
|
try {
|
|
905
991
|
const db = (globalThis as any).__tina4_db;
|
|
906
992
|
if (!db) return { error: "No database connection" };
|
|
907
|
-
|
|
993
|
+
// Constrain the table name to a safe identifier (optionally
|
|
994
|
+
// schema-qualified) — defense-in-depth so it can never be abused for
|
|
995
|
+
// injection even if an adapter interpolates it. Parity with Python/PHP.
|
|
996
|
+
const table = String(args.table ?? "");
|
|
997
|
+
if (!/^[A-Za-z_][A-Za-z0-9_$]*(\.[A-Za-z_][A-Za-z0-9_$]*)?$/.test(table)) {
|
|
998
|
+
return { error: "Invalid table name" };
|
|
999
|
+
}
|
|
1000
|
+
return (await db.getColumns?.(table)) ?? [];
|
|
908
1001
|
} catch (e) {
|
|
909
1002
|
return { error: (e as Error).message };
|
|
910
1003
|
}
|