tina4-nodejs 3.13.38 → 3.13.39

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 CHANGED
@@ -120,11 +120,11 @@ The HTTP foundation. Handles request/response lifecycle, route matching, middlew
120
120
  - `auth.ts` — Authentication helpers
121
121
  - `cache.ts` — In-memory caching
122
122
  - `session.ts` — Session management with pluggable handlers. `TINA4_SESSION_SAMESITE` env var (default: Lax)
123
- - `websocket.ts` — WebSocket support with backplane for scaling via Redis/NATS pub/sub (`TINA4_WS_BACKPLANE`, `TINA4_WS_BACKPLANE_URL`). The backplane is wired for real: `broadcast`/`broadcastToRoom`/`sendTo` deliver to LOCAL connections first (resiliently — a dead/slow client is pruned, never aborting the loop) then publish an envelope `{src,kind,exclude,room,path,text|b64}` to the shared channel `tina4:ws` (identical wire shape across all 4 frameworks). The subscribe callback relays directly on the event loop with an origin guard (drop our own `src` echo — no double-delivery) and never re-publishes (no cluster loop); binary messages ride as base64. Security/ops knobs (all opt-in, non-breaking): `TINA4_WS_ALLOWED_ORIGINS` (comma-separated origin allow-list enforced on upgrade — empty/unset = allow all), `TINA4_WS_IDLE_TIMEOUT` (seconds; 0/unset disables the idle-connection reaper), `TINA4_WS_MAX_BACKLOG` (bytes; a slow client whose socket write backlog exceeds this is dropped rather than buffered without bound). Rooms API: `wss.joinRoom(clientId, room)`, `wss.leaveRoom(clientId, room)`, `wss.broadcastToRoom(room, msg, excludeIds?)`, `wss.getRoomConnections(room)`, `wss.roomCount(room)`, `wss.getClientRooms(clientId)`. `originAllowed(headers)` is exported for upgrade-path checks.
123
+ - `websocket.ts` — WebSocket support with backplane for scaling via Redis/NATS pub/sub (`TINA4_WS_BACKPLANE`, `TINA4_WS_BACKPLANE_URL`). The backplane is wired for real: `broadcast`/`broadcastToRoom`/`sendTo` deliver to LOCAL connections first (resiliently — a dead/slow client is pruned, never aborting the loop) then publish an envelope `{src,kind,exclude,room,path,text|b64}` to the shared channel `tina4:ws` (identical wire shape across all 4 frameworks). The subscribe callback relays directly on the event loop with an origin guard (drop our own `src` echo — no double-delivery) and never re-publishes (no cluster loop); binary messages ride as base64. Security/ops knobs (all opt-in, non-breaking): `TINA4_WS_ALLOWED_ORIGINS` (comma-separated origin allow-list enforced on upgrade — empty/unset = allow all), `TINA4_WS_IDLE_TIMEOUT` (seconds; 0/unset disables the idle-connection reaper), `TINA4_WS_MAX_BACKLOG` (bytes; a slow client whose socket write backlog exceeds this is dropped rather than buffered without bound). Rooms API: `wss.joinRoom(clientId, room)`, `wss.leaveRoom(clientId, room)`, `wss.broadcastToRoom(room, msg, excludeIds?)`, `wss.getRoomConnections(room)`, `wss.roomCount(room)`, `wss.getClientRooms(clientId)`. `originAllowed(headers)` is exported for upgrade-path checks. **Per-route auth (v3.13.39, Python master b5976d4):** a WS route is PUBLIC by default (mirrors GET). Mark it secured imperatively — `Router.websocket(path, fn, { secured: true })` / chain `.secure()` on the returned `WsRouteRef` / `WebSocketServer.route(path, fn, { secured: true })` — OR decorator-style via a `_secured` flag on the handler (works in either declaration order). A secured route enforces a valid JWT on the upgrade, AFTER the origin allow-list and BEFORE accept: missing/invalid → reject with HTTP 401 (never accept); public routes always pass (non-breaking). Three token transports (`wsToken(headers, query, subprotocol)`): (1) `Authorization: Bearer <jwt>` header (server/CLI/mobile); (2) the `Sec-WebSocket-Protocol` subprotocol `"bearer, <jwt>"` (browsers — `new WebSocket()` can't set headers; the server echoes `bearer` back as the accepted subprotocol); (3) `?token=<jwt>` query param. Validated via the same `validToken` (Auth) the HTTP routes use. The verified payload is exposed as `connection.auth` (`null` on public routes). `wsAuthorized(route, headers, query, subprotocol) -> [payload, ok]` is the gate. **The integrated server (`server.ts`) now dispatches user WS routes:** its `upgrade` handler routes a non-`/__dev_reload` upgrade through `serveWebSocketRoute(req, socket, head)`, which matches the WS route table, enforces per-route auth, then drives the real open/message/close lifecycle on the connection (`wsRouteManager`) — parity with Python/PHP/Ruby (previously only `/__dev_reload` was wired, so user WS routes never reached a live connection).
124
124
  - `queue.ts` — Queue system with pluggable backends
125
125
  - `graphql.ts` — GraphQL engine. **Hardening:** selection-set nesting is bounded by `TINA4_GRAPHQL_MAX_DEPTH` (default `50`; `<= 0` disables; exposed as the public `gql.maxDepth` field + `graphqlMaxDepth()` helper). Depth increments on every recursive entry — sub-selections, fragment spreads, AND inline fragments — so an over-deep query or a circular fragment fails with `Query exceeds maximum depth of N` instead of overflowing the stack (top-level starts at depth 1). A resolver exception is logged via `Log.error` and the detail is surfaced to the client **only** under `TINA4_DEBUG` (`isDebugMode()`); otherwise it returns a generic `Internal server error` (path preserved) so internal state never leaks.
126
126
  - `i18n.ts` — Internationalization / localization
127
- - `logger.ts` — Structured logging
127
+ - `logger.ts` — Structured logging. Five first-class severity levels: `debug`(0) < `info`(1) < `warning`(2) < `error`(3) < `critical`(4). `critical` is the HIGHEST level, NOT a relabelled `error`, and renders magenta. `Log.critical()` ALWAYS emits like every other level — subject only to `TINA4_LOG_LEVEL` (which it always clears) and teed to the log file whenever a file is being written. There is NO enable toggle: the old `TINA4_LOG_CRITICAL` opt-in was retired in v3.13.39 (the env var is no longer read), so a critical log is never a silent no-op. `Log.isEnabled("critical")` is ordinary threshold logic (`4 >= configured min`). **Dev/prod-aware default file output (v3.13.39, Python master 4c6d881):** stdout is ALWAYS on. When `TINA4_LOG_OUTPUT` is unset (default), the log FILE (`logs/tina4.log`) is written ONLY in development (`TINA4_DEBUG` truthy); in production / containers (`TINA4_DEBUG` falsy) the logger is stdout-only — no file to bloat the writable layer / disk (12-factor: logs on stdout for the platform to capture). Explicit `TINA4_LOG_OUTPUT=file`/`both`, OR an explicit `TINA4_LOG_FILE` path, always forces a file (explicit wins). `readEnv()` resolves all of this into a single `fileEnabled` flag that gates the file writer. Full parity with Python master.
128
128
  - `rateLimiter.ts` — Rate limiting middleware
129
129
  - `dotenv.ts` — `.env` file loading
130
130
  - `health.ts` — Health check endpoint
@@ -775,6 +775,8 @@ const adults = User.query().where("age > ?", [18]).orderBy("name").get();
775
775
 
776
776
  SQL-file based migrations under `migrations/`. The framework runs pending migrations on startup; the helpers here are for programmatic control (CLI, scripts, tests).
777
777
 
778
+ **Auto-run on startup (`TINA4_AUTO_MIGRATE`, default on).** `startServer()` calls `autoMigrateOnStartup()` (in `server.ts`) AFTER `initDatabase()`/model sync and BEFORE the server listens. When a `migrations/` folder with at least one `.sql` file exists, `TINA4_AUTO_MIGRATE` is not falsy (default `"true"`; `false`/`0`/`no`/`off` disable), and a DB adapter is resolvable, it runs the existing `migrate()` runner so the schema is current with no manual `tina4 migrate` step. It is **non-breaking**: a failure is logged via `Log.error` and the service still starts (a bad migration must never take the backend down). The runner is wrapped in try/catch and `autoMigrateOnStartup()` never rejects/throws out of the boot path. Set `TINA4_AUTO_MIGRATE=false` to disable (e.g. multi-instance production that migrates as a separate deploy step — concurrent first-apply can race). The explicit `tina4 migrate` CLI (`packages/cli/src/commands/migrate.ts`) is unaffected and stays **fail-fast** (`process.exit(1)` on a statement error) so CI keeps the non-zero exit code.
779
+
778
780
  ```typescript
779
781
  import {
780
782
  migrate, rollback, status, createMigration, syncModels,
@@ -788,7 +790,16 @@ await createMigration("add users table"); // scaffolds migrations/<ts>_add_use
788
790
  syncModels(discoveredModels); // auto-create tables / add columns from `static fields`
789
791
  ```
790
792
 
791
- Migration tracking lives in `tina4_migration` (id, name, batch, applied_at). Schema sync runs alongside SQL migrations on boot.
793
+ ### How migrations work internally
794
+
795
+ - SQL files live in `migrations/`, named `NNNNNN_description.sql` (sequential) or `YYYYMMDDHHMMSS_description.sql` (timestamp), and are split on the `;` delimiter.
796
+ - Files are applied in **numeric-prefix order** (`9_` before `10_` — a plain lexical sort misorders unpadded prefixes because `"10" < "9"`). A file with no numeric/timestamp prefix sorts **after** the numbered ones (lexically) and logs a `Log.warning` — its order is undefined.
797
+ - State is tracked by **row existence** in `tina4_migration` (id, name, batch, applied_at), auto-created per engine: a migration runs once — if a row with its `name` exists it is skipped. A FAILED migration is **never** recorded (no row is written, nothing is deleted) — fix the bad file and re-run; the `migrate()` summary's `failed[]` carries the failure.
798
+ - **Each migration FILE is wrapped in its own transaction.** On a failure the file rolls back and `migrate()` **STOPS** — later files are never applied on top of a missing earlier one (parity with Python/PHP/Ruby). Already-applied files stay applied. The explicit `tina4 migrate` CLI surfaces a non-empty `failed[]` as a non-zero exit; startup auto-migration logs it and the service still boots (see `TINA4_AUTO_MIGRATE` above).
799
+ - **Atomicity caveat:** per-file transactions are truly atomic only on engines with **transactional DDL (PostgreSQL)**. MySQL, Firebird, and SQLite auto-commit DDL, so a multi-statement migration that fails midway on those engines leaves earlier statements applied — keep one logical change per file. `CREATE TABLE` and `ALTER TABLE ... ADD` are made idempotent on Firebird/MSSQL (existence-checked via `RDB$RELATION_FIELDS` / `tableExists`) so a re-run with a raw `CREATE`/`ADD` does not error "object already exists"; SQLite/MySQL/PostgreSQL support `IF NOT EXISTS` and are left to the engine. Only a genuine already-exists is skipped — every other error still raises.
800
+ - The stored-proc block delimiters (`$$ … $$` / `// … //`) are extracted before splitting, but a `//` preceded by a colon is **not** treated as a delimiter, so a URL (`https://…`) or any `://` literal inside a migration is never swallowed as an opaque block.
801
+
802
+ Schema sync (`syncModels`) runs alongside SQL migrations on boot.
792
803
 
793
804
  ## Module: Frond (`packages/frond/src/engine.ts`)
794
805
 
@@ -821,6 +832,8 @@ frond.unsandbox();
821
832
 
822
833
  Zero-dep HTTP client over `node:http` / `node:https`. Used by integrations, queue producers, health checks, and tests.
823
834
 
835
+ **Retry/backoff (opt-in, default off):** pass `maxRetries` (default `0`) and `retryBackoff` (default `0.5`s base, exponential) in the options bag — `new Api(url, { bearerToken, maxRetries: 3, retryBackoff: 0.5 })`. Retries a transport error (`http_code` null) or a retryable status (429/500/502/503/504); 4xx is never retried (a retried non-idempotent request may be re-sent, so retries are opt-in). Node's `node:http`/`node:https` doesn't auto-follow redirects, so there's no cross-host Authorization-leak surface (the redirect auth-strip is Python-only).
836
+
824
837
  ```typescript
825
838
  import { Api } from "@tina4/core";
826
839
 
package/README.md CHANGED
@@ -17,7 +17,7 @@
17
17
  ## Quick Start
18
18
 
19
19
  ```bash
20
- # With the Tina4 CLI (recommended enables SCSS + live reload)
20
+ # With the Tina4 CLI (recommended, enables SCSS + live reload)
21
21
  cargo install tina4 # or grab a binary from https://github.com/tina4stack/tina4/releases
22
22
  tina4 init nodejs ./my-app
23
23
  cd my-app && tina4 serve
@@ -57,12 +57,12 @@ export default class User {
57
57
  | Category | Features |
58
58
  |----------|----------|
59
59
  | **Core HTTP** (7) | Router with path params (`{id:int}`, `{p:path}`), Server, Request/Response, Middleware pipeline, Static file serving, CORS |
60
- | **Database** (6) | SQLite, PostgreSQL, MySQL, MSSQL, Firebird unified adapter, connection pooling, query cache, transactions, race-safe ID generation, SQL dialect translation |
60
+ | **Database** (6) | SQLite, PostgreSQL, MySQL, MSSQL, Firebird: unified adapter, connection pooling, query cache, transactions, race-safe ID generation, SQL dialect translation |
61
61
  | **ORM** (7) | Active Record with typed fields, relationships (`has_one`/`has_many`/`belongs_to`), soft delete, QueryBuilder + MongoDB support, Auto-CRUD generator, migrations with rollback |
62
62
  | **Auth & Security** (5) | JWT (HS256/RS256), password hashing (PBKDF2-SHA256), API key validation, rate limiting, CSRF form tokens |
63
63
  | **Templating** (3) | Frond engine (Twig/Jinja2-compatible, pre-compiled 2.8× faster), SCSS auto-compilation, built-in CSS (~24 KB) |
64
64
  | **API & Integration** (5) | HTTP client (zero-dep), GraphQL with ORM auto-schema + GraphiQL IDE, WSDL/SOAP with auto WSDL, WebSocket (RFC 6455) + Redis backplane, MCP server (24 dev tools) |
65
- | **Background** (3) | Job queue (File/RabbitMQ/Kafka/MongoDB) with priority, delay, retry, dead letters service runner event system (on/emit/once/off) |
65
+ | **Background** (3) | Job queue (File/RabbitMQ/Kafka/MongoDB) with priority, delay, retry, dead letters; service runner; event system (on/emit/once/off) |
66
66
  | **Data & Storage** (4) | Session (File/Redis/Valkey/MongoDB/DB), response cache (LRU, TTL), seeder + 50+ fake data generators, messenger (SMTP/IMAP) |
67
67
  | **Developer Tools** (7) | Dev dashboard (11 tabs), dev toolbar, error overlay (Catppuccin Mocha), dev mailbox, hot reload + CSS hot-reload, code metrics (complexity, coupling, maintainability), AI context installer (7 tools) |
68
68
  | **Utilities** (7) | DI container (transient + singleton), HtmlElement builder, inline testing (`@tests` decorator), i18n (6 languages), Swagger/OpenAPI auto-generation, CLI scaffolding (`generate model/route/migration/middleware`), structured logging |
@@ -86,14 +86,14 @@ npx tina4nodejs generate model <name>
86
86
 
87
87
  ## Performance
88
88
 
89
- Benchmarked with `wrk` 5,000 requests, 50 concurrent, median of 3 runs:
89
+ Benchmarked with `wrk`: 5,000 requests, 50 concurrent, median of 3 runs:
90
90
 
91
91
  | Framework | JSON req/s | Deps | Features |
92
92
  |-----------|-----------|------|----------|
93
93
  | Raw `node:http` | 91,110 | 0 | 1 |
94
94
  | **Tina4 Node.js** | **84,771** | 0 | 55 |
95
95
 
96
- Tina4 Node.js runs at **93% of raw Node.js speed** while providing 55 built-in features zero overhead architecture.
96
+ Tina4 Node.js runs at **93% of raw Node.js speed** while providing 55 built-in features, a zero-overhead architecture.
97
97
 
98
98
  **Across all 4 Tina4 implementations:**
99
99
 
@@ -107,7 +107,7 @@ Tina4 Node.js runs at **93% of raw Node.js speed** while providing 55 built-in f
107
107
 
108
108
  ## Cross-Framework Parity
109
109
 
110
- Tina4 ships identical features across four languages same architecture, same conventions, same 55 features:
110
+ Tina4 ships identical features across four languages: same architecture, same conventions, same 55 features:
111
111
 
112
112
  | | Python | PHP | Ruby | Node.js |
113
113
  |---|--------|-----|------|---------|
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
 
5
5
 
6
- "version": "3.13.38",
6
+ "version": "3.13.39",
7
7
 
8
8
  "type": "module",
9
9
  "description": "Tina4 for Node.js/TypeScript \u2014 54 built-in features, zero dependencies",
@@ -18,6 +18,13 @@ export interface ApiResult {
18
18
  error: string | null;
19
19
  }
20
20
 
21
+ /**
22
+ * HTTP statuses that warrant an automatic retry when `maxRetries` > 0:
23
+ * rate-limit (429) plus the transient server-side 5xx family. 4xx client
24
+ * errors (401, 404, …) are NOT retried — a repeat won't succeed.
25
+ */
26
+ const RETRY_STATUSES: ReadonlySet<number> = new Set([429, 500, 502, 503, 504]);
27
+
21
28
  /**
22
29
  * Constructor options for {@link Api}. Used as the second argument to
23
30
  * `new Api(url, { ... })` — cross-framework parity with Python
@@ -33,6 +40,16 @@ export interface ApiOptions {
33
40
  username?: string;
34
41
  password?: string;
35
42
  headers?: Record<string, string>;
43
+ /**
44
+ * Maximum automatic retries on a transient failure (default 0 = off, so
45
+ * existing callers are unaffected). When > 0, a transport error or a
46
+ * retryable status (429/5xx) is retried up to this many times with
47
+ * exponential backoff. NOTE: a retried non-idempotent request (POST/…)
48
+ * may be re-sent — retries are opt-in for that reason.
49
+ */
50
+ maxRetries?: number;
51
+ /** Base backoff in seconds, doubling each attempt (default 0.5). */
52
+ retryBackoff?: number;
36
53
  }
37
54
 
38
55
  export class Api {
@@ -41,6 +58,8 @@ export class Api {
41
58
  private timeout: number;
42
59
  private authHeader: string;
43
60
  private ignoreSsl: boolean;
61
+ private maxRetries: number;
62
+ private retryBackoff: number;
44
63
 
45
64
  /**
46
65
  * Construct an Api client.
@@ -60,6 +79,14 @@ export class Api {
60
79
  * Bearer wins over basic-auth when both passed. `verifySsl: false` is
61
80
  * the positive form of `ignoreSsl: true`; `ignoreSsl` wins when both
62
81
  * supplied for backward compatibility.
82
+ *
83
+ * `maxRetries` (default 0 = off) enables automatic retry with
84
+ * exponential backoff (`retryBackoff` seconds base, doubling each
85
+ * attempt) on a transport error or a retryable status (429/5xx). A
86
+ * retried non-idempotent request (POST/…) may be re-sent — retries are
87
+ * opt-in for that reason.
88
+ *
89
+ * new Api("https://api.example.com", { maxRetries: 3, retryBackoff: 0.5 });
63
90
  */
64
91
  constructor(
65
92
  baseUrl: string = "",
@@ -68,6 +95,9 @@ export class Api {
68
95
  ) {
69
96
  this.baseUrl = baseUrl.replace(/\/+$/, "");
70
97
  this.headers = {};
98
+ // Retry defaults: off (0) so existing callers are unaffected.
99
+ this.maxRetries = 0;
100
+ this.retryBackoff = 0.5;
71
101
 
72
102
  // Options-bag form — second arg is an object literal
73
103
  if (typeof authHeaderOrOptions === "object" && authHeaderOrOptions !== null) {
@@ -75,6 +105,8 @@ export class Api {
75
105
  this.authHeader = opts.authHeader ?? "";
76
106
  this.timeout = opts.timeout ?? timeout;
77
107
  this.ignoreSsl = (opts.ignoreSsl ?? false) || (opts.verifySsl === false);
108
+ this.maxRetries = Math.max(0, opts.maxRetries ?? 0);
109
+ this.retryBackoff = opts.retryBackoff ?? 0.5;
78
110
 
79
111
  // Bearer wins over basic-auth when both are passed
80
112
  if (opts.bearerToken != null) {
@@ -189,7 +221,38 @@ export class Api {
189
221
  return `${this.baseUrl}/${path.replace(/^\/+/, "")}`;
190
222
  }
191
223
 
192
- private execute(
224
+ /**
225
+ * Execute the request with opt-in retry/backoff.
226
+ *
227
+ * With `maxRetries` > 0, a transport failure (`http_code` null) or a
228
+ * retryable status (429/5xx) is retried up to `maxRetries` times with
229
+ * exponential backoff; any other outcome (2xx, 3xx, other 4xx) returns
230
+ * at once. A retried non-idempotent request may be re-sent — retries
231
+ * are opt-in for that reason.
232
+ */
233
+ private async execute(
234
+ method: string,
235
+ url: string,
236
+ body?: unknown,
237
+ contentType: string = "application/json",
238
+ ): Promise<ApiResult> {
239
+ const attempts = this.maxRetries + 1;
240
+ let result: ApiResult = { http_code: null, body: null, headers: {}, error: null };
241
+ for (let attempt = 0; attempt < attempts; attempt++) {
242
+ result = await this.attempt(method, url, body, contentType);
243
+ const code = result.http_code;
244
+ const retryable = code === null || RETRY_STATUSES.has(code);
245
+ if (!retryable || attempt === attempts - 1) {
246
+ return result;
247
+ }
248
+ const delayMs = this.retryBackoff * Math.pow(2, attempt) * 1000;
249
+ await new Promise<void>((r) => setTimeout(r, delayMs));
250
+ }
251
+ return result;
252
+ }
253
+
254
+ /** A single HTTP attempt — returns the standardized result. */
255
+ private attempt(
193
256
  method: string,
194
257
  url: string,
195
258
  body?: unknown,
@@ -19,7 +19,7 @@ import { DevMailbox } from "./devMailbox.js";
19
19
  import { isTruthy } from "./dotenv.js";
20
20
  import { quickMetrics, fullAnalysis, fileDetail } from "./metrics.js";
21
21
  import { registerFeedbackRoutes } from "./feedback.js";
22
- import { getDefaultDevServer } from "./mcp.js";
22
+ import { getDefaultDevServer, mcpEnabled } from "./mcp.js";
23
23
 
24
24
  const cpuCount = osCpus().length;
25
25
 
@@ -561,18 +561,6 @@ export class DevAdmin {
561
561
  { method: "POST", pattern: "/__dev/api/deps/install", handler: handleDepsInstall },
562
562
  // Git status
563
563
  { method: "GET", pattern: "/__dev/api/git/status", handler: handleGitStatus },
564
- // MCP tool introspection over the built-in MCP server (browser dev-admin REST shim)
565
- { method: "GET", pattern: "/__dev/api/mcp/tools", handler: handleMcpTools },
566
- { method: "POST", pattern: "/__dev/api/mcp/call", handler: handleMcpCall },
567
- // MCP JSON-RPC + SSE endpoints that REAL MCP clients (Claude Code/Desktop)
568
- // speak. POST /__dev/mcp[/message] -> JSON-RPC handleMessage; GET
569
- // /__dev/mcp/sse -> SSE handshake announcing the message endpoint. Mounted
570
- // through the same dispatch as the REST shim above and gated by the same
571
- // /__dev public-route rule. Mirrors the Python v3 fix (POST /__dev/mcp +
572
- // /__dev/mcp/message, GET /__dev/mcp/sse).
573
- { method: "POST", pattern: "/__dev/mcp", handler: handleMcpMessage },
574
- { method: "POST", pattern: "/__dev/mcp/message", handler: handleMcpMessage },
575
- { method: "GET", pattern: "/__dev/mcp/sse", handler: handleMcpSse },
576
564
  // Scaffolding
577
565
  { method: "GET", pattern: "/__dev/api/scaffold", handler: handleScaffoldList },
578
566
  { method: "POST", pattern: "/__dev/api/scaffold/run", handler: handleScaffoldRun },
@@ -610,12 +598,41 @@ export class DevAdmin {
610
598
  });
611
599
  }
612
600
 
613
- // Ensure the default /__dev/mcp MCP server exists with its dev tools
614
- // registered. This is the single shared instance behind both the REST shim
615
- // and the JSON-RPC + SSE endpoints registered above. Doing it here (gated by
616
- // the same TINA4_DEBUG check that gates DevAdmin.register) means tools/list
617
- // and the REST shim return tools immediately, before any first call.
618
- getDefaultDevServer();
601
+ // MCP exposure is gated SEPARATELY from the rest of the dev dashboard.
602
+ // The MCP dev tools expose powerful operations (DB query, file read/WRITE,
603
+ // route listing), so they must NOT auto-expose on a non-localhost
604
+ // TINA4_DEBUG=true deployment. mcpEnabled() honours an explicit TINA4_MCP on
605
+ // any host, else requires TINA4_DEBUG AND (localhost OR TINA4_MCP_REMOTE)
606
+ // full parity with Python master tina4_python.mcp.is_enabled(). When the
607
+ // gate is closed, neither the REST shim, the JSON-RPC/SSE endpoints, nor the
608
+ // default dev MCP server (with its dev tools) are registered.
609
+ if (mcpEnabled()) {
610
+ const mcpRoutes: Array<{ method: string; pattern: string; handler: RouteHandler }> = [
611
+ // MCP tool introspection over the built-in MCP server (browser dev-admin REST shim)
612
+ { method: "GET", pattern: "/__dev/api/mcp/tools", handler: handleMcpTools },
613
+ { method: "POST", pattern: "/__dev/api/mcp/call", handler: handleMcpCall },
614
+ // MCP JSON-RPC + SSE endpoints that REAL MCP clients (Claude Code/Desktop)
615
+ // speak. POST /__dev/mcp[/message] -> JSON-RPC handleMessage; GET
616
+ // /__dev/mcp/sse -> SSE handshake announcing the message endpoint. Mirrors
617
+ // the Python v3 fix (POST /__dev/mcp + /__dev/mcp/message, GET /__dev/mcp/sse).
618
+ { method: "POST", pattern: "/__dev/mcp", handler: handleMcpMessage },
619
+ { method: "POST", pattern: "/__dev/mcp/message", handler: handleMcpMessage },
620
+ { method: "GET", pattern: "/__dev/mcp/sse", handler: handleMcpSse },
621
+ ];
622
+ for (const route of mcpRoutes) {
623
+ router.addRoute({
624
+ method: route.method,
625
+ pattern: route.pattern,
626
+ handler: route.handler,
627
+ });
628
+ }
629
+
630
+ // Ensure the default /__dev/mcp MCP server exists with its dev tools
631
+ // registered. This is the single shared instance behind both the REST shim
632
+ // and the JSON-RPC + SSE endpoints registered above, so tools/list and the
633
+ // REST shim return tools immediately, before any first call.
634
+ getDefaultDevServer();
635
+ }
619
636
  }
620
637
 
621
638
  /**
@@ -15,7 +15,7 @@ export type {
15
15
 
16
16
  export { startServer, resolvePortAndHost, handle, start, stop, httpReason, resolveTemplate, resetTemplateCache, templateAutoRoutingEnabled, isBannerSuppressed } from "./server.js";
17
17
  export { background, stopAllBackgroundTasks, backgroundTaskCount } from "./background.js";
18
- export { Router, RouteGroup, RouteRef, defaultRouter, runRouteMiddlewares, resolveStringMiddleware, isTrailingSlashRedirectEnabled } from "./router.js";
18
+ export { Router, RouteGroup, RouteRef, WsRouteRef, defaultRouter, runRouteMiddlewares, resolveStringMiddleware, isTrailingSlashRedirectEnabled } from "./router.js";
19
19
  export { get, post, put, patch, del, any, websocket, del as delete } from "./router.js";
20
20
  export type { RouteInfo } from "./router.js";
21
21
  export { discoverRoutes } from "./routeDiscovery.js";
@@ -64,6 +64,7 @@ export {
64
64
  WebSocketServer,
65
65
  devReloadWs,
66
66
  computeAcceptKey, parseUpgradeHeaders, buildFrame, parseFrame, originAllowed,
67
+ wsToken, wsAuthorized, offeredBearerSubprotocol, serveWebSocketRoute, wsRouteManager,
67
68
  OP_TEXT, OP_BINARY, OP_CLOSE, OP_PING, OP_PONG,
68
69
  CLOSE_NORMAL, CLOSE_GOING_AWAY, CLOSE_PROTOCOL_ERROR, CLOSE_POLICY_VIOLATION,
69
70
  } from "./websocket.js";
@@ -100,8 +101,12 @@ export type { ImapMessage, ImapFullMessage } from "./messenger.js";
100
101
  export { LiteBackend } from "./queueBackends/liteBackend.js";
101
102
  export { RabbitMQBackend, parseAmqpUrl } from "./queueBackends/rabbitmqBackend.js";
102
103
  export type { RabbitMQConfig } from "./queueBackends/rabbitmqBackend.js";
103
- export { KafkaBackend } from "./queueBackends/kafkaBackend.js";
104
- export type { KafkaConfig } from "./queueBackends/kafkaBackend.js";
104
+ export { KafkaBackend, kafkaSecurityConfig } from "./queueBackends/kafkaBackend.js";
105
+ export type {
106
+ KafkaConfig,
107
+ KafkaSecurityConfig,
108
+ KafkaClientConfig,
109
+ } from "./queueBackends/kafkaBackend.js";
105
110
  export { MongoBackend } from "./queueBackends/mongoBackend.js";
106
111
  export type { MongoConfig as MongoQueueConfig } from "./queueBackends/mongoBackend.js";
107
112
  export { DatabaseSessionHandler } from "./sessionHandlers/databaseHandler.js";
@@ -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
  }
@@ -202,18 +202,35 @@ function envTruthy(val: string | undefined): boolean {
202
202
  }
203
203
 
204
204
  /**
205
- * Whether the built-in MCP server should auto-start.
205
+ * Whether the built-in MCP dev tools / `/__dev/mcp` endpoint should be enabled.
206
206
  *
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.
207
+ * Resolution order (highest priority first), matching Python master
208
+ * `tina4_python.mcp.is_enabled()`:
209
+ * 1. `TINA4_MCP` explicit on/off override, honoured on ANY host. An
210
+ * explicit truthy value opts a remote / debug-disabled deployment in
211
+ * (e.g. for a remote AI assistant); an explicit falsy value force-disables
212
+ * it everywhere.
213
+ * 2. `TINA4_DEBUG=true` — implicit on for dev, but LOCALHOST-ONLY unless
214
+ * `TINA4_MCP_REMOTE=true`. The MCP dev tools expose powerful operations
215
+ * (DB query, file read/WRITE, route listing), so they never auto-expose on
216
+ * a non-localhost host without an explicit opt-in.
217
+ * 3. Otherwise off.
218
+ *
219
+ * Wired in v3.13.39: previously this was `TINA4_MCP` else `TINA4_DEBUG`, with
220
+ * `isLocalhost()` unused for the gate and `TINA4_MCP_REMOTE` read by zero code
221
+ * — so the documented localhost guard was not actually enforced and a
222
+ * non-localhost `TINA4_DEBUG=true` deployment auto-exposed the dev tools.
210
223
  */
211
224
  export function mcpEnabled(): boolean {
212
- const raw = process.env.TINA4_MCP;
213
- if (raw === undefined || raw.trim() === "") {
214
- return envTruthy(process.env.TINA4_DEBUG);
225
+ const explicit = process.env.TINA4_MCP;
226
+ if (explicit !== undefined && explicit.trim() !== "") {
227
+ return envTruthy(explicit);
228
+ }
229
+ if (!envTruthy(process.env.TINA4_DEBUG)) {
230
+ return false;
215
231
  }
216
- return envTruthy(raw);
232
+ // Dev auto-enable: localhost only, unless explicitly opted into remote.
233
+ return isLocalhost() || envTruthy(process.env.TINA4_MCP_REMOTE);
217
234
  }
218
235
 
219
236
  /**