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.
@@ -136,18 +136,23 @@ function resolveLogFilePath(logDir: string, logFile: string): string {
136
136
  /**
137
137
  * Structured logger for Tina4.
138
138
  *
139
- * Production (TINA4_DEBUG not truthy): JSON or text lines to logs/tina4.log
140
- * Development (TINA4_DEBUG=true): Colorized human-readable to stdout + file
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: "DEBUG")
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
- criticalEnabled: boolean;
179
+ fileEnabled: boolean;
175
180
  } {
176
181
  const logDir = process.env.TINA4_LOG_DIR ?? DEFAULT_LOG_DIR;
177
- const logFile = (process.env.TINA4_LOG_FILE ?? "").trim() || DEFAULT_LOG_FILE;
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
- const criticalEnabled = isTruthy(process.env.TINA4_LOG_CRITICAL);
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
- return { logDir, logFile, rotateSize, rotateKeep, minLevel, format, output, criticalEnabled };
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. Only emitted when TINA4_LOG_CRITICAL=true,
261
- * otherwise this is a no-op (matches Python paritycritical is the
262
- * highest-severity bucket and is opt-in to avoid drowning noisy apps).
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 = (LEVEL_PRIORITY[level] ?? 0) >= cfg.minLevel;
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: always teed for dev (legacy behaviour), and either always
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 + prod (legacy parity)
417
- // output=file file only — no console
418
- // output=both file + console (already handled above)
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
- // The "stdout-only without file" mode that some Python deployments want
421
- // is gated on TINA4_LOG_OUTPUT=stdout combined with TINA4_LOG_FILE set
422
- // explicitly to an empty string in env — we treat empty file as default.
423
- const filePath = resolveLogFilePath(cfg.logDir, cfg.logFile);
424
- Log.writeToFile(filePath, fileLine, cfg.rotateSize, cfg.rotateKeep);
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
  }
@@ -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 the built-in MCP server should auto-start.
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
- * Default: `true` when `TINA4_DEBUG=true`, `false` otherwise. The `TINA4_MCP`
208
- * env var can force either state explicitly. Matches the Python framework
209
- * which only exposes MCP endpoints in dev mode by default.
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 raw = process.env.TINA4_MCP;
213
- if (raw === undefined || raw.trim() === "") {
214
- return envTruthy(process.env.TINA4_DEBUG);
245
+ const explicit = process.env.TINA4_MCP;
246
+ if (explicit !== undefined && explicit.trim() !== "") {
247
+ return envTruthy(explicit);
215
248
  }
216
- return envTruthy(raw);
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
- if (!resolved.startsWith(projectRoot)) {
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
- const params = typeof args.params === "string" ? JSON.parse(args.params as string) : (args.params || []);
852
- const result = await db.fetch(args.sql as string, params);
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
- return (await db.getColumns?.(args.table as string)) ?? [];
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
  }