tina4-nodejs 3.11.19 → 3.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/cli/src/bin.ts +1 -1
- package/packages/core/public/js/frond.js +600 -0
- package/packages/core/public/js/frond.min.js +1 -1
- package/packages/core/src/auth.ts +8 -8
- package/packages/core/src/devAdmin.ts +8 -8
- package/packages/core/src/devMailbox.ts +3 -3
- package/packages/core/src/index.ts +1 -1
- package/packages/core/src/mcp.test.ts +7 -7
- package/packages/core/src/mcp.ts +1 -1
- package/packages/core/src/messenger.ts +6 -11
- package/packages/core/src/server.ts +193 -19
- package/packages/core/src/sessionHandlers/databaseHandler.ts +2 -2
- package/packages/frond/src/engine.ts +1 -1
- package/packages/orm/src/baseModel.ts +4 -4
- package/packages/orm/src/database.ts +55 -12
- package/packages/swagger/src/generator.ts +3 -3
|
@@ -1206,9 +1206,9 @@ function parseEnvFile(): Record<string, string> {
|
|
|
1206
1206
|
const handleConnections: RouteHandler = (_req, res) => {
|
|
1207
1207
|
const env = parseEnvFile();
|
|
1208
1208
|
res.json({
|
|
1209
|
-
url: env.
|
|
1210
|
-
username: env.
|
|
1211
|
-
password: env.
|
|
1209
|
+
url: env.TINA4_DATABASE_URL ?? "",
|
|
1210
|
+
username: env.TINA4_DATABASE_USERNAME ?? "",
|
|
1211
|
+
password: env.TINA4_DATABASE_PASSWORD ? "***" : "",
|
|
1212
1212
|
});
|
|
1213
1213
|
};
|
|
1214
1214
|
|
|
@@ -1274,7 +1274,7 @@ const handleConnectionsSave: RouteHandler = (req, res) => {
|
|
|
1274
1274
|
try {
|
|
1275
1275
|
const envPath = join(process.cwd(), ".env");
|
|
1276
1276
|
const lines = existsSync(envPath) ? readFileSync(envPath, "utf-8").split("\n") : [];
|
|
1277
|
-
const keysFound: Record<string, boolean> = {
|
|
1277
|
+
const keysFound: Record<string, boolean> = { TINA4_DATABASE_URL: false, TINA4_DATABASE_USERNAME: false, TINA4_DATABASE_PASSWORD: false };
|
|
1278
1278
|
const newLines: string[] = [];
|
|
1279
1279
|
for (const line of lines) {
|
|
1280
1280
|
const trimmed = line.trim();
|
|
@@ -1283,12 +1283,12 @@ const handleConnectionsSave: RouteHandler = (req, res) => {
|
|
|
1283
1283
|
continue;
|
|
1284
1284
|
}
|
|
1285
1285
|
const key = trimmed.split("=", 1)[0].trim();
|
|
1286
|
-
if (key === "
|
|
1287
|
-
else if (key === "
|
|
1288
|
-
else if (key === "
|
|
1286
|
+
if (key === "TINA4_DATABASE_URL") { newLines.push(`TINA4_DATABASE_URL=${url}`); keysFound.TINA4_DATABASE_URL = true; }
|
|
1287
|
+
else if (key === "TINA4_DATABASE_USERNAME") { newLines.push(`TINA4_DATABASE_USERNAME=${username}`); keysFound.TINA4_DATABASE_USERNAME = true; }
|
|
1288
|
+
else if (key === "TINA4_DATABASE_PASSWORD") { newLines.push(`TINA4_DATABASE_PASSWORD=${password}`); keysFound.TINA4_DATABASE_PASSWORD = true; }
|
|
1289
1289
|
else { newLines.push(line); }
|
|
1290
1290
|
}
|
|
1291
|
-
const values: Record<string, string> = {
|
|
1291
|
+
const values: Record<string, string> = { TINA4_DATABASE_URL: url, TINA4_DATABASE_USERNAME: username, TINA4_DATABASE_PASSWORD: password };
|
|
1292
1292
|
for (const [key, found] of Object.entries(keysFound)) {
|
|
1293
1293
|
if (!found) newLines.push(`${key}=${values[key]}`);
|
|
1294
1294
|
}
|
|
@@ -57,7 +57,7 @@ export class DevMailbox {
|
|
|
57
57
|
const message: EmailMessage = {
|
|
58
58
|
id,
|
|
59
59
|
type: "outbox",
|
|
60
|
-
from: from ?? process.env.
|
|
60
|
+
from: from ?? process.env.TINA4_MAIL_FROM ?? "dev@localhost",
|
|
61
61
|
to: toList,
|
|
62
62
|
cc,
|
|
63
63
|
bcc,
|
|
@@ -286,7 +286,7 @@ export class DevMailbox {
|
|
|
286
286
|
*
|
|
287
287
|
* Returns DevMailbox when:
|
|
288
288
|
* - TINA4_DEBUG is "true", OR
|
|
289
|
-
* - No
|
|
289
|
+
* - No TINA4_MAIL_HOST is configured
|
|
290
290
|
*
|
|
291
291
|
* Returns a real Messenger otherwise (SMTP configured + not debug mode).
|
|
292
292
|
*
|
|
@@ -294,7 +294,7 @@ export class DevMailbox {
|
|
|
294
294
|
*/
|
|
295
295
|
export function createMessenger(): Messenger | DevMailbox {
|
|
296
296
|
const debug = process.env.TINA4_DEBUG;
|
|
297
|
-
const smtpHost = process.env.
|
|
297
|
+
const smtpHost = process.env.TINA4_MAIL_HOST;
|
|
298
298
|
|
|
299
299
|
// Force dev mode when TINA4_DEBUG is truthy
|
|
300
300
|
if (isTruthy(debug)) {
|
|
@@ -12,7 +12,7 @@ export type {
|
|
|
12
12
|
WebSocketRouteDefinition,
|
|
13
13
|
} from "./types.js";
|
|
14
14
|
|
|
15
|
-
export { startServer, resolvePortAndHost, handle, start, stop } from "./server.js";
|
|
15
|
+
export { startServer, resolvePortAndHost, handle, start, stop, httpReason, resolveTemplate, resetTemplateCache, templateAutoRoutingEnabled } from "./server.js";
|
|
16
16
|
export { background, stopAllBackgroundTasks, backgroundTaskCount } from "./background.js";
|
|
17
17
|
export { Router, RouteGroup, RouteRef, defaultRouter, runRouteMiddlewares } from "./router.js";
|
|
18
18
|
export { get, post, put, patch, del, any, websocket, del as delete } from "./router.js";
|
|
@@ -300,25 +300,25 @@ console.log("\nResource Registration and Read");
|
|
|
300
300
|
console.log("\nLocalhost Detection");
|
|
301
301
|
|
|
302
302
|
{
|
|
303
|
-
const oldHost = process.env.
|
|
303
|
+
const oldHost = process.env.TINA4_HOST_NAME;
|
|
304
304
|
|
|
305
|
-
process.env.
|
|
305
|
+
process.env.TINA4_HOST_NAME = "localhost:7148";
|
|
306
306
|
assert("isLocalhost — localhost", isLocalhost() === true);
|
|
307
307
|
|
|
308
|
-
process.env.
|
|
308
|
+
process.env.TINA4_HOST_NAME = "127.0.0.1:7148";
|
|
309
309
|
assert("isLocalhost — 127.0.0.1", isLocalhost() === true);
|
|
310
310
|
|
|
311
|
-
process.env.
|
|
311
|
+
process.env.TINA4_HOST_NAME = "0.0.0.0:7148";
|
|
312
312
|
assert("isLocalhost — 0.0.0.0", isLocalhost() === true);
|
|
313
313
|
|
|
314
|
-
process.env.
|
|
314
|
+
process.env.TINA4_HOST_NAME = "myserver.example.com:7148";
|
|
315
315
|
assert("isLocalhost — remote false", isLocalhost() === false);
|
|
316
316
|
|
|
317
317
|
// Restore
|
|
318
318
|
if (oldHost !== undefined) {
|
|
319
|
-
process.env.
|
|
319
|
+
process.env.TINA4_HOST_NAME = oldHost;
|
|
320
320
|
} else {
|
|
321
|
-
delete process.env.
|
|
321
|
+
delete process.env.TINA4_HOST_NAME;
|
|
322
322
|
}
|
|
323
323
|
}
|
|
324
324
|
|
package/packages/core/src/mcp.ts
CHANGED
|
@@ -157,7 +157,7 @@ export function schemaFromParams(params: McpToolParam[]): JsonSchema {
|
|
|
157
157
|
// ── Localhost detection ──────────────────────────────────────
|
|
158
158
|
|
|
159
159
|
export function isLocalhost(): boolean {
|
|
160
|
-
const hostEnv = process.env.
|
|
160
|
+
const hostEnv = process.env.TINA4_HOST_NAME || "localhost:7148";
|
|
161
161
|
const host = hostEnv.split(":")[0];
|
|
162
162
|
return ["localhost", "127.0.0.1", "0.0.0.0", "::1", ""].includes(host);
|
|
163
163
|
}
|
|
@@ -302,28 +302,24 @@ export class Messenger {
|
|
|
302
302
|
private imapPass: string;
|
|
303
303
|
|
|
304
304
|
constructor(options?: MessengerOptions) {
|
|
305
|
-
// Priority: constructor > TINA4_MAIL_* >
|
|
305
|
+
// Priority: constructor > TINA4_MAIL_* > sensible default.
|
|
306
|
+
// Legacy SMTP_*/IMAP_* env vars were removed in v3.12 — boot guard rejects them.
|
|
306
307
|
this.host = options?.host
|
|
307
308
|
?? process.env.TINA4_MAIL_HOST
|
|
308
|
-
?? process.env.SMTP_HOST
|
|
309
309
|
?? "localhost";
|
|
310
310
|
this.port = options?.port
|
|
311
|
-
?? parseInt(process.env.TINA4_MAIL_PORT ??
|
|
311
|
+
?? parseInt(process.env.TINA4_MAIL_PORT ?? "587", 10);
|
|
312
312
|
this.username = options?.username
|
|
313
313
|
?? process.env.TINA4_MAIL_USERNAME
|
|
314
|
-
?? process.env.SMTP_USERNAME
|
|
315
314
|
?? "";
|
|
316
315
|
this.password = options?.password
|
|
317
316
|
?? process.env.TINA4_MAIL_PASSWORD
|
|
318
|
-
?? process.env.SMTP_PASSWORD
|
|
319
317
|
?? "";
|
|
320
318
|
this.fromAddress = options?.fromAddress
|
|
321
319
|
?? process.env.TINA4_MAIL_FROM
|
|
322
|
-
?? process.env.SMTP_FROM
|
|
323
320
|
?? (this.username || "noreply@localhost");
|
|
324
321
|
this.fromName = options?.fromName
|
|
325
322
|
?? process.env.TINA4_MAIL_FROM_NAME
|
|
326
|
-
?? process.env.SMTP_FROM_NAME
|
|
327
323
|
?? "";
|
|
328
324
|
|
|
329
325
|
// Encryption: constructor > .env > backward-compat useTls > default "tls"
|
|
@@ -340,15 +336,14 @@ export class Messenger {
|
|
|
340
336
|
|
|
341
337
|
this.imapHost = options?.imapHost
|
|
342
338
|
?? process.env.TINA4_MAIL_IMAP_HOST
|
|
343
|
-
?? process.env.IMAP_HOST
|
|
344
339
|
?? "";
|
|
345
340
|
this.imapPort = options?.imapPort
|
|
346
|
-
?? parseInt(process.env.TINA4_MAIL_IMAP_PORT ??
|
|
341
|
+
?? parseInt(process.env.TINA4_MAIL_IMAP_PORT ?? "993", 10);
|
|
347
342
|
this.imapUser = options?.imapUser
|
|
348
|
-
?? process.env.
|
|
343
|
+
?? process.env.TINA4_MAIL_IMAP_USERNAME
|
|
349
344
|
?? this.username;
|
|
350
345
|
this.imapPass = options?.imapPass
|
|
351
|
-
?? process.env.
|
|
346
|
+
?? process.env.TINA4_MAIL_IMAP_PASSWORD
|
|
352
347
|
?? this.password;
|
|
353
348
|
}
|
|
354
349
|
|
|
@@ -47,6 +47,83 @@ const TINA4_VERSION = readPackageVersion();
|
|
|
47
47
|
/** Cache Frond instances by template directory to avoid repeated instantiation. */
|
|
48
48
|
const frondCache = new Map<string, InstanceType<any>>();
|
|
49
49
|
|
|
50
|
+
// ─── Legacy env var guard (v3.12 hard rename) ────────────────────────────
|
|
51
|
+
// All framework env vars now require the TINA4_ prefix. If any of these
|
|
52
|
+
// pre-3.12 names are present in the environment we refuse to boot —
|
|
53
|
+
// silently ignoring them would cause auth/db/mail to fall back to
|
|
54
|
+
// defaults with no warning. Each maps to its new TINA4_-prefixed
|
|
55
|
+
// canonical name.
|
|
56
|
+
const _LEGACY_ENV_VARS: Record<string, string> = {
|
|
57
|
+
DATABASE_URL: "TINA4_DATABASE_URL",
|
|
58
|
+
DATABASE_USERNAME: "TINA4_DATABASE_USERNAME",
|
|
59
|
+
DATABASE_PASSWORD: "TINA4_DATABASE_PASSWORD",
|
|
60
|
+
DB_URL: "TINA4_DATABASE_URL",
|
|
61
|
+
SECRET: "TINA4_SECRET",
|
|
62
|
+
API_KEY: "TINA4_API_KEY",
|
|
63
|
+
JWT_ALGORITHM: "TINA4_JWT_ALGORITHM",
|
|
64
|
+
SMTP_HOST: "TINA4_MAIL_HOST",
|
|
65
|
+
SMTP_PORT: "TINA4_MAIL_PORT",
|
|
66
|
+
SMTP_USERNAME: "TINA4_MAIL_USERNAME",
|
|
67
|
+
SMTP_PASSWORD: "TINA4_MAIL_PASSWORD",
|
|
68
|
+
SMTP_FROM: "TINA4_MAIL_FROM",
|
|
69
|
+
SMTP_FROM_NAME: "TINA4_MAIL_FROM_NAME",
|
|
70
|
+
IMAP_HOST: "TINA4_MAIL_IMAP_HOST",
|
|
71
|
+
IMAP_PORT: "TINA4_MAIL_IMAP_PORT",
|
|
72
|
+
IMAP_USER: "TINA4_MAIL_IMAP_USERNAME",
|
|
73
|
+
IMAP_PASS: "TINA4_MAIL_IMAP_PASSWORD",
|
|
74
|
+
HOST_NAME: "TINA4_HOST_NAME",
|
|
75
|
+
SWAGGER_TITLE: "TINA4_SWAGGER_TITLE",
|
|
76
|
+
SWAGGER_DESCRIPTION: "TINA4_SWAGGER_DESCRIPTION",
|
|
77
|
+
SWAGGER_VERSION: "TINA4_SWAGGER_VERSION",
|
|
78
|
+
ORM_PLURAL_TABLE_NAMES: "TINA4_ORM_PLURAL_TABLE_NAMES",
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Refuse to boot if pre-3.12 un-prefixed env vars are still set.
|
|
83
|
+
*
|
|
84
|
+
* Tina4 v3.12 hard-renamed every framework-specific env var to use the
|
|
85
|
+
* `TINA4_` prefix. Booting silently with a legacy `DATABASE_URL` or
|
|
86
|
+
* `SECRET` would let auth, DB, or mail fall back to insecure defaults
|
|
87
|
+
* while the user thought their config was being read. Better to die
|
|
88
|
+
* loudly with a list of names to fix.
|
|
89
|
+
*
|
|
90
|
+
* Bypass with `TINA4_ALLOW_LEGACY_ENV=true` in CI / migration scripts
|
|
91
|
+
* that genuinely need both names set during a transition window.
|
|
92
|
+
*/
|
|
93
|
+
export function _checkLegacyEnvVars(): void {
|
|
94
|
+
if (isTruthy(process.env.TINA4_ALLOW_LEGACY_ENV)) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const found = Object.keys(_LEGACY_ENV_VARS)
|
|
98
|
+
.filter((name) => process.env[name] !== undefined)
|
|
99
|
+
.sort();
|
|
100
|
+
if (found.length === 0) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const bar = "─".repeat(72);
|
|
104
|
+
const lines: string[] = [
|
|
105
|
+
"",
|
|
106
|
+
bar,
|
|
107
|
+
"Tina4 v3.12 requires TINA4_ prefix on all framework env vars.",
|
|
108
|
+
"Your environment still has these legacy names:",
|
|
109
|
+
"",
|
|
110
|
+
];
|
|
111
|
+
for (const old of found) {
|
|
112
|
+
const next = _LEGACY_ENV_VARS[old];
|
|
113
|
+
lines.push(` ${old.padEnd(28)} → ${next}`);
|
|
114
|
+
}
|
|
115
|
+
lines.push(
|
|
116
|
+
"",
|
|
117
|
+
"Run `tina4 env-migrate` to rewrite your .env automatically,",
|
|
118
|
+
"or rename manually. See https://tina4.com/release/3.12.0",
|
|
119
|
+
"Set TINA4_ALLOW_LEGACY_ENV=true to bypass during migration.",
|
|
120
|
+
bar,
|
|
121
|
+
"",
|
|
122
|
+
);
|
|
123
|
+
process.stderr.write(lines.join("\n") + "\n");
|
|
124
|
+
process.exit(2);
|
|
125
|
+
}
|
|
126
|
+
|
|
50
127
|
/**
|
|
51
128
|
* Kill whatever process is listening on *port*.
|
|
52
129
|
* Uses lsof on macOS/Linux and netstat + taskkill on Windows.
|
|
@@ -231,23 +308,96 @@ function getGalleryDeployedState(): Record<string, boolean> {
|
|
|
231
308
|
return state;
|
|
232
309
|
}
|
|
233
310
|
|
|
311
|
+
/**
|
|
312
|
+
* Auto-routing scans this single subdirectory of src/templates/. Only files
|
|
313
|
+
* in src/templates/pages/ become URLs — everything else (partials, layouts,
|
|
314
|
+
* base.twig, errors, components, macros) is never URL-exposed and remains
|
|
315
|
+
* renderable only via {% include %} / {% extends %} / res.render().
|
|
316
|
+
*
|
|
317
|
+
* Convention adapted from Next.js' pages/ directory and Nuxt's pages/ folder.
|
|
318
|
+
* Explicit, secure by default, no skip lists to maintain.
|
|
319
|
+
*/
|
|
320
|
+
const TEMPLATE_PAGES_DIR = "pages";
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Honour TINA4_TEMPLATE_ROUTING=off|false|0|no|disabled as an explicit kill
|
|
324
|
+
* switch. Default: enabled. Drop a file in src/templates/pages/ and it serves
|
|
325
|
+
* at the matching URL — the zero-config Tina4 convention. Operators who want
|
|
326
|
+
* explicit-only routing can set TINA4_TEMPLATE_ROUTING=off and every URL
|
|
327
|
+
* must be registered via get() / post() (or be a static file).
|
|
328
|
+
*/
|
|
329
|
+
export function templateAutoRoutingEnabled(): boolean {
|
|
330
|
+
const val = (process.env.TINA4_TEMPLATE_ROUTING ?? "on").trim().toLowerCase();
|
|
331
|
+
return !["off", "false", "0", "no", "disabled"].includes(val);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* RFC 7231 / RFC 9110 status reason phrases. Used to write a correct HTTP
|
|
336
|
+
* status line — previously some paths wrote "HTTP/1.1 404 OK" because the
|
|
337
|
+
* canonical phrase wasn't being looked up per code.
|
|
338
|
+
*/
|
|
339
|
+
const HTTP_REASON_PHRASES: Record<number, string> = {
|
|
340
|
+
100: "Continue", 101: "Switching Protocols",
|
|
341
|
+
200: "OK", 201: "Created", 202: "Accepted", 204: "No Content",
|
|
342
|
+
206: "Partial Content",
|
|
343
|
+
301: "Moved Permanently", 302: "Found", 303: "See Other",
|
|
344
|
+
304: "Not Modified", 307: "Temporary Redirect", 308: "Permanent Redirect",
|
|
345
|
+
400: "Bad Request", 401: "Unauthorized", 403: "Forbidden",
|
|
346
|
+
404: "Not Found", 405: "Method Not Allowed", 406: "Not Acceptable",
|
|
347
|
+
409: "Conflict", 410: "Gone", 413: "Content Too Large",
|
|
348
|
+
415: "Unsupported Media Type", 422: "Unprocessable Content",
|
|
349
|
+
429: "Too Many Requests",
|
|
350
|
+
500: "Internal Server Error", 501: "Not Implemented",
|
|
351
|
+
502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout",
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Return the canonical HTTP reason phrase for `status`. Falls back to a
|
|
356
|
+
* sensible label when an exotic status is used. Never returns an empty string.
|
|
357
|
+
*/
|
|
358
|
+
export function httpReason(status: number): string {
|
|
359
|
+
const phrase = HTTP_REASON_PHRASES[status];
|
|
360
|
+
if (phrase) return phrase;
|
|
361
|
+
return status >= 200 && status < 300 ? "OK" : "Error";
|
|
362
|
+
}
|
|
363
|
+
|
|
234
364
|
/** Template cache: url_path -> template_file. Null until first production lookup. */
|
|
235
365
|
let templateCache: Map<string, string> | null = null;
|
|
236
366
|
|
|
237
367
|
/**
|
|
238
|
-
*
|
|
368
|
+
* Reset the production template cache. Tests use this between scenarios so
|
|
369
|
+
* a fresh scan picks up fixture files in a tmp project.
|
|
370
|
+
*/
|
|
371
|
+
export function resetTemplateCache(): void {
|
|
372
|
+
templateCache = null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Resolve a URL path to a template file in src/templates/pages/.
|
|
377
|
+
*
|
|
378
|
+
* Only files inside `src/templates/pages/` auto-route from a URL. Anything
|
|
379
|
+
* in `src/templates/` outside `pages/` (partials, layouts, base.twig,
|
|
380
|
+
* errors, components) is never served standalone.
|
|
381
|
+
*
|
|
239
382
|
* Dev mode: checks filesystem every time for live changes.
|
|
240
383
|
* Production: uses a cached lookup built once at startup.
|
|
384
|
+
*
|
|
385
|
+
* The whole feature can be turned off with `TINA4_TEMPLATE_ROUTING=off`.
|
|
241
386
|
*/
|
|
242
|
-
function resolveTemplate(pathname: string, templatesDir: string): string | null {
|
|
243
|
-
|
|
387
|
+
export function resolveTemplate(pathname: string, templatesDir: string): string | null {
|
|
388
|
+
if (!templateAutoRoutingEnabled()) return null;
|
|
389
|
+
|
|
390
|
+
const cleanPath = pathname.replace(/^\/+/, "").replace(/\/+$/, "") || "index";
|
|
244
391
|
const isDev = (process.env.TINA4_DEBUG ?? "false").toLowerCase() === "true";
|
|
245
392
|
|
|
246
393
|
if (isDev) {
|
|
394
|
+
// Skip underscore-prefixed files even within pages/ — they're private
|
|
395
|
+
// by Hugo/Jekyll convention (helpers, fragments) and shouldn't auto-serve.
|
|
396
|
+
if (cleanPath.split("/").some((seg) => seg.startsWith("_"))) return null;
|
|
397
|
+
const pagesDir = resolve(templatesDir, TEMPLATE_PAGES_DIR);
|
|
247
398
|
for (const ext of [".twig", ".html"]) {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
return candidate;
|
|
399
|
+
if (existsSync(resolve(pagesDir, cleanPath + ext))) {
|
|
400
|
+
return `${TEMPLATE_PAGES_DIR}/${cleanPath}${ext}`;
|
|
251
401
|
}
|
|
252
402
|
}
|
|
253
403
|
return null;
|
|
@@ -256,21 +406,24 @@ function resolveTemplate(pathname: string, templatesDir: string): string | null
|
|
|
256
406
|
// Production: cached lookup
|
|
257
407
|
if (!templateCache) {
|
|
258
408
|
templateCache = new Map();
|
|
259
|
-
|
|
409
|
+
const pagesDir = resolve(templatesDir, TEMPLATE_PAGES_DIR);
|
|
410
|
+
if (existsSync(pagesDir)) {
|
|
260
411
|
const scan = (dir: string, prefix: string) => {
|
|
261
412
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
413
|
+
// Skip private files even within pages/ (e.g. pages/_helper.twig)
|
|
414
|
+
if (entry.name.startsWith("_")) continue;
|
|
262
415
|
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
263
416
|
if (entry.isDirectory()) {
|
|
264
417
|
scan(resolve(dir, entry.name), rel);
|
|
265
418
|
} else if (entry.name.endsWith(".twig") || entry.name.endsWith(".html")) {
|
|
266
419
|
const urlPath = rel.replace(/\.(twig|html)$/, "");
|
|
267
420
|
if (!templateCache!.has(urlPath)) {
|
|
268
|
-
templateCache!.set(urlPath, rel);
|
|
421
|
+
templateCache!.set(urlPath, `${TEMPLATE_PAGES_DIR}/${rel}`);
|
|
269
422
|
}
|
|
270
423
|
}
|
|
271
424
|
}
|
|
272
425
|
};
|
|
273
|
-
scan(
|
|
426
|
+
scan(pagesDir, "");
|
|
274
427
|
}
|
|
275
428
|
}
|
|
276
429
|
return templateCache.get(cleanPath) ?? null;
|
|
@@ -502,6 +655,9 @@ export async function startServer(config?: Tina4Config): Promise<{
|
|
|
502
655
|
// Load .env early so TINA4_DEBUG is available for cluster decision
|
|
503
656
|
loadEnv();
|
|
504
657
|
|
|
658
|
+
// Refuse to boot with pre-3.12 un-prefixed env vars set.
|
|
659
|
+
_checkLegacyEnvVars();
|
|
660
|
+
|
|
505
661
|
const resolved = resolvePortAndHost(config);
|
|
506
662
|
const host = resolved.host;
|
|
507
663
|
let port = resolved.port;
|
|
@@ -566,6 +722,9 @@ ${reset}
|
|
|
566
722
|
const modelsDir = resolve(base, config?.modelsDir ?? "src/models");
|
|
567
723
|
const ormDir = resolve(base, "src/orm");
|
|
568
724
|
const staticDir = resolve(base, config?.staticDir ?? "public");
|
|
725
|
+
// src/public is the second-tier static dir (Python parity). When the user
|
|
726
|
+
// ships a Vite/SPA build there, src/public/index.html auto-serves at "/".
|
|
727
|
+
const srcPublicDir = resolve(base, "src/public");
|
|
569
728
|
const templatesDir = resolve(base, config?.templatesDir ?? "src/templates");
|
|
570
729
|
|
|
571
730
|
// .env already loaded above for cluster decision
|
|
@@ -834,10 +993,14 @@ ${reset}
|
|
|
834
993
|
res.raw.end = wrappedEnd;
|
|
835
994
|
}
|
|
836
995
|
|
|
837
|
-
// Try static files first (project public dir, then framework built-in
|
|
996
|
+
// Try static files first (project public dir, src/public dir, then framework built-in)
|
|
997
|
+
// Index resolution: "/" or "/foo/" picks up index.html so SPA builds Just Work.
|
|
838
998
|
if (existsSync(staticDir) && tryServeStatic(staticDir, req, res)) {
|
|
839
999
|
return;
|
|
840
1000
|
}
|
|
1001
|
+
if (existsSync(srcPublicDir) && tryServeStatic(srcPublicDir, req, res)) {
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
841
1004
|
if (tryServeStatic(BUILTIN_PUBLIC_DIR, req, res)) {
|
|
842
1005
|
return;
|
|
843
1006
|
}
|
|
@@ -944,34 +1107,45 @@ ${reset}
|
|
|
944
1107
|
return;
|
|
945
1108
|
}
|
|
946
1109
|
|
|
947
|
-
// Try serving a template file (e.g. /hello -> src/templates/hello.twig
|
|
1110
|
+
// Try serving a template file (e.g. /hello -> src/templates/pages/hello.twig)
|
|
948
1111
|
if ((req.method ?? "GET") === "GET") {
|
|
949
1112
|
const tplFile = resolveTemplate(pathname, templatesDir);
|
|
950
1113
|
if (tplFile) {
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1114
|
+
// Render through Frond so {% include %} / {% extends %} work,
|
|
1115
|
+
// not raw readFileSync.
|
|
1116
|
+
if (frondEngine) {
|
|
1117
|
+
const html = frondEngine.render(tplFile, {});
|
|
1118
|
+
res.raw.writeHead(200, undefined, { "Content-Type": "text/html; charset=utf-8" });
|
|
1119
|
+
res.raw.end(html);
|
|
1120
|
+
} else {
|
|
1121
|
+
const html = readFileSync(resolve(templatesDir, tplFile), "utf-8");
|
|
1122
|
+
res.raw.writeHead(200, undefined, { "Content-Type": "text/html; charset=utf-8" });
|
|
1123
|
+
res.raw.end(html);
|
|
1124
|
+
}
|
|
954
1125
|
return;
|
|
955
1126
|
}
|
|
956
1127
|
|
|
957
|
-
//
|
|
958
|
-
|
|
1128
|
+
// Landing page renders only at "/" AND only when TINA4_DEBUG=true.
|
|
1129
|
+
// In production "/" with no static index.html and no pages/index.twig
|
|
1130
|
+
// falls through to a clean 404 — the framework's branded welcome,
|
|
1131
|
+
// gallery and version never leak to real users.
|
|
1132
|
+
if (pathname === "/" && isDevMode()) {
|
|
959
1133
|
const allRoutes = router.getRoutes().map((r) => ({
|
|
960
1134
|
method: r.method,
|
|
961
1135
|
pattern: r.pattern,
|
|
962
1136
|
flags: [] as string[],
|
|
963
1137
|
}));
|
|
964
1138
|
const html = renderLandingPage(allRoutes, port);
|
|
965
|
-
res.raw.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
1139
|
+
res.raw.writeHead(200, undefined, { "Content-Type": "text/html; charset=utf-8" });
|
|
966
1140
|
res.raw.end(html);
|
|
967
1141
|
return;
|
|
968
1142
|
}
|
|
969
1143
|
}
|
|
970
1144
|
|
|
971
|
-
// 404
|
|
1145
|
+
// 404 — pass canonical reason phrase so the status line is well-formed
|
|
972
1146
|
const html404 = await renderErrorPage(404, { path: pathname }, templatesDir);
|
|
973
1147
|
if (html404) {
|
|
974
|
-
res.raw.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
|
|
1148
|
+
res.raw.writeHead(404, httpReason(404), { "Content-Type": "text/html; charset=utf-8" });
|
|
975
1149
|
res.raw.end(html404);
|
|
976
1150
|
} else {
|
|
977
1151
|
res({ error: "Not Found", statusCode: 404, message: `No route found for ${req.method} ${pathname}` }, 404);
|
|
@@ -42,10 +42,10 @@ export class DatabaseSessionHandler implements SessionHandler {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
|
-
* Resolve the database file path from
|
|
45
|
+
* Resolve the database file path from TINA4_DATABASE_URL or use the default.
|
|
46
46
|
*/
|
|
47
47
|
private resolveDbPath(): string {
|
|
48
|
-
const url = process.env.
|
|
48
|
+
const url = process.env.TINA4_DATABASE_URL;
|
|
49
49
|
if (url && url.startsWith("sqlite://")) {
|
|
50
50
|
// sqlite:///path/to/db or sqlite://./relative/path
|
|
51
51
|
return url.replace(/^sqlite:\/\//, "");
|
|
@@ -1271,7 +1271,7 @@ export function setFormTokenSessionId(sessionId: string): void {
|
|
|
1271
1271
|
}
|
|
1272
1272
|
|
|
1273
1273
|
function _buildFormTokenJwt(descriptor: string = ""): string {
|
|
1274
|
-
const secret = process.env.
|
|
1274
|
+
const secret = process.env.TINA4_SECRET || "tina4-default-secret";
|
|
1275
1275
|
const ttlMinutes = parseInt(process.env.TINA4_TOKEN_LIMIT || "60", 10);
|
|
1276
1276
|
|
|
1277
1277
|
const header = { alg: "HS256", typ: "JWT" };
|
|
@@ -21,11 +21,11 @@ export function camelToSnake(name: string): string {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* Check whether
|
|
24
|
+
* Check whether TINA4_ORM_PLURAL_TABLE_NAMES is enabled in .env.
|
|
25
25
|
* When true, hasMany relationship keys get an "s" suffix (e.g. "posts" instead of "post").
|
|
26
26
|
*/
|
|
27
27
|
function _pluralRelKeys(): boolean {
|
|
28
|
-
const v = process.env.
|
|
28
|
+
const v = process.env.TINA4_ORM_PLURAL_TABLE_NAMES ?? "";
|
|
29
29
|
return /^(true|1|yes)$/i.test(v);
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -217,8 +217,8 @@ export class BaseModel {
|
|
|
217
217
|
try {
|
|
218
218
|
return getAdapter();
|
|
219
219
|
} catch {
|
|
220
|
-
// No adapter registered — try
|
|
221
|
-
const url = process.env.
|
|
220
|
+
// No adapter registered — try TINA4_DATABASE_URL auto-discovery
|
|
221
|
+
const url = process.env.TINA4_DATABASE_URL;
|
|
222
222
|
if (url) {
|
|
223
223
|
const parsed = parseDatabaseUrl(url);
|
|
224
224
|
if (parsed.type === "sqlite") {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
import type { DatabaseAdapter, DatabaseResult as DatabaseWriteResult } from "./types.js";
|
|
2
3
|
import { DatabaseResult } from "./databaseResult.js";
|
|
3
4
|
|
|
@@ -256,6 +257,23 @@ export class Database {
|
|
|
256
257
|
/** Database engine type (sqlite, postgres, mysql, mssql, firebird) */
|
|
257
258
|
private dbType: string = "sqlite";
|
|
258
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Async-local storage for the adapter pinned to the current transaction.
|
|
262
|
+
*
|
|
263
|
+
* With pooling enabled, ordinary calls round-robin through the pool. Inside
|
|
264
|
+
* a transaction, however, all calls must land on the SAME adapter — otherwise
|
|
265
|
+
* startTransaction(), execute() and commit() each rotate to a different
|
|
266
|
+
* connection and the transaction is meaningless (executes autocommit on
|
|
267
|
+
* whatever adapter they hit; the final commit lands on yet another adapter
|
|
268
|
+
* that has nothing to commit; rollback() is silently no-op'd).
|
|
269
|
+
*
|
|
270
|
+
* AsyncLocalStorage is the Node analog of Python's threading.local. It pins
|
|
271
|
+
* the adapter to the current async task tree so concurrent transactions on
|
|
272
|
+
* the same Database don't clobber each other. startTransaction() sets the
|
|
273
|
+
* pin via .enterWith(); commit()/rollback() clear it.
|
|
274
|
+
*/
|
|
275
|
+
private txStore: AsyncLocalStorage<{ adapter: DatabaseAdapter | null }> = new AsyncLocalStorage();
|
|
276
|
+
|
|
259
277
|
/**
|
|
260
278
|
* Create a Database wrapping an existing adapter.
|
|
261
279
|
* For creating a Database from a URL, use the async static factories:
|
|
@@ -320,8 +338,15 @@ export class Database {
|
|
|
320
338
|
|
|
321
339
|
/**
|
|
322
340
|
* Get the next adapter — from pool (round-robin) or single connection.
|
|
341
|
+
*
|
|
342
|
+
* If a transaction is active (an adapter is pinned in async-local storage),
|
|
343
|
+
* that adapter is returned for every call so the whole transaction is
|
|
344
|
+
* atomic on one connection. Otherwise pooled mode round-robins.
|
|
323
345
|
*/
|
|
324
346
|
private getNextAdapter(): DatabaseAdapter {
|
|
347
|
+
const pinned = this.txStore.getStore()?.adapter;
|
|
348
|
+
if (pinned) return pinned;
|
|
349
|
+
|
|
325
350
|
if (this._poolSize > 0) {
|
|
326
351
|
const idx = this.poolIndex;
|
|
327
352
|
this.poolIndex = (this.poolIndex + 1) % this._poolSize;
|
|
@@ -457,19 +482,37 @@ export class Database {
|
|
|
457
482
|
}
|
|
458
483
|
}
|
|
459
484
|
|
|
460
|
-
/**
|
|
485
|
+
/**
|
|
486
|
+
* Start a transaction. Pins the adapter to the current async context for
|
|
487
|
+
* the whole transaction so executes and the final commit/rollback all run
|
|
488
|
+
* on the same connection (critical when pool > 0).
|
|
489
|
+
*/
|
|
461
490
|
startTransaction(): void {
|
|
462
|
-
|
|
491
|
+
// Pick an adapter using the normal selection logic, then pin it.
|
|
492
|
+
const adapter = this.getNextAdapter();
|
|
493
|
+
let store = this.txStore.getStore();
|
|
494
|
+
if (store) {
|
|
495
|
+
store.adapter = adapter;
|
|
496
|
+
} else {
|
|
497
|
+
this.txStore.enterWith({ adapter });
|
|
498
|
+
}
|
|
499
|
+
adapter.startTransaction();
|
|
463
500
|
}
|
|
464
501
|
|
|
465
|
-
/** Commit the current transaction. */
|
|
502
|
+
/** Commit the current transaction and release the adapter pin. */
|
|
466
503
|
commit(): void {
|
|
467
|
-
this.getNextAdapter()
|
|
504
|
+
const adapter = this.getNextAdapter();
|
|
505
|
+
adapter.commit();
|
|
506
|
+
const store = this.txStore.getStore();
|
|
507
|
+
if (store) store.adapter = null;
|
|
468
508
|
}
|
|
469
509
|
|
|
470
|
-
/** Rollback the current transaction. */
|
|
510
|
+
/** Rollback the current transaction and release the adapter pin. */
|
|
471
511
|
rollback(): void {
|
|
472
|
-
this.getNextAdapter()
|
|
512
|
+
const adapter = this.getNextAdapter();
|
|
513
|
+
adapter.rollback();
|
|
514
|
+
const store = this.txStore.getStore();
|
|
515
|
+
if (store) store.adapter = null;
|
|
473
516
|
}
|
|
474
517
|
|
|
475
518
|
/** Check if a table exists. */
|
|
@@ -779,21 +822,21 @@ async function createAdapterFromUrl(url: string, username?: string, password?: s
|
|
|
779
822
|
}
|
|
780
823
|
|
|
781
824
|
/**
|
|
782
|
-
* Initialize the database from a config object or
|
|
825
|
+
* Initialize the database from a config object or TINA4_DATABASE_URL env var.
|
|
783
826
|
* Now returns a Database wrapper instance.
|
|
784
827
|
*
|
|
785
828
|
* Priority:
|
|
786
829
|
* 1. config.url (explicit URL)
|
|
787
|
-
* 2. process.env.
|
|
830
|
+
* 2. process.env.TINA4_DATABASE_URL
|
|
788
831
|
* 3. config.type + config.path (legacy)
|
|
789
832
|
*/
|
|
790
833
|
export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
|
|
791
|
-
// Resolve credentials: config.user > config.username > env
|
|
792
|
-
const resolvedUser = config?.user ?? config?.username ?? process.env.
|
|
793
|
-
const resolvedPassword = config?.password ?? process.env.
|
|
834
|
+
// Resolve credentials: config.user > config.username > env TINA4_DATABASE_USERNAME
|
|
835
|
+
const resolvedUser = config?.user ?? config?.username ?? process.env.TINA4_DATABASE_USERNAME;
|
|
836
|
+
const resolvedPassword = config?.password ?? process.env.TINA4_DATABASE_PASSWORD;
|
|
794
837
|
|
|
795
838
|
// Resolve from URL if provided
|
|
796
|
-
const url = config?.url ?? process.env.
|
|
839
|
+
const url = config?.url ?? process.env.TINA4_DATABASE_URL;
|
|
797
840
|
|
|
798
841
|
if (url) {
|
|
799
842
|
const adapter = await createAdapterFromUrl(url, resolvedUser, resolvedPassword);
|
|
@@ -15,9 +15,9 @@ export function generate(
|
|
|
15
15
|
const spec: OpenAPISpec = {
|
|
16
16
|
openapi: "3.0.3",
|
|
17
17
|
info: {
|
|
18
|
-
title: process.env.
|
|
19
|
-
version: "0.0.1",
|
|
20
|
-
description: "Auto-generated API documentation",
|
|
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
21
|
},
|
|
22
22
|
paths: {},
|
|
23
23
|
components: { schemas: {} },
|