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.
@@ -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.DATABASE_URL ?? "",
1210
- username: env.DATABASE_USERNAME ?? "",
1211
- password: env.DATABASE_PASSWORD ? "***" : "",
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> = { DATABASE_URL: false, DATABASE_USERNAME: false, DATABASE_PASSWORD: false };
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 === "DATABASE_URL") { newLines.push(`DATABASE_URL=${url}`); keysFound.DATABASE_URL = true; }
1287
- else if (key === "DATABASE_USERNAME") { newLines.push(`DATABASE_USERNAME=${username}`); keysFound.DATABASE_USERNAME = true; }
1288
- else if (key === "DATABASE_PASSWORD") { newLines.push(`DATABASE_PASSWORD=${password}`); keysFound.DATABASE_PASSWORD = true; }
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> = { DATABASE_URL: url, DATABASE_USERNAME: username, DATABASE_PASSWORD: password };
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.SMTP_FROM ?? "dev@localhost",
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 SMTP_HOST is configured
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.SMTP_HOST;
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.HOST_NAME;
303
+ const oldHost = process.env.TINA4_HOST_NAME;
304
304
 
305
- process.env.HOST_NAME = "localhost:7148";
305
+ process.env.TINA4_HOST_NAME = "localhost:7148";
306
306
  assert("isLocalhost — localhost", isLocalhost() === true);
307
307
 
308
- process.env.HOST_NAME = "127.0.0.1:7148";
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.HOST_NAME = "0.0.0.0:7148";
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.HOST_NAME = "myserver.example.com:7148";
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.HOST_NAME = oldHost;
319
+ process.env.TINA4_HOST_NAME = oldHost;
320
320
  } else {
321
- delete process.env.HOST_NAME;
321
+ delete process.env.TINA4_HOST_NAME;
322
322
  }
323
323
  }
324
324
 
@@ -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.HOST_NAME || "localhost:7148";
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_* > SMTP_* > sensible default
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 ?? process.env.SMTP_PORT ?? "587", 10);
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 ?? process.env.IMAP_PORT ?? "993", 10);
341
+ ?? parseInt(process.env.TINA4_MAIL_IMAP_PORT ?? "993", 10);
347
342
  this.imapUser = options?.imapUser
348
- ?? process.env.IMAP_USER
343
+ ?? process.env.TINA4_MAIL_IMAP_USERNAME
349
344
  ?? this.username;
350
345
  this.imapPass = options?.imapPass
351
- ?? process.env.IMAP_PASS
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
- * Resolve a URL path to a template file in src/templates/.
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
- const cleanPath = pathname.replace(/^\//, "") || "index";
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
- const candidate = cleanPath + ext;
249
- if (existsSync(resolve(templatesDir, candidate))) {
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
- if (existsSync(templatesDir)) {
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(templatesDir, "");
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 public dir)
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 or hello.html)
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
- const html = readFileSync(resolve(templatesDir, tplFile), "utf-8");
952
- res.raw.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
953
- res.raw.end(html);
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
- // Show landing page for "/" when no template exists
958
- if (pathname === "/") {
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 DATABASE_URL or use the default.
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.DATABASE_URL;
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.SECRET || "tina4-default-secret";
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 ORM_PLURAL_TABLE_NAMES is enabled in .env.
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.ORM_PLURAL_TABLE_NAMES ?? "";
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 DATABASE_URL auto-discovery
221
- const url = process.env.DATABASE_URL;
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
- /** Start a transaction. */
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
- this.getNextAdapter().startTransaction();
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().commit();
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().rollback();
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 DATABASE_URL env var.
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.DATABASE_URL
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 DATABASE_USERNAME
792
- const resolvedUser = config?.user ?? config?.username ?? process.env.DATABASE_USERNAME;
793
- const resolvedPassword = config?.password ?? process.env.DATABASE_PASSWORD;
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.DATABASE_URL;
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.SWAGGER_TITLE ?? "Tina4 API",
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: {} },