tina4-nodejs 3.12.3 → 3.12.5
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 +31 -2
- package/package.json +1 -1
- package/packages/core/src/dotenv.ts +9 -2
- package/packages/core/src/graphql.ts +23 -0
- package/packages/core/src/health.ts +71 -13
- package/packages/core/src/index.ts +6 -6
- package/packages/core/src/logger.ts +179 -59
- package/packages/core/src/mcp.ts +38 -0
- package/packages/core/src/messenger.ts +25 -1
- package/packages/core/src/router.ts +39 -1
- package/packages/core/src/server.ts +42 -12
- package/packages/core/src/session.ts +43 -3
- package/packages/frond/src/engine.ts +37 -9
- package/packages/orm/src/database.ts +21 -0
- package/packages/orm/src/index.ts +1 -1
- package/packages/swagger/src/generator.ts +31 -6
- package/packages/swagger/src/index.ts +1 -1
- package/packages/swagger/src/ui.ts +17 -0
package/CLAUDE.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.12.
|
|
1
|
+
# CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.12.5)
|
|
2
2
|
|
|
3
3
|
> This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
|
|
4
4
|
|
|
5
5
|
## What This Project Is
|
|
6
6
|
|
|
7
|
-
Tina4 for Node.js/TypeScript v3.12.
|
|
7
|
+
Tina4 for Node.js/TypeScript v3.12.5 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
|
|
8
8
|
|
|
9
9
|
The philosophy: zero ceremony, batteries included, file system as source of truth.
|
|
10
10
|
|
|
@@ -1094,3 +1094,32 @@ Always read and follow the instructions in .claude/skills/tina4-developer/SKILL.
|
|
|
1094
1094
|
|
|
1095
1095
|
## Tina4-js Frontend Skill
|
|
1096
1096
|
Always read and follow the instructions in .claude/skills/tina4-js/SKILL.md when working with tina4-js frontend code. Read its referenced files in .claude/skills/tina4-js/references/ as needed.
|
|
1097
|
+
|
|
1098
|
+
## First Principle: Documentation Matches Code Reality
|
|
1099
|
+
|
|
1100
|
+
**This rule overrides everything else in this file.**
|
|
1101
|
+
|
|
1102
|
+
Every command, env var, method, class, or feature mentioned in any
|
|
1103
|
+
documentation file (`*.md` in this repo, or any tina4-book chapter,
|
|
1104
|
+
or `tina4-documentation/docs/`) MUST exist in code. No exceptions.
|
|
1105
|
+
No "we'll build it later" entries. No Laravel/Rails-style commands
|
|
1106
|
+
that look right but don't exist. No env vars that the framework
|
|
1107
|
+
doesn't actually read.
|
|
1108
|
+
|
|
1109
|
+
When you add a doc reference, add the implementation in the same PR.
|
|
1110
|
+
When you remove a feature, remove every doc reference in the same PR.
|
|
1111
|
+
When you find drift, fix it both ways: build the real thing OR delete
|
|
1112
|
+
the doc.
|
|
1113
|
+
|
|
1114
|
+
The `tina4-documentation/scripts/audit-truth.py` script is the source
|
|
1115
|
+
of truth. It runs as a CI gate (`audit-truth.yml`) on every PR — the
|
|
1116
|
+
build fails on CLI drift. Run it locally before pushing if you've
|
|
1117
|
+
touched docs:
|
|
1118
|
+
|
|
1119
|
+
```bash
|
|
1120
|
+
cd /path/to/tina4-documentation
|
|
1121
|
+
python3 scripts/audit-truth.py --strict
|
|
1122
|
+
```
|
|
1123
|
+
|
|
1124
|
+
If you're unsure whether something exists, run `tina4 <command> --help`
|
|
1125
|
+
or grep the framework source. Don't guess.
|
package/package.json
CHANGED
|
@@ -76,11 +76,18 @@ function parseEnvContent(content: string): Record<string, string> {
|
|
|
76
76
|
* Load environment variables from a .env file into process.env.
|
|
77
77
|
* Does not override existing process.env values unless they are undefined.
|
|
78
78
|
*
|
|
79
|
-
*
|
|
79
|
+
* Resolution order for the env file path:
|
|
80
|
+
* 1. Explicit `path` argument
|
|
81
|
+
* 2. `TINA4_ENV_FILE` env var (if set and non-empty)
|
|
82
|
+
* 3. `.env` in the current working directory
|
|
83
|
+
*
|
|
84
|
+
* @param path - Path to the .env file. Optional override.
|
|
80
85
|
* @returns The parsed key-value pairs, or an empty object if the file doesn't exist.
|
|
81
86
|
*/
|
|
82
87
|
export function loadEnv(path?: string): Record<string, string> {
|
|
83
|
-
const
|
|
88
|
+
const fromEnv = (process.env.TINA4_ENV_FILE ?? "").trim();
|
|
89
|
+
const target = path ?? (fromEnv.length > 0 ? fromEnv : ".env");
|
|
90
|
+
const envPath = resolve(target);
|
|
84
91
|
|
|
85
92
|
if (!existsSync(envPath)) {
|
|
86
93
|
return {};
|
|
@@ -374,6 +374,29 @@ interface QueryConfig {
|
|
|
374
374
|
resolver: ResolverFn;
|
|
375
375
|
}
|
|
376
376
|
|
|
377
|
+
// ── Env-driven config ────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* URL the GraphQL handler should be mounted at.
|
|
381
|
+
* `TINA4_GRAPHQL_ENDPOINT` overrides the default `/graphql`.
|
|
382
|
+
*/
|
|
383
|
+
export function graphqlEndpoint(): string {
|
|
384
|
+
const raw = (process.env.TINA4_GRAPHQL_ENDPOINT ?? "").trim();
|
|
385
|
+
if (raw.length === 0) return "/graphql";
|
|
386
|
+
return raw.startsWith("/") ? raw : `/${raw}`;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Whether to auto-generate the schema from registered ORM models.
|
|
391
|
+
* `TINA4_GRAPHQL_AUTO_SCHEMA=true` (default) lets the dev server build a
|
|
392
|
+
* usable schema with no manual wiring; set to `false` to require explicit
|
|
393
|
+
* `addType` / `addQuery` calls.
|
|
394
|
+
*/
|
|
395
|
+
export function graphqlAutoSchemaEnabled(): boolean {
|
|
396
|
+
const raw = (process.env.TINA4_GRAPHQL_AUTO_SCHEMA ?? "true").trim().toLowerCase();
|
|
397
|
+
return ["true", "1", "yes", "on"].includes(raw);
|
|
398
|
+
}
|
|
399
|
+
|
|
377
400
|
// ── GraphQL Engine ───────────────────────────────────────────
|
|
378
401
|
|
|
379
402
|
export class GraphQL {
|
|
@@ -1,27 +1,46 @@
|
|
|
1
|
-
import type { RouteDefinition } from "./types.js";
|
|
1
|
+
import type { RouteDefinition, RouteHandler } from "./types.js";
|
|
2
2
|
|
|
3
3
|
/** Server start time, set when health route is created */
|
|
4
4
|
let startTime: number;
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Resolve the health route path. Priority:
|
|
8
|
+
* 1. `TINA4_HEALTH_PATH` env var
|
|
9
|
+
* 2. Default `/__health` (matches Python parity — under-prefix avoids
|
|
10
|
+
* colliding with app routes named /health)
|
|
11
|
+
*/
|
|
12
|
+
export function healthPath(): string {
|
|
13
|
+
const raw = (process.env.TINA4_HEALTH_PATH ?? "").trim();
|
|
14
|
+
if (raw.length === 0) return "/__health";
|
|
15
|
+
return raw.startsWith("/") ? raw : `/${raw}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function buildHandler(version: string): RouteHandler {
|
|
19
|
+
return (_req, res) => {
|
|
20
|
+
const uptimeSeconds = (Date.now() - startTime) / 1000;
|
|
21
|
+
res.json({
|
|
22
|
+
status: "ok",
|
|
23
|
+
version,
|
|
24
|
+
uptime: Math.round(uptimeSeconds * 100) / 100,
|
|
25
|
+
framework: "tina4-nodejs",
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create the primary health route definition.
|
|
32
|
+
*
|
|
33
|
+
* Tests use this directly. Server bootstrap goes through createHealthRoutes()
|
|
34
|
+
* to also register the legacy `/health` alias when TINA4_HEALTH_PATH points
|
|
35
|
+
* elsewhere — matching Python behaviour so existing probes don't break.
|
|
9
36
|
*/
|
|
10
37
|
export function createHealthRoute(version: string = "3.0.0"): RouteDefinition {
|
|
11
38
|
startTime = Date.now();
|
|
12
39
|
|
|
13
40
|
return {
|
|
14
41
|
method: "GET",
|
|
15
|
-
pattern:
|
|
16
|
-
handler: (
|
|
17
|
-
const uptimeSeconds = (Date.now() - startTime) / 1000;
|
|
18
|
-
res.json({
|
|
19
|
-
status: "ok",
|
|
20
|
-
version,
|
|
21
|
-
uptime: Math.round(uptimeSeconds * 100) / 100,
|
|
22
|
-
framework: "tina4-nodejs",
|
|
23
|
-
});
|
|
24
|
-
},
|
|
42
|
+
pattern: healthPath(),
|
|
43
|
+
handler: buildHandler(version),
|
|
25
44
|
meta: {
|
|
26
45
|
summary: "Health check",
|
|
27
46
|
description: "Returns server health status, version, and uptime.",
|
|
@@ -29,3 +48,42 @@ export function createHealthRoute(version: string = "3.0.0"): RouteDefinition {
|
|
|
29
48
|
},
|
|
30
49
|
};
|
|
31
50
|
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Create one or two health routes — the env-defined path always, plus a
|
|
54
|
+
* legacy `/health` alias when the env path differs. Mirrors
|
|
55
|
+
* tina4-python's two-line registration in `core/server.py`.
|
|
56
|
+
*/
|
|
57
|
+
export function createHealthRoutes(version: string = "3.0.0"): RouteDefinition[] {
|
|
58
|
+
startTime = Date.now();
|
|
59
|
+
const routes: RouteDefinition[] = [];
|
|
60
|
+
const path = healthPath();
|
|
61
|
+
|
|
62
|
+
routes.push({
|
|
63
|
+
method: "GET",
|
|
64
|
+
pattern: path,
|
|
65
|
+
handler: buildHandler(version),
|
|
66
|
+
meta: {
|
|
67
|
+
summary: "Health check",
|
|
68
|
+
description: "Returns server health status, version, and uptime.",
|
|
69
|
+
tags: ["System"],
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Always register /health for backwards compatibility with existing probes
|
|
74
|
+
// (Kubernetes liveness/readiness, load balancers, monitoring scripts).
|
|
75
|
+
if (path !== "/health") {
|
|
76
|
+
routes.push({
|
|
77
|
+
method: "GET",
|
|
78
|
+
pattern: "/health",
|
|
79
|
+
handler: buildHandler(version),
|
|
80
|
+
meta: {
|
|
81
|
+
summary: "Health check (legacy alias)",
|
|
82
|
+
description: "Backwards-compatible alias for the configured health path.",
|
|
83
|
+
tags: ["System"],
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return routes;
|
|
89
|
+
}
|
|
@@ -12,9 +12,9 @@ export type {
|
|
|
12
12
|
WebSocketRouteDefinition,
|
|
13
13
|
} from "./types.js";
|
|
14
14
|
|
|
15
|
-
export { startServer, resolvePortAndHost, handle, start, stop, httpReason, resolveTemplate, resetTemplateCache, templateAutoRoutingEnabled } from "./server.js";
|
|
15
|
+
export { startServer, resolvePortAndHost, handle, start, stop, httpReason, resolveTemplate, resetTemplateCache, templateAutoRoutingEnabled, isBannerSuppressed } from "./server.js";
|
|
16
16
|
export { background, stopAllBackgroundTasks, backgroundTaskCount } from "./background.js";
|
|
17
|
-
export { Router, RouteGroup, RouteRef, defaultRouter, runRouteMiddlewares } from "./router.js";
|
|
17
|
+
export { Router, RouteGroup, RouteRef, defaultRouter, runRouteMiddlewares, isTrailingSlashRedirectEnabled } from "./router.js";
|
|
18
18
|
export { get, post, put, patch, del, any, websocket, del as delete } from "./router.js";
|
|
19
19
|
export type { RouteInfo } from "./router.js";
|
|
20
20
|
export { discoverRoutes } from "./routeDiscovery.js";
|
|
@@ -25,7 +25,7 @@ export { createResponse, errorResponse, setDefaultTemplatesDir, getFrond, setFro
|
|
|
25
25
|
export { tryServeStatic } from "./static.js";
|
|
26
26
|
export { loadEnv, getEnv, requireEnv, hasEnv, allEnv, resetEnv, isTruthy } from "./dotenv.js";
|
|
27
27
|
export { Log } from "./logger.js";
|
|
28
|
-
export { createHealthRoute } from "./health.js";
|
|
28
|
+
export { createHealthRoute, createHealthRoutes, healthPath } from "./health.js";
|
|
29
29
|
export { rateLimiter } from "./rateLimiter.js";
|
|
30
30
|
export type { RateLimiterConfig } from "./rateLimiter.js";
|
|
31
31
|
export {
|
|
@@ -45,7 +45,7 @@ export {
|
|
|
45
45
|
refreshToken, authenticateRequest, validateApiKey,
|
|
46
46
|
Auth,
|
|
47
47
|
} from "./auth.js";
|
|
48
|
-
export { Session, FileSessionHandler, RedisSessionHandler } from "./session.js";
|
|
48
|
+
export { Session, FileSessionHandler, RedisSessionHandler, buildSessionCookie } from "./session.js";
|
|
49
49
|
export type { SessionConfig, SessionHandler } from "./session.js";
|
|
50
50
|
export { I18n } from "./i18n.js";
|
|
51
51
|
export { FakeData } from "./fakeData.js";
|
|
@@ -55,7 +55,7 @@ export { Queue } from "./queue.js";
|
|
|
55
55
|
export type { QueueConfig, QueueJob, ProcessOptions } from "./queue.js";
|
|
56
56
|
export { createJob } from "./job.js";
|
|
57
57
|
export type { JobData, JobQueueBridge } from "./job.js";
|
|
58
|
-
export { GraphQL, ParseError } from "./graphql.js";
|
|
58
|
+
export { GraphQL, ParseError, graphqlEndpoint, graphqlAutoSchemaEnabled } from "./graphql.js";
|
|
59
59
|
export type { GraphQLField, ResolverFn, GraphQLResult } from "./graphql.js";
|
|
60
60
|
export {
|
|
61
61
|
WebSocketServer,
|
|
@@ -108,7 +108,7 @@ export type { WebSocketBackplane } from "./websocketBackplane.js";
|
|
|
108
108
|
export {
|
|
109
109
|
McpServer, mcpTool, mcpResource, registerDevTools,
|
|
110
110
|
encodeResponse, encodeError, encodeNotification, decodeRequest,
|
|
111
|
-
schemaFromParams, isLocalhost,
|
|
111
|
+
schemaFromParams, isLocalhost, mcpEnabled, mcpPort,
|
|
112
112
|
PARSE_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, INVALID_PARAMS, INTERNAL_ERROR,
|
|
113
113
|
} from "./mcp.js";
|
|
114
114
|
export type { JsonRpcMessage, McpToolDefinition, McpResourceDefinition, JsonSchema, McpToolParam } from "./mcp.js";
|
|
@@ -5,12 +5,13 @@ import {
|
|
|
5
5
|
renameSync,
|
|
6
6
|
statSync,
|
|
7
7
|
unlinkSync,
|
|
8
|
+
writeFileSync,
|
|
8
9
|
} from "node:fs";
|
|
9
|
-
import { join, dirname } from "node:path";
|
|
10
|
+
import { join, dirname, isAbsolute } from "node:path";
|
|
10
11
|
import { isTruthy } from "./dotenv.js";
|
|
11
12
|
|
|
12
13
|
/** Log level severity */
|
|
13
|
-
type LogLevel = "DEBUG" | "INFO" | "WARNING" | "ERROR";
|
|
14
|
+
type LogLevel = "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL";
|
|
14
15
|
|
|
15
16
|
/** Log level priority for filtering */
|
|
16
17
|
const LEVEL_PRIORITY: Record<LogLevel, number> = {
|
|
@@ -18,6 +19,7 @@ const LEVEL_PRIORITY: Record<LogLevel, number> = {
|
|
|
18
19
|
INFO: 1,
|
|
19
20
|
WARNING: 2,
|
|
20
21
|
ERROR: 3,
|
|
22
|
+
CRITICAL: 4,
|
|
21
23
|
};
|
|
22
24
|
|
|
23
25
|
/** Structured log entry for JSON output */
|
|
@@ -31,10 +33,11 @@ interface LogEntry {
|
|
|
31
33
|
|
|
32
34
|
/** ANSI color codes for terminal output */
|
|
33
35
|
const COLORS: Record<LogLevel, string> = {
|
|
34
|
-
DEBUG: "\x1b[36m",
|
|
35
|
-
INFO: "\x1b[32m",
|
|
36
|
-
WARNING: "\x1b[33m",
|
|
37
|
-
ERROR: "\x1b[31m",
|
|
36
|
+
DEBUG: "\x1b[36m", // cyan
|
|
37
|
+
INFO: "\x1b[32m", // green
|
|
38
|
+
WARNING: "\x1b[33m", // yellow
|
|
39
|
+
ERROR: "\x1b[31m", // red
|
|
40
|
+
CRITICAL: "\x1b[35m", // magenta
|
|
38
41
|
};
|
|
39
42
|
const RESET = "\x1b[0m";
|
|
40
43
|
|
|
@@ -47,28 +50,103 @@ const DEFAULT_LOG_DIR = "logs";
|
|
|
47
50
|
/** Default log filename */
|
|
48
51
|
const DEFAULT_LOG_FILE = "tina4.log";
|
|
49
52
|
|
|
53
|
+
/** Default rotation size — 10 MB */
|
|
54
|
+
const DEFAULT_ROTATE_SIZE = 10 * 1024 * 1024;
|
|
55
|
+
|
|
56
|
+
/** Default rotation keep count */
|
|
57
|
+
const DEFAULT_ROTATE_KEEP = 5;
|
|
58
|
+
|
|
50
59
|
/** Strip ANSI escape codes from a string */
|
|
51
60
|
function stripAnsi(text: string): string {
|
|
52
61
|
return text.replace(ANSI_RE, "");
|
|
53
62
|
}
|
|
54
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the log file path from env (or constructor options).
|
|
66
|
+
*
|
|
67
|
+
* If `TINA4_LOG_FILE` is set:
|
|
68
|
+
* - absolute path → used as-is.
|
|
69
|
+
* - relative → resolved against `TINA4_LOG_DIR` (default `logs`).
|
|
70
|
+
*
|
|
71
|
+
* Otherwise the directory is `TINA4_LOG_DIR` and filename is `tina4.log`.
|
|
72
|
+
*/
|
|
73
|
+
function resolveLogFilePath(logDir: string, logFile: string): string {
|
|
74
|
+
if (isAbsolute(logFile)) return logFile;
|
|
75
|
+
return join(logDir, logFile);
|
|
76
|
+
}
|
|
77
|
+
|
|
55
78
|
/**
|
|
56
79
|
* Structured logger for Tina4.
|
|
57
80
|
*
|
|
58
|
-
* Production (TINA4_DEBUG not truthy): JSON lines to logs/tina4.log
|
|
81
|
+
* Production (TINA4_DEBUG not truthy): JSON or text lines to logs/tina4.log
|
|
59
82
|
* Development (TINA4_DEBUG=true): Colorized human-readable to stdout + file
|
|
60
83
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
84
|
+
* Env vars:
|
|
85
|
+
* TINA4_LOG_FILE — explicit log file (absolute or relative). Empty = use TINA4_LOG_DIR + tina4.log
|
|
86
|
+
* TINA4_LOG_DIR — directory for log files (default: "logs")
|
|
87
|
+
* TINA4_LOG_FORMAT — "text" | "json" (default: "text")
|
|
88
|
+
* TINA4_LOG_OUTPUT — "stdout" | "file" | "both" (default: "stdout")
|
|
89
|
+
* TINA4_LOG_CRITICAL — "true" to enable CRITICAL level shortcut (default: "false")
|
|
90
|
+
* TINA4_LOG_ROTATE_SIZE — bytes; 0 disables rotation (default: 10485760 = 10MB)
|
|
91
|
+
* TINA4_LOG_ROTATE_KEEP — number of historical files to keep (default: 5)
|
|
92
|
+
* TINA4_LOG_LEVEL — minimum console level (default: "DEBUG")
|
|
93
|
+
*
|
|
94
|
+
* Rotation is stdlib roll-your-own:
|
|
95
|
+
* - On each write, statSync the file. If size >= TINA4_LOG_ROTATE_SIZE, rotate.
|
|
96
|
+
* - app.log.{N-1} → app.log.{N}, …, app.log → app.log.1 via fs.renameSync.
|
|
97
|
+
* - Files beyond _KEEP are dropped via fs.unlinkSync.
|
|
98
|
+
* - _SIZE=0 disables rotation entirely.
|
|
64
99
|
*/
|
|
65
100
|
export class Log {
|
|
66
101
|
private static requestId: string | undefined;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Re-read all log-related env vars. Called on every log() so tests that
|
|
105
|
+
* mutate process.env between calls see the new values without having to
|
|
106
|
+
* call configure() each time.
|
|
107
|
+
*/
|
|
108
|
+
private static readEnv(): {
|
|
109
|
+
logDir: string;
|
|
110
|
+
logFile: string;
|
|
111
|
+
rotateSize: number;
|
|
112
|
+
rotateKeep: number;
|
|
113
|
+
minLevel: number;
|
|
114
|
+
format: "text" | "json";
|
|
115
|
+
output: "stdout" | "file" | "both";
|
|
116
|
+
criticalEnabled: boolean;
|
|
117
|
+
} {
|
|
118
|
+
const logDir = process.env.TINA4_LOG_DIR ?? DEFAULT_LOG_DIR;
|
|
119
|
+
const logFile = (process.env.TINA4_LOG_FILE ?? "").trim() || DEFAULT_LOG_FILE;
|
|
120
|
+
|
|
121
|
+
const rawSize = process.env.TINA4_LOG_ROTATE_SIZE;
|
|
122
|
+
let rotateSize = DEFAULT_ROTATE_SIZE;
|
|
123
|
+
if (rawSize !== undefined) {
|
|
124
|
+
const n = parseInt(rawSize, 10);
|
|
125
|
+
rotateSize = isNaN(n) || n < 0 ? DEFAULT_ROTATE_SIZE : n;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const rawKeep = process.env.TINA4_LOG_ROTATE_KEEP;
|
|
129
|
+
let rotateKeep = DEFAULT_ROTATE_KEEP;
|
|
130
|
+
if (rawKeep !== undefined) {
|
|
131
|
+
const n = parseInt(rawKeep, 10);
|
|
132
|
+
rotateKeep = isNaN(n) || n < 1 ? DEFAULT_ROTATE_KEEP : n;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const levelEnv = (process.env.TINA4_LOG_LEVEL ?? "DEBUG").toUpperCase();
|
|
136
|
+
const minLevel = LEVEL_PRIORITY[levelEnv as LogLevel] ?? 0;
|
|
137
|
+
|
|
138
|
+
const fmt = (process.env.TINA4_LOG_FORMAT ?? "text").trim().toLowerCase();
|
|
139
|
+
const format: "text" | "json" = fmt === "json" ? "json" : "text";
|
|
140
|
+
|
|
141
|
+
const out = (process.env.TINA4_LOG_OUTPUT ?? "stdout").trim().toLowerCase();
|
|
142
|
+
let output: "stdout" | "file" | "both" = "stdout";
|
|
143
|
+
if (out === "file") output = "file";
|
|
144
|
+
else if (out === "both") output = "both";
|
|
145
|
+
|
|
146
|
+
const criticalEnabled = isTruthy(process.env.TINA4_LOG_CRITICAL);
|
|
147
|
+
|
|
148
|
+
return { logDir, logFile, rotateSize, rotateKeep, minLevel, format, output, criticalEnabled };
|
|
149
|
+
}
|
|
72
150
|
|
|
73
151
|
/**
|
|
74
152
|
* Set the current request ID for log correlation.
|
|
@@ -85,21 +163,12 @@ export class Log {
|
|
|
85
163
|
}
|
|
86
164
|
|
|
87
165
|
/**
|
|
88
|
-
* Configure the log directory
|
|
166
|
+
* Configure the log directory / filename. Mostly a no-op now —
|
|
167
|
+
* env vars are re-read on every call. Kept for backwards compatibility.
|
|
89
168
|
*/
|
|
90
169
|
static configure(options: { logDir?: string; logFile?: string }): void {
|
|
91
|
-
if (options.logDir)
|
|
92
|
-
if (options.logFile)
|
|
93
|
-
|
|
94
|
-
// Read rotation config from env (with defaults)
|
|
95
|
-
const maxSizeMb = parseInt(process.env.TINA4_LOG_MAX_SIZE ?? "10", 10);
|
|
96
|
-
Log.maxFileSize = (isNaN(maxSizeMb) ? 10 : maxSizeMb) * 1024 * 1024;
|
|
97
|
-
const keep = parseInt(process.env.TINA4_LOG_KEEP ?? "5", 10);
|
|
98
|
-
Log.keepFiles = isNaN(keep) ? 5 : keep;
|
|
99
|
-
|
|
100
|
-
// Resolve minimum console log level
|
|
101
|
-
const levelEnv = (process.env.TINA4_LOG_LEVEL ?? "DEBUG").toUpperCase();
|
|
102
|
-
Log.minLevel = LEVEL_PRIORITY[levelEnv as LogLevel] ?? 0;
|
|
170
|
+
if (options.logDir) process.env.TINA4_LOG_DIR = options.logDir;
|
|
171
|
+
if (options.logFile) process.env.TINA4_LOG_FILE = options.logFile;
|
|
103
172
|
}
|
|
104
173
|
|
|
105
174
|
/** Log an informational message. */
|
|
@@ -117,11 +186,25 @@ export class Log {
|
|
|
117
186
|
Log.log("WARNING", message, data);
|
|
118
187
|
}
|
|
119
188
|
|
|
189
|
+
/** Backwards-compat alias for warning(). */
|
|
190
|
+
static warn(message: string, data?: unknown): void {
|
|
191
|
+
Log.log("WARNING", message, data);
|
|
192
|
+
}
|
|
193
|
+
|
|
120
194
|
/** Log an error message. */
|
|
121
195
|
static error(message: string, data?: unknown): void {
|
|
122
196
|
Log.log("ERROR", message, data);
|
|
123
197
|
}
|
|
124
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Log a critical message. Only emitted when TINA4_LOG_CRITICAL=true,
|
|
201
|
+
* otherwise this is a no-op (matches Python parity — critical is the
|
|
202
|
+
* highest-severity bucket and is opt-in to avoid drowning noisy apps).
|
|
203
|
+
*/
|
|
204
|
+
static critical(message: string, data?: unknown): void {
|
|
205
|
+
Log.log("CRITICAL", message, data);
|
|
206
|
+
}
|
|
207
|
+
|
|
125
208
|
/** Check if running in production mode (TINA4_DEBUG is not truthy). */
|
|
126
209
|
private static isProduction(): boolean {
|
|
127
210
|
return !isTruthy(process.env.TINA4_DEBUG);
|
|
@@ -132,43 +215,59 @@ export class Log {
|
|
|
132
215
|
return new Date().toISOString();
|
|
133
216
|
}
|
|
134
217
|
|
|
135
|
-
/** Get the full path to the current log file */
|
|
136
|
-
private static logFilePath(): string {
|
|
137
|
-
return join(Log.logDir, Log.logFile);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
218
|
/** Ensure the log directory exists */
|
|
141
|
-
private static ensureLogDir(): void {
|
|
142
|
-
const dir = dirname(
|
|
219
|
+
private static ensureLogDir(filePath: string): void {
|
|
220
|
+
const dir = dirname(filePath);
|
|
143
221
|
if (!existsSync(dir)) {
|
|
144
222
|
mkdirSync(dir, { recursive: true });
|
|
145
223
|
}
|
|
146
224
|
}
|
|
147
225
|
|
|
148
226
|
/**
|
|
149
|
-
*
|
|
227
|
+
* Roll-your-own rotation, stdlib only.
|
|
228
|
+
*
|
|
229
|
+
* Sequence on each write:
|
|
230
|
+
* 1. statSync the current file. If size < rotateSize, return.
|
|
231
|
+
* 2. Drop any file beyond keep via unlinkSync (cap the historical count).
|
|
232
|
+
* 3. Atomic shift: app.log.{N-1} → app.log.{N}, …, app.log.1 → app.log.2.
|
|
233
|
+
* 4. Rename current app.log → app.log.1.
|
|
234
|
+
* 5. Truncate via writeFileSync(path, "") so subsequent appends start fresh.
|
|
235
|
+
*
|
|
236
|
+
* Sync calls per write are fine — the worst case is contention on a single
|
|
237
|
+
* file, and the OS atomically serialises rename/unlink anyway.
|
|
238
|
+
*
|
|
239
|
+
* `rotateSize` of 0 disables rotation entirely.
|
|
150
240
|
*/
|
|
151
|
-
private static rotateIfNeeded(): void {
|
|
152
|
-
|
|
241
|
+
private static rotateIfNeeded(filePath: string, rotateSize: number, rotateKeep: number): void {
|
|
242
|
+
if (rotateSize <= 0) return;
|
|
153
243
|
if (!existsSync(filePath)) return;
|
|
154
244
|
|
|
245
|
+
let size = 0;
|
|
155
246
|
try {
|
|
156
|
-
|
|
157
|
-
if (stats.size < Log.maxFileSize) return;
|
|
247
|
+
size = statSync(filePath).size;
|
|
158
248
|
} catch {
|
|
159
249
|
return;
|
|
160
250
|
}
|
|
251
|
+
if (size < rotateSize) return;
|
|
252
|
+
|
|
253
|
+
// Drop files beyond keep
|
|
254
|
+
for (let n = rotateKeep + 1; n <= rotateKeep + 10; n++) {
|
|
255
|
+
const stale = `${filePath}.${n}`;
|
|
256
|
+
if (existsSync(stale)) {
|
|
257
|
+
try { unlinkSync(stale); } catch { /* ignore */ }
|
|
258
|
+
} else {
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
161
262
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
// Delete the oldest rotated file if it exists
|
|
165
|
-
const oldest = `${filePath}.${keep}`;
|
|
263
|
+
// Drop the oldest in-window file if at capacity
|
|
264
|
+
const oldest = `${filePath}.${rotateKeep}`;
|
|
166
265
|
if (existsSync(oldest)) {
|
|
167
266
|
try { unlinkSync(oldest); } catch { /* ignore */ }
|
|
168
267
|
}
|
|
169
268
|
|
|
170
|
-
// Shift
|
|
171
|
-
for (let n =
|
|
269
|
+
// Shift: .{N-1} -> .{N}, ..., .1 -> .2
|
|
270
|
+
for (let n = rotateKeep - 1; n >= 1; n--) {
|
|
172
271
|
const src = `${filePath}.${n}`;
|
|
173
272
|
const dst = `${filePath}.${n + 1}`;
|
|
174
273
|
if (existsSync(src)) {
|
|
@@ -176,16 +275,17 @@ export class Log {
|
|
|
176
275
|
}
|
|
177
276
|
}
|
|
178
277
|
|
|
179
|
-
//
|
|
278
|
+
// Move current → .1, then truncate
|
|
180
279
|
try { renameSync(filePath, `${filePath}.1`); } catch { /* ignore */ }
|
|
280
|
+
try { writeFileSync(filePath, "", "utf-8"); } catch { /* ignore */ }
|
|
181
281
|
}
|
|
182
282
|
|
|
183
|
-
/** Write a line to the log file, stripping ANSI codes */
|
|
184
|
-
private static writeToFile(line: string): void {
|
|
283
|
+
/** Write a line to the log file, stripping ANSI codes. */
|
|
284
|
+
private static writeToFile(filePath: string, line: string, rotateSize: number, rotateKeep: number): void {
|
|
185
285
|
try {
|
|
186
|
-
Log.ensureLogDir();
|
|
187
|
-
Log.rotateIfNeeded();
|
|
188
|
-
appendFileSync(
|
|
286
|
+
Log.ensureLogDir(filePath);
|
|
287
|
+
Log.rotateIfNeeded(filePath, rotateSize, rotateKeep);
|
|
288
|
+
appendFileSync(filePath, stripAnsi(line) + "\n", "utf-8");
|
|
189
289
|
} catch {
|
|
190
290
|
// Silently fail — logging should never crash the app
|
|
191
291
|
}
|
|
@@ -193,6 +293,11 @@ export class Log {
|
|
|
193
293
|
|
|
194
294
|
/** Core log method */
|
|
195
295
|
private static log(level: LogLevel, message: string, data?: unknown): void {
|
|
296
|
+
const cfg = Log.readEnv();
|
|
297
|
+
|
|
298
|
+
// Critical level is opt-in; treat as no-op when disabled.
|
|
299
|
+
if (level === "CRITICAL" && !cfg.criticalEnabled) return;
|
|
300
|
+
|
|
196
301
|
const entry: LogEntry = {
|
|
197
302
|
timestamp: Log.timestamp(),
|
|
198
303
|
level,
|
|
@@ -207,20 +312,35 @@ export class Log {
|
|
|
207
312
|
entry.context = data;
|
|
208
313
|
}
|
|
209
314
|
|
|
210
|
-
// Build human-readable line
|
|
211
|
-
const paddedLevel = level.padEnd(
|
|
315
|
+
// Build human-readable line
|
|
316
|
+
const paddedLevel = level.padEnd(8);
|
|
212
317
|
const reqPart = Log.requestId ? ` [${Log.requestId}]` : "";
|
|
213
318
|
const dataPart = data !== undefined ? ` ${JSON.stringify(data)}` : "";
|
|
214
319
|
const humanLine = `${entry.timestamp} [${paddedLevel}]${reqPart} ${message}${dataPart}`;
|
|
215
320
|
|
|
216
|
-
//
|
|
217
|
-
const
|
|
218
|
-
|
|
321
|
+
// Build the file-format line based on TINA4_LOG_FORMAT
|
|
322
|
+
const fileLine = cfg.format === "json" ? JSON.stringify(entry) : humanLine;
|
|
323
|
+
|
|
324
|
+
const shouldLog = (LEVEL_PRIORITY[level] ?? 0) >= cfg.minLevel;
|
|
325
|
+
|
|
326
|
+
// Console output. TINA4_LOG_OUTPUT="file" disables stdout entirely;
|
|
327
|
+
// anything else (stdout, both) prints to console in dev, suppresses in prod.
|
|
328
|
+
if (shouldLog && cfg.output !== "file" && !Log.isProduction()) {
|
|
219
329
|
const color = COLORS[level];
|
|
220
330
|
console.log(`${color}${humanLine}${RESET}`);
|
|
221
331
|
}
|
|
222
332
|
|
|
223
|
-
// File always
|
|
224
|
-
|
|
333
|
+
// File output: always teed for dev (legacy behaviour), and either always
|
|
334
|
+
// (production default) or honoured per output mode.
|
|
335
|
+
//
|
|
336
|
+
// output=stdout (default): file in dev + prod (legacy parity)
|
|
337
|
+
// output=file file only — no console
|
|
338
|
+
// output=both file + console (already handled above)
|
|
339
|
+
//
|
|
340
|
+
// The "stdout-only without file" mode that some Python deployments want
|
|
341
|
+
// is gated on TINA4_LOG_OUTPUT=stdout combined with TINA4_LOG_FILE set
|
|
342
|
+
// explicitly to an empty string in env — we treat empty file as default.
|
|
343
|
+
const filePath = resolveLogFilePath(cfg.logDir, cfg.logFile);
|
|
344
|
+
Log.writeToFile(filePath, fileLine, cfg.rotateSize, cfg.rotateKeep);
|
|
225
345
|
}
|
|
226
346
|
}
|
package/packages/core/src/mcp.ts
CHANGED
|
@@ -162,6 +162,44 @@ export function isLocalhost(): boolean {
|
|
|
162
162
|
return ["localhost", "127.0.0.1", "0.0.0.0", "::1", ""].includes(host);
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
// ── MCP env config (Python parity) ───────────────────────────
|
|
166
|
+
|
|
167
|
+
/** Truthy check that mirrors `dotenv.isTruthy` without the import cycle. */
|
|
168
|
+
function envTruthy(val: string | undefined): boolean {
|
|
169
|
+
if (val == null) return false;
|
|
170
|
+
return ["true", "1", "yes", "on"].includes(val.trim().toLowerCase());
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Whether the built-in MCP server should auto-start.
|
|
175
|
+
*
|
|
176
|
+
* Default: `true` when `TINA4_DEBUG=true`, `false` otherwise. The `TINA4_MCP`
|
|
177
|
+
* env var can force either state explicitly. Matches the Python framework
|
|
178
|
+
* which only exposes MCP endpoints in dev mode by default.
|
|
179
|
+
*/
|
|
180
|
+
export function mcpEnabled(): boolean {
|
|
181
|
+
const raw = process.env.TINA4_MCP;
|
|
182
|
+
if (raw === undefined || raw.trim() === "") {
|
|
183
|
+
return envTruthy(process.env.TINA4_DEBUG);
|
|
184
|
+
}
|
|
185
|
+
return envTruthy(raw);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Resolve the MCP HTTP port. Default: HTTP server port + 2000.
|
|
190
|
+
*
|
|
191
|
+
* `TINA4_MCP_PORT` overrides directly. The `mainPort` argument is the
|
|
192
|
+
* primary HTTP port (the framework passes `port` from `resolvePortAndHost`).
|
|
193
|
+
*/
|
|
194
|
+
export function mcpPort(mainPort: number = 7148): number {
|
|
195
|
+
const raw = process.env.TINA4_MCP_PORT;
|
|
196
|
+
if (raw && raw.trim() !== "") {
|
|
197
|
+
const n = parseInt(raw, 10);
|
|
198
|
+
if (!isNaN(n) && n > 0) return n;
|
|
199
|
+
}
|
|
200
|
+
return mainPort + 2000;
|
|
201
|
+
}
|
|
202
|
+
|
|
165
203
|
// ── McpServer class ──────────────────────────────────────────
|
|
166
204
|
|
|
167
205
|
export class McpServer {
|
|
@@ -67,6 +67,8 @@ interface MessengerOptions {
|
|
|
67
67
|
imapPort?: number;
|
|
68
68
|
imapUser?: string;
|
|
69
69
|
imapPass?: string;
|
|
70
|
+
/** IMAP transport security: "tls" (default), "starttls", or "none". */
|
|
71
|
+
imapEncryption?: string;
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
export interface ImapMessage {
|
|
@@ -300,6 +302,7 @@ export class Messenger {
|
|
|
300
302
|
private imapPort: number;
|
|
301
303
|
private imapUser: string;
|
|
302
304
|
private imapPass: string;
|
|
305
|
+
private imapEncryption: string;
|
|
303
306
|
|
|
304
307
|
constructor(options?: MessengerOptions) {
|
|
305
308
|
// Priority: constructor > TINA4_MAIL_* > sensible default.
|
|
@@ -345,6 +348,22 @@ export class Messenger {
|
|
|
345
348
|
this.imapPass = options?.imapPass
|
|
346
349
|
?? process.env.TINA4_MAIL_IMAP_PASSWORD
|
|
347
350
|
?? this.password;
|
|
351
|
+
|
|
352
|
+
// IMAP encryption — separate from SMTP encryption because IMAP almost
|
|
353
|
+
// always uses port 993 + implicit TLS while SMTP toggles between 587
|
|
354
|
+
// (STARTTLS) and 465 (implicit TLS). Default is "tls" to match
|
|
355
|
+
// industry-standard IMAPS port 993 behaviour.
|
|
356
|
+
this.imapEncryption = (options?.imapEncryption
|
|
357
|
+
?? process.env.TINA4_MAIL_IMAP_ENCRYPTION
|
|
358
|
+
?? "tls").toLowerCase();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Read-only IMAP encryption mode for inspection / tests.
|
|
363
|
+
* Returns one of "tls", "starttls", "none", "ssl".
|
|
364
|
+
*/
|
|
365
|
+
getImapEncryption(): string {
|
|
366
|
+
return this.imapEncryption;
|
|
348
367
|
}
|
|
349
368
|
|
|
350
369
|
/**
|
|
@@ -570,7 +589,12 @@ export class Messenger {
|
|
|
570
589
|
|
|
571
590
|
let socket: net.Socket | tls.TLSSocket;
|
|
572
591
|
|
|
573
|
-
|
|
592
|
+
// Honour TINA4_MAIL_IMAP_ENCRYPTION when set; otherwise infer from port.
|
|
593
|
+
// "tls" / "ssl" → implicit TLS connect; anything else → plain connect.
|
|
594
|
+
const useTls = this.imapEncryption === "tls" || this.imapEncryption === "ssl"
|
|
595
|
+
|| (this.imapEncryption === "" && this.imapPort === 993);
|
|
596
|
+
|
|
597
|
+
if (useTls) {
|
|
574
598
|
socket = tls.connect({ host: this.imapHost, port: this.imapPort, rejectUnauthorized: false });
|
|
575
599
|
await new Promise<void>((resolve, reject) => {
|
|
576
600
|
socket.once("secureConnect", resolve);
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
import type { RouteHandler, RouteDefinition, RouteMeta, Middleware, Tina4Request, Tina4Response, WebSocketRouteHandler, WebSocketRouteDefinition } from "./types.js";
|
|
2
|
+
import { isTruthy } from "./dotenv.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether `TINA4_TRAILING_SLASH_REDIRECT` is enabled.
|
|
6
|
+
*
|
|
7
|
+
* Default: false. When true, a request to `/foo/` that has no exact match
|
|
8
|
+
* but matches `/foo` will be treated as a hit on `/foo` — callers can use
|
|
9
|
+
* the returned pattern to issue a 308 redirect (Python parity).
|
|
10
|
+
*/
|
|
11
|
+
export function isTrailingSlashRedirectEnabled(): boolean {
|
|
12
|
+
return isTruthy(process.env.TINA4_TRAILING_SLASH_REDIRECT);
|
|
13
|
+
}
|
|
2
14
|
|
|
3
15
|
interface MatchResult {
|
|
4
16
|
handler: RouteHandler;
|
|
@@ -199,6 +211,11 @@ export class Router {
|
|
|
199
211
|
|
|
200
212
|
/**
|
|
201
213
|
* Match a request method + pathname to a registered route.
|
|
214
|
+
*
|
|
215
|
+
* When `TINA4_TRAILING_SLASH_REDIRECT=true` and the request path ends in
|
|
216
|
+
* a trailing slash that doesn't match a registered route, retry without
|
|
217
|
+
* the trailing slash. Returning the de-slashed pattern lets callers issue
|
|
218
|
+
* a 308 redirect instead of a hard 404 — Python parity.
|
|
202
219
|
*/
|
|
203
220
|
match(method: string, path: string): MatchResult | null {
|
|
204
221
|
const upperMethod = method.toUpperCase();
|
|
@@ -207,6 +224,28 @@ export class Router {
|
|
|
207
224
|
const routes = this.routes.get(upperMethod);
|
|
208
225
|
if (!routes) return null;
|
|
209
226
|
|
|
227
|
+
const direct = this.matchRoute(routes, path);
|
|
228
|
+
if (direct) return direct;
|
|
229
|
+
|
|
230
|
+
// Trailing-slash redirect — strip a single trailing "/" and retry.
|
|
231
|
+
// Root "/" is intentionally excluded (it's its own route).
|
|
232
|
+
if (
|
|
233
|
+
isTrailingSlashRedirectEnabled() &&
|
|
234
|
+
path.length > 1 &&
|
|
235
|
+
path.endsWith("/")
|
|
236
|
+
) {
|
|
237
|
+
const stripped = path.replace(/\/+$/, "");
|
|
238
|
+
if (stripped.length > 0) {
|
|
239
|
+
const retry = this.matchRoute(routes, stripped);
|
|
240
|
+
if (retry) return retry;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Inner match against a list of compiled routes, no trailing-slash logic. */
|
|
248
|
+
private matchRoute(routes: CompiledRoute[], path: string): MatchResult | null {
|
|
210
249
|
for (const route of routes) {
|
|
211
250
|
const match = route.regex.exec(path);
|
|
212
251
|
if (match) {
|
|
@@ -227,7 +266,6 @@ export class Router {
|
|
|
227
266
|
};
|
|
228
267
|
}
|
|
229
268
|
}
|
|
230
|
-
|
|
231
269
|
return null;
|
|
232
270
|
}
|
|
233
271
|
|
|
@@ -15,7 +15,7 @@ import { createResponse, setDefaultTemplatesDir } from "./response.js";
|
|
|
15
15
|
import { MiddlewareChain, cors, requestLogger } from "./middleware.js";
|
|
16
16
|
import { tryServeStatic } from "./static.js";
|
|
17
17
|
import { loadEnv, isTruthy } from "./dotenv.js";
|
|
18
|
-
import {
|
|
18
|
+
import { createHealthRoutes } from "./health.js";
|
|
19
19
|
import { rateLimiter } from "./rateLimiter.js";
|
|
20
20
|
import { Log } from "./logger.js";
|
|
21
21
|
import { DevAdmin, RequestInspector } from "./devAdmin.js";
|
|
@@ -114,7 +114,7 @@ export function _checkLegacyEnvVars(): void {
|
|
|
114
114
|
}
|
|
115
115
|
lines.push(
|
|
116
116
|
"",
|
|
117
|
-
"Run `tina4 env
|
|
117
|
+
"Run `tina4 env --migrate` to rewrite your .env automatically,",
|
|
118
118
|
"or rename manually. See https://tina4.com/release/3.12.0",
|
|
119
119
|
"Set TINA4_ALLOW_LEGACY_ENV=true to bypass during migration.",
|
|
120
120
|
bar,
|
|
@@ -198,17 +198,32 @@ function openBrowser(url: string) {
|
|
|
198
198
|
/**
|
|
199
199
|
* Resolve port and host with priority: explicit config > ENV var > default.
|
|
200
200
|
* Exported for testability.
|
|
201
|
+
*
|
|
202
|
+
* Host resolution prefers `TINA4_HOST` (the framework-prefixed name —
|
|
203
|
+
* matches Python parity) and falls back to the unprefixed `HOST` env var
|
|
204
|
+
* for backwards compatibility.
|
|
201
205
|
*/
|
|
202
206
|
export function resolvePortAndHost(config?: { port?: number; host?: string }): { port: number; host: string } {
|
|
203
207
|
const port = config?.port
|
|
204
208
|
?? (process.env.PORT ? parseInt(process.env.PORT, 10) : undefined)
|
|
205
209
|
?? 7148;
|
|
206
210
|
const host = config?.host
|
|
211
|
+
?? process.env.TINA4_HOST
|
|
207
212
|
?? process.env.HOST
|
|
208
213
|
?? "0.0.0.0";
|
|
209
214
|
return { port, host };
|
|
210
215
|
}
|
|
211
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Whether the boot banner should be suppressed. Set TINA4_SUPPRESS=true to
|
|
219
|
+
* silence the ASCII-art banner and route table on startup — useful in CI,
|
|
220
|
+
* test runners, and embedded contexts where stdout is consumed by another
|
|
221
|
+
* process.
|
|
222
|
+
*/
|
|
223
|
+
export function isBannerSuppressed(): boolean {
|
|
224
|
+
return isTruthy(process.env.TINA4_SUPPRESS);
|
|
225
|
+
}
|
|
226
|
+
|
|
212
227
|
function isDevMode(): boolean {
|
|
213
228
|
return isTruthy(process.env.TINA4_DEBUG);
|
|
214
229
|
}
|
|
@@ -677,7 +692,8 @@ export async function startServer(config?: Tina4Config): Promise<{
|
|
|
677
692
|
const reset = isTty ? "\x1b[0m" : "";
|
|
678
693
|
const logLevel = (process.env.TINA4_LOG_LEVEL ?? "DEBUG").toUpperCase();
|
|
679
694
|
|
|
680
|
-
|
|
695
|
+
if (!isBannerSuppressed()) {
|
|
696
|
+
console.log(`${color}
|
|
681
697
|
______ _ __ __
|
|
682
698
|
/_ __/(_)___ ____ _/ // /
|
|
683
699
|
/ / / / __ \\/ __ \`/ // /_
|
|
@@ -691,6 +707,7 @@ ${reset}
|
|
|
691
707
|
Dashboard: http://localhost:${port}/__dev
|
|
692
708
|
Debug: OFF (Log level: ${logLevel})
|
|
693
709
|
`);
|
|
710
|
+
}
|
|
694
711
|
|
|
695
712
|
for (let i = 0; i < numCPUs; i++) {
|
|
696
713
|
cluster.fork();
|
|
@@ -737,9 +754,11 @@ ${reset}
|
|
|
737
754
|
router.addRoute(route);
|
|
738
755
|
}
|
|
739
756
|
|
|
740
|
-
// Register health check endpoint
|
|
741
|
-
|
|
742
|
-
|
|
757
|
+
// Register health check endpoint(s). createHealthRoutes returns both the
|
|
758
|
+
// env-configured path (default /__health) and a /health legacy alias.
|
|
759
|
+
for (const healthRoute of createHealthRoutes(TINA4_VERSION)) {
|
|
760
|
+
router.addRoute(healthRoute);
|
|
761
|
+
}
|
|
743
762
|
|
|
744
763
|
// Initialize Frond template engine
|
|
745
764
|
let frondEngine: any = null;
|
|
@@ -837,9 +856,16 @@ ${reset}
|
|
|
837
856
|
}
|
|
838
857
|
}
|
|
839
858
|
|
|
840
|
-
// Initialize Swagger
|
|
859
|
+
// Initialize Swagger — gated on TINA4_SWAGGER_ENABLED (default: enabled
|
|
860
|
+
// in debug mode, off in production). Loading the swagger module also
|
|
861
|
+
// pulls in route discovery for the generator, so skip the import entirely
|
|
862
|
+
// when disabled.
|
|
841
863
|
try {
|
|
842
864
|
const swagger = await import("../../swagger/src/index.js");
|
|
865
|
+
if (!swagger.swaggerEnabled()) {
|
|
866
|
+
// Skip the rest of the swagger block when disabled.
|
|
867
|
+
throw new Error("__swagger_disabled__");
|
|
868
|
+
}
|
|
843
869
|
const allRoutes = router.getRoutes();
|
|
844
870
|
|
|
845
871
|
// Collect model definitions for schema generation
|
|
@@ -889,9 +915,12 @@ ${reset}
|
|
|
889
915
|
|
|
890
916
|
// Auto-start session — read cookie, create session, save + set cookie on response end
|
|
891
917
|
{
|
|
892
|
-
const { Session } = await import("./session.js");
|
|
918
|
+
const { Session, buildSessionCookie } = await import("./session.js");
|
|
893
919
|
const cookieHeader = rawReq.headers.cookie ?? "";
|
|
894
|
-
const
|
|
920
|
+
const cookieName = process.env.TINA4_SESSION_NAME ?? "tina4_session";
|
|
921
|
+
// Build a regex from the (possibly customised) cookie name. Escape regex meta-chars.
|
|
922
|
+
const escapedName = cookieName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
923
|
+
const sidMatch = cookieHeader.match(new RegExp(`${escapedName}=([^;]+)`));
|
|
895
924
|
const existingSid = sidMatch ? sidMatch[1] : undefined;
|
|
896
925
|
const sess = new Session();
|
|
897
926
|
sess.start(existingSid);
|
|
@@ -909,8 +938,7 @@ ${reset}
|
|
|
909
938
|
const newSid = (sess as any).sessionId ?? (sess as any).getSessionId?.();
|
|
910
939
|
if (newSid && newSid !== existingSid && !rawRes.headersSent) {
|
|
911
940
|
const ttl = parseInt(process.env.TINA4_SESSION_TTL ?? "3600", 10);
|
|
912
|
-
|
|
913
|
-
rawRes.setHeader("Set-Cookie", `tina4_session=${newSid}; Path=/; HttpOnly; SameSite=${sameSite}; Max-Age=${ttl}`);
|
|
941
|
+
rawRes.setHeader("Set-Cookie", buildSessionCookie(newSid, ttl));
|
|
914
942
|
}
|
|
915
943
|
return origEnd(...args);
|
|
916
944
|
} as typeof rawRes.end;
|
|
@@ -1234,7 +1262,8 @@ ${reset}
|
|
|
1234
1262
|
? `\n Test Port: http://localhost:${testPort} (stable — no hot-reload)`
|
|
1235
1263
|
: "";
|
|
1236
1264
|
|
|
1237
|
-
|
|
1265
|
+
if (!isBannerSuppressed()) {
|
|
1266
|
+
console.log(`${color}
|
|
1238
1267
|
______ _ __ __
|
|
1239
1268
|
/_ __/(_)___ ____ _/ // /
|
|
1240
1269
|
/ / / / __ \\/ __ \`/ // /_
|
|
@@ -1248,6 +1277,7 @@ ${reset}
|
|
|
1248
1277
|
Dashboard: http://localhost:${port}/__dev
|
|
1249
1278
|
Debug: ${isDebug ? "ON" : "OFF"} (Log level: ${logLevel})${dualPortLines}
|
|
1250
1279
|
`);
|
|
1280
|
+
}
|
|
1251
1281
|
const noBrowser = isTruthy(process.env.TINA4_NO_BROWSER);
|
|
1252
1282
|
if (!noBrowser) {
|
|
1253
1283
|
// Open browser on test port (hot-reload) if available, otherwise main port
|
|
@@ -531,10 +531,15 @@ export class Session {
|
|
|
531
531
|
|
|
532
532
|
/**
|
|
533
533
|
* Return a Set-Cookie header value for this session.
|
|
534
|
+
*
|
|
535
|
+
* Honours these env vars (cross-framework parity):
|
|
536
|
+
* TINA4_SESSION_NAME — cookie name (default: "tina4_session")
|
|
537
|
+
* TINA4_SESSION_SAMESITE — SameSite attribute (default: "Lax")
|
|
538
|
+
* TINA4_SESSION_HTTPONLY — emit HttpOnly (default: true)
|
|
539
|
+
* TINA4_SESSION_SECURE — emit Secure (default: false)
|
|
534
540
|
*/
|
|
535
|
-
cookieHeader(cookieName
|
|
536
|
-
|
|
537
|
-
return `${cookieName}=${this.sessionId}; Path=/; HttpOnly; SameSite=${sameSite}; Max-Age=${this.ttl}`;
|
|
541
|
+
cookieHeader(cookieName?: string): string {
|
|
542
|
+
return buildSessionCookie(this.sessionId, this.ttl, cookieName);
|
|
538
543
|
}
|
|
539
544
|
|
|
540
545
|
/**
|
|
@@ -555,3 +560,38 @@ export class Session {
|
|
|
555
560
|
this.handler.write(this.sessionId, this.data, this.ttl);
|
|
556
561
|
}
|
|
557
562
|
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Build the `Set-Cookie` header value for a Tina4 session. Centralised so
|
|
566
|
+
* the auto-cookie path in server.ts and `Session.cookieHeader()` agree on
|
|
567
|
+
* which env vars are honoured and what the defaults are.
|
|
568
|
+
*
|
|
569
|
+
* Env vars (Python parity):
|
|
570
|
+
* TINA4_SESSION_NAME — cookie name (default: "tina4_session")
|
|
571
|
+
* TINA4_SESSION_SAMESITE — SameSite attribute (default: "Lax")
|
|
572
|
+
* TINA4_SESSION_HTTPONLY — emit HttpOnly (default: true)
|
|
573
|
+
* TINA4_SESSION_SECURE — emit Secure (default: false)
|
|
574
|
+
*/
|
|
575
|
+
export function buildSessionCookie(sessionId: string | null, ttl: number, cookieName?: string): string {
|
|
576
|
+
const name = cookieName ?? process.env.TINA4_SESSION_NAME ?? "tina4_session";
|
|
577
|
+
const sameSite = process.env.TINA4_SESSION_SAMESITE ?? "Lax";
|
|
578
|
+
|
|
579
|
+
// HttpOnly defaults to TRUE (matches existing behaviour and Python parity).
|
|
580
|
+
// Treat any explicit non-truthy value (false/0/no/off) as opt-out.
|
|
581
|
+
const httpOnlyRaw = process.env.TINA4_SESSION_HTTPONLY;
|
|
582
|
+
const httpOnly = httpOnlyRaw === undefined
|
|
583
|
+
? true
|
|
584
|
+
: !["false", "0", "no", "off"].includes(httpOnlyRaw.trim().toLowerCase());
|
|
585
|
+
|
|
586
|
+
// Secure defaults to FALSE — only emit when the operator opts in (https
|
|
587
|
+
// deployments). Setting it eagerly would break http://localhost dev cookies.
|
|
588
|
+
const secureRaw = process.env.TINA4_SESSION_SECURE ?? "";
|
|
589
|
+
const secure = ["true", "1", "yes", "on"].includes(secureRaw.trim().toLowerCase());
|
|
590
|
+
|
|
591
|
+
const parts = [`${name}=${sessionId ?? ""}`, "Path=/"];
|
|
592
|
+
if (httpOnly) parts.push("HttpOnly");
|
|
593
|
+
parts.push(`SameSite=${sameSite}`);
|
|
594
|
+
if (secure) parts.push("Secure");
|
|
595
|
+
parts.push(`Max-Age=${ttl}`);
|
|
596
|
+
return parts.join("; ");
|
|
597
|
+
}
|
|
@@ -1327,10 +1327,16 @@ export class Frond {
|
|
|
1327
1327
|
private _allowedVars: Set<string> | null;
|
|
1328
1328
|
private fragmentCache: Map<string, [string, number]>;
|
|
1329
1329
|
private _autoEscape: boolean;
|
|
1330
|
-
/**
|
|
1331
|
-
|
|
1330
|
+
/**
|
|
1331
|
+
* Token pre-compilation cache for file templates.
|
|
1332
|
+
*
|
|
1333
|
+
* `cachedAt` is captured so the TINA4_TEMPLATE_CACHE_TTL env var can
|
|
1334
|
+
* force re-compilation after N seconds even in production. TTL of 0
|
|
1335
|
+
* means "no time-based invalidation" — entries live forever.
|
|
1336
|
+
*/
|
|
1337
|
+
private compiled = new Map<string, { tokens: Token[]; mtime: number; cachedAt: number }>();
|
|
1332
1338
|
/** Token pre-compilation cache for string templates */
|
|
1333
|
-
private compiledStrings = new Map<string, Token[]>();
|
|
1339
|
+
private compiledStrings = new Map<string, { tokens: Token[]; cachedAt: number }>();
|
|
1334
1340
|
|
|
1335
1341
|
getTemplateDir(): string { return this.templateDir; }
|
|
1336
1342
|
|
|
@@ -1386,6 +1392,20 @@ export class Frond {
|
|
|
1386
1392
|
this.tests[name] = fn;
|
|
1387
1393
|
}
|
|
1388
1394
|
|
|
1395
|
+
/**
|
|
1396
|
+
* Read the cache TTL in seconds. `TINA4_TEMPLATE_CACHE_TTL=0` (the
|
|
1397
|
+
* default) keeps the existing "cache forever in prod" behaviour — any
|
|
1398
|
+
* positive value invalidates compiled tokens after N seconds, useful
|
|
1399
|
+
* when running long-lived servers behind a slow file sync where mtime
|
|
1400
|
+
* isn't a reliable freshness signal.
|
|
1401
|
+
*/
|
|
1402
|
+
private cacheTtlSeconds(): number {
|
|
1403
|
+
const raw = process.env.TINA4_TEMPLATE_CACHE_TTL;
|
|
1404
|
+
if (raw === undefined) return 0;
|
|
1405
|
+
const n = parseInt(raw, 10);
|
|
1406
|
+
return isNaN(n) || n < 0 ? 0 : n;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1389
1409
|
render(template: string, data?: Record<string, unknown>): string {
|
|
1390
1410
|
const context = { ...this.globals, ...(data || {}) };
|
|
1391
1411
|
const filePath = join(this.templateDir, template);
|
|
@@ -1395,12 +1415,17 @@ export class Frond {
|
|
|
1395
1415
|
}
|
|
1396
1416
|
|
|
1397
1417
|
const debugMode = (process.env.TINA4_DEBUG || "").toLowerCase() === "true";
|
|
1418
|
+
const ttlMs = this.cacheTtlSeconds() * 1000;
|
|
1398
1419
|
|
|
1399
1420
|
if (!debugMode) {
|
|
1400
1421
|
// Production: use permanent cache (no filesystem checks)
|
|
1401
1422
|
const cached = this.compiled.get(template);
|
|
1402
1423
|
if (cached) {
|
|
1403
|
-
|
|
1424
|
+
// TTL=0 means cache forever; any positive value invalidates the
|
|
1425
|
+
// compiled tokens after N seconds.
|
|
1426
|
+
if (ttlMs === 0 || (Date.now() - cached.cachedAt) < ttlMs) {
|
|
1427
|
+
return this.executeCached(cached.tokens, context);
|
|
1428
|
+
}
|
|
1404
1429
|
}
|
|
1405
1430
|
}
|
|
1406
1431
|
// Dev mode: skip cache entirely — always re-read and re-tokenize
|
|
@@ -1410,7 +1435,7 @@ export class Frond {
|
|
|
1410
1435
|
const source = readFileSync(filePath, "utf-8");
|
|
1411
1436
|
const mtime = statSync(filePath).mtimeMs;
|
|
1412
1437
|
const tokens = tokenize(source);
|
|
1413
|
-
this.compiled.set(template, { tokens, mtime });
|
|
1438
|
+
this.compiled.set(template, { tokens, mtime, cachedAt: Date.now() });
|
|
1414
1439
|
return this.executeWithSource(source, tokens, context);
|
|
1415
1440
|
}
|
|
1416
1441
|
|
|
@@ -1418,13 +1443,16 @@ export class Frond {
|
|
|
1418
1443
|
const context = { ...this.globals, ...(data || {}) };
|
|
1419
1444
|
|
|
1420
1445
|
const key = createHash("md5").update(source).digest("hex");
|
|
1421
|
-
const
|
|
1422
|
-
|
|
1423
|
-
|
|
1446
|
+
const ttlMs = this.cacheTtlSeconds() * 1000;
|
|
1447
|
+
const cached = this.compiledStrings.get(key);
|
|
1448
|
+
if (cached) {
|
|
1449
|
+
if (ttlMs === 0 || (Date.now() - cached.cachedAt) < ttlMs) {
|
|
1450
|
+
return this.executeCached(cached.tokens, context);
|
|
1451
|
+
}
|
|
1424
1452
|
}
|
|
1425
1453
|
|
|
1426
1454
|
const tokens = tokenize(source);
|
|
1427
|
-
this.compiledStrings.set(key, tokens);
|
|
1455
|
+
this.compiledStrings.set(key, { tokens, cachedAt: Date.now() });
|
|
1428
1456
|
return this.executeCached(tokens, context);
|
|
1429
1457
|
}
|
|
1430
1458
|
|
|
@@ -830,6 +830,21 @@ async function createAdapterFromUrl(url: string, username?: string, password?: s
|
|
|
830
830
|
* 2. process.env.TINA4_DATABASE_URL
|
|
831
831
|
* 3. config.type + config.path (legacy)
|
|
832
832
|
*/
|
|
833
|
+
/**
|
|
834
|
+
* Resolve the connection-pool size from `TINA4_DB_POOL`.
|
|
835
|
+
*
|
|
836
|
+
* Default: 0 (single-connection mode). Any positive integer enables
|
|
837
|
+
* round-robin pooling with that many connections — Database.create() honours
|
|
838
|
+
* this transparently. The env var is the simple deploy-time override; tests
|
|
839
|
+
* and library users can still pass `pool` directly to Database.create().
|
|
840
|
+
*/
|
|
841
|
+
export function resolveDbPool(): number {
|
|
842
|
+
const raw = process.env.TINA4_DB_POOL;
|
|
843
|
+
if (raw === undefined || raw.trim() === "") return 0;
|
|
844
|
+
const n = parseInt(raw, 10);
|
|
845
|
+
return isNaN(n) || n < 0 ? 0 : n;
|
|
846
|
+
}
|
|
847
|
+
|
|
833
848
|
export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
|
|
834
849
|
// Resolve credentials: config.user > config.username > env TINA4_DATABASE_USERNAME
|
|
835
850
|
const resolvedUser = config?.user ?? config?.username ?? process.env.TINA4_DATABASE_USERNAME;
|
|
@@ -839,6 +854,12 @@ export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
|
|
|
839
854
|
const url = config?.url ?? process.env.TINA4_DATABASE_URL;
|
|
840
855
|
|
|
841
856
|
if (url) {
|
|
857
|
+
const pool = resolveDbPool();
|
|
858
|
+
if (pool > 0) {
|
|
859
|
+
// Pool-aware path — delegate to Database.create which manages
|
|
860
|
+
// round-robin adapter rotation and async-local-storage transaction pinning.
|
|
861
|
+
return Database.create(url, resolvedUser, resolvedPassword, pool);
|
|
862
|
+
}
|
|
842
863
|
const adapter = await createAdapterFromUrl(url, resolvedUser, resolvedPassword);
|
|
843
864
|
setAdapter(adapter);
|
|
844
865
|
return new Database(adapter);
|
|
@@ -14,7 +14,7 @@ export { FetchResult } from "./types.js";
|
|
|
14
14
|
|
|
15
15
|
export { DatabaseResult } from "./databaseResult.js";
|
|
16
16
|
export type { ColumnInfoResult } from "./databaseResult.js";
|
|
17
|
-
export { Database, initDatabase, getAdapter, setAdapter, closeDatabase, parseDatabaseUrl, setNamedAdapter, getNamedAdapter } from "./database.js";
|
|
17
|
+
export { Database, initDatabase, getAdapter, setAdapter, closeDatabase, parseDatabaseUrl, setNamedAdapter, getNamedAdapter, resolveDbPool } from "./database.js";
|
|
18
18
|
export type { DatabaseConfig, ParsedDatabaseUrl } from "./database.js";
|
|
19
19
|
export { discoverModels } from "./model.js";
|
|
20
20
|
export type { DiscoveredModel } from "./model.js";
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import type { RouteDefinition } from "@tina4/core";
|
|
2
2
|
import type { ModelDefinition, FieldDefinition } from "@tina4/orm";
|
|
3
3
|
|
|
4
|
+
interface OpenAPISpecInfo {
|
|
5
|
+
title: string;
|
|
6
|
+
version: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
contact?: { name?: string; url?: string; email?: string };
|
|
9
|
+
license?: { name: string; url?: string };
|
|
10
|
+
}
|
|
11
|
+
|
|
4
12
|
interface OpenAPISpec {
|
|
5
13
|
openapi: string;
|
|
6
|
-
info:
|
|
14
|
+
info: OpenAPISpecInfo;
|
|
7
15
|
paths: Record<string, Record<string, unknown>>;
|
|
8
16
|
components?: { schemas?: Record<string, unknown> };
|
|
9
17
|
}
|
|
@@ -12,13 +20,30 @@ export function generate(
|
|
|
12
20
|
routes: RouteDefinition[],
|
|
13
21
|
models: ModelDefinition[] = []
|
|
14
22
|
): OpenAPISpec {
|
|
23
|
+
const info: OpenAPISpecInfo = {
|
|
24
|
+
title: process.env.TINA4_SWAGGER_TITLE ?? "Tina4 API",
|
|
25
|
+
version: process.env.TINA4_SWAGGER_VERSION ?? "0.0.1",
|
|
26
|
+
description: process.env.TINA4_SWAGGER_DESCRIPTION ?? "Auto-generated API documentation",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Optional contact email — surfaced in the OpenAPI `info.contact.email`
|
|
30
|
+
// field when set. Matches the python `SWAGGER_CONTACT_EMAIL` convention.
|
|
31
|
+
const contactEmail = (process.env.TINA4_SWAGGER_CONTACT_EMAIL ?? "").trim();
|
|
32
|
+
if (contactEmail.length > 0) {
|
|
33
|
+
info.contact = { email: contactEmail };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Optional license — accepts a plain SPDX identifier ("MIT", "Apache-2.0")
|
|
37
|
+
// or a "Name|URL" pair. Empty string disables license output entirely.
|
|
38
|
+
const licenseRaw = (process.env.TINA4_SWAGGER_LICENSE ?? "").trim();
|
|
39
|
+
if (licenseRaw.length > 0) {
|
|
40
|
+
const [name, url] = licenseRaw.split("|").map((s) => s.trim());
|
|
41
|
+
info.license = url ? { name, url } : { name };
|
|
42
|
+
}
|
|
43
|
+
|
|
15
44
|
const spec: OpenAPISpec = {
|
|
16
45
|
openapi: "3.0.3",
|
|
17
|
-
info
|
|
18
|
-
title: process.env.TINA4_SWAGGER_TITLE ?? "Tina4 API",
|
|
19
|
-
version: process.env.TINA4_SWAGGER_VERSION ?? "0.0.1",
|
|
20
|
-
description: process.env.TINA4_SWAGGER_DESCRIPTION ?? "Auto-generated API documentation",
|
|
21
|
-
},
|
|
46
|
+
info,
|
|
22
47
|
paths: {},
|
|
23
48
|
components: { schemas: {} },
|
|
24
49
|
};
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { generate } from "./generator.js";
|
|
2
|
-
export { createSwaggerRoutes } from "./ui.js";
|
|
2
|
+
export { createSwaggerRoutes, swaggerEnabled } from "./ui.js";
|
|
@@ -26,6 +26,23 @@ const SWAGGER_UI_HTML = (specUrl: string) => `<!DOCTYPE html>
|
|
|
26
26
|
</body>
|
|
27
27
|
</html>`;
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Whether the Swagger UI + spec routes should be registered at boot.
|
|
31
|
+
*
|
|
32
|
+
* Default: enabled when `TINA4_DEBUG=true`, disabled otherwise. Operators
|
|
33
|
+
* can force either state with `TINA4_SWAGGER_ENABLED=true|false`. Matches
|
|
34
|
+
* Python parity: dev-only by default to keep production attack surface
|
|
35
|
+
* minimal, but easy to expose intentionally for public APIs.
|
|
36
|
+
*/
|
|
37
|
+
export function swaggerEnabled(): boolean {
|
|
38
|
+
const raw = (process.env.TINA4_SWAGGER_ENABLED ?? "").trim().toLowerCase();
|
|
39
|
+
if (raw === "") {
|
|
40
|
+
const debug = (process.env.TINA4_DEBUG ?? "").trim().toLowerCase();
|
|
41
|
+
return ["true", "1", "yes", "on"].includes(debug);
|
|
42
|
+
}
|
|
43
|
+
return ["true", "1", "yes", "on"].includes(raw);
|
|
44
|
+
}
|
|
45
|
+
|
|
29
46
|
export function createSwaggerRoutes(
|
|
30
47
|
getSpec: () => Record<string, unknown>
|
|
31
48
|
): RouteDefinition[] {
|