tina4-nodejs 3.11.32 → 3.12.1

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 CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
 
5
5
 
6
- "version": "3.11.32",
6
+ "version": "3.12.1",
7
7
 
8
8
  "type": "module",
9
9
  "description": "Tina4 for Node.js/TypeScript — 54 built-in features, zero dependencies",
@@ -138,7 +138,7 @@ async function main(): Promise<void> {
138
138
 
139
139
  loadEnv();
140
140
 
141
- const dbUrl = process.env.DATABASE_URL;
141
+ const dbUrl = process.env.TINA4_DATABASE_URL;
142
142
  let db: unknown = null;
143
143
  if (dbUrl) {
144
144
  try {
@@ -0,0 +1,600 @@
1
+ var _frondModule = (() => {
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/js/frond.ts
21
+ var frond_exports = {};
22
+ __export(frond_exports, {
23
+ frond: () => frond
24
+ });
25
+ var _token = null;
26
+ function request(url, options) {
27
+ let opts;
28
+ if (typeof options === "function") {
29
+ opts = { onSuccess: options };
30
+ } else {
31
+ opts = options || {};
32
+ }
33
+ const method = (opts.method || "GET").toUpperCase();
34
+ const xhr = new XMLHttpRequest();
35
+ xhr.open(method, url, true);
36
+ if (_token !== null) {
37
+ xhr.setRequestHeader("Authorization", "Bearer " + _token);
38
+ }
39
+ if (opts.headers) {
40
+ for (const key in opts.headers) {
41
+ if (Object.prototype.hasOwnProperty.call(opts.headers, key)) {
42
+ xhr.setRequestHeader(key, opts.headers[key]);
43
+ }
44
+ }
45
+ }
46
+ let body = null;
47
+ if (opts.body !== void 0 && opts.body !== null) {
48
+ if (opts.body instanceof FormData) {
49
+ body = opts.body;
50
+ } else if (typeof opts.body === "object") {
51
+ body = JSON.stringify(opts.body);
52
+ xhr.setRequestHeader("Content-Type", "application/json; charset=UTF-8");
53
+ } else if (typeof opts.body === "string") {
54
+ body = opts.body;
55
+ xhr.setRequestHeader("Content-Type", "text/plain; charset=UTF-8");
56
+ }
57
+ }
58
+ xhr.onload = function() {
59
+ const freshToken = xhr.getResponseHeader("FreshToken");
60
+ if (freshToken && freshToken !== "") {
61
+ _token = freshToken;
62
+ }
63
+ let content = xhr.response;
64
+ try {
65
+ content = JSON.parse(content);
66
+ } catch {
67
+ }
68
+ if (xhr.responseURL) {
69
+ const requested = new URL(url, window.location.href).href;
70
+ if (xhr.responseURL !== requested) {
71
+ window.location.href = xhr.responseURL;
72
+ return;
73
+ }
74
+ }
75
+ if (xhr.status >= 200 && xhr.status < 400) {
76
+ if (opts.onSuccess) opts.onSuccess(content, xhr.status, xhr);
77
+ } else {
78
+ if (opts.onError) opts.onError(xhr.status, xhr);
79
+ }
80
+ };
81
+ xhr.onerror = function() {
82
+ if (opts.onError) opts.onError(xhr.status, xhr);
83
+ };
84
+ xhr.send(body);
85
+ }
86
+ function inject(html, target) {
87
+ if (!html) return "";
88
+ const parser = new DOMParser();
89
+ const wrapped = html.includes("<html>") ? html : "<body>" + html + "</body></html>";
90
+ const doc = parser.parseFromString(wrapped, "text/html");
91
+ const body = doc.querySelector("body");
92
+ const scripts = body.querySelectorAll("script");
93
+ scripts.forEach(function(s) {
94
+ s.remove();
95
+ });
96
+ if (target !== null) {
97
+ const el = document.getElementById(target);
98
+ if (!el) return "";
99
+ if (body.children.length > 0) {
100
+ el.replaceChildren.apply(el, Array.from(body.children));
101
+ } else {
102
+ el.innerHTML = body.innerHTML;
103
+ }
104
+ scripts.forEach(function(script) {
105
+ const ns = document.createElement("script");
106
+ ns.type = "text/javascript";
107
+ ns.async = true;
108
+ if (script.src) {
109
+ ns.src = script.src;
110
+ } else {
111
+ ns.textContent = script.textContent;
112
+ }
113
+ el.appendChild(ns);
114
+ });
115
+ return "";
116
+ }
117
+ scripts.forEach(function(script) {
118
+ const ns = document.createElement("script");
119
+ ns.type = "text/javascript";
120
+ ns.async = true;
121
+ ns.textContent = script.textContent;
122
+ document.body.appendChild(ns);
123
+ });
124
+ return body.innerHTML;
125
+ }
126
+ function load(url, target, callback) {
127
+ const targetId = target || "content";
128
+ request(url, {
129
+ method: "GET",
130
+ onSuccess: function(data, _status) {
131
+ if (document.getElementById(targetId)) {
132
+ const html = inject(data, targetId);
133
+ if (callback) callback(html, data);
134
+ } else {
135
+ if (callback) callback(data);
136
+ }
137
+ }
138
+ });
139
+ }
140
+ function post(url, data, target, callback) {
141
+ const targetId = target || "content";
142
+ request(url, {
143
+ method: "POST",
144
+ body: data,
145
+ onSuccess: function(responseData) {
146
+ let html = "";
147
+ if (responseData && responseData.message !== void 0) {
148
+ html = inject(responseData.message, targetId);
149
+ } else if (document.getElementById(targetId)) {
150
+ html = inject(responseData, targetId);
151
+ } else {
152
+ if (callback) callback(responseData);
153
+ return;
154
+ }
155
+ if (callback) callback(html, responseData);
156
+ }
157
+ });
158
+ }
159
+ var form = {
160
+ /**
161
+ * Collect all form field values into a FormData object.
162
+ *
163
+ * Handles inputs, selects, textareas, file uploads (including
164
+ * multi-file), checkboxes, and radio buttons. Updates formToken
165
+ * hidden fields automatically.
166
+ *
167
+ * @param formId - DOM id of the form (without '#').
168
+ * @returns Populated FormData instance.
169
+ */
170
+ collect: function(formId) {
171
+ const fd = new FormData();
172
+ const elements = document.querySelectorAll("#" + formId + " select, #" + formId + " input, #" + formId + " textarea");
173
+ for (let i = 0; i < elements.length; i++) {
174
+ const el = elements[i];
175
+ if (el.name === "formToken" && _token !== null) {
176
+ el.value = _token;
177
+ }
178
+ if (!el.name) continue;
179
+ if (el.type === "file") {
180
+ const files = el.files;
181
+ if (files) {
182
+ for (let f = 0; f < files.length; f++) {
183
+ const file = files[f];
184
+ if (file !== void 0) {
185
+ let name = el.name;
186
+ if (files.length > 1 && !name.includes("[")) {
187
+ name = name + "[]";
188
+ }
189
+ fd.append(name, file, file.name);
190
+ }
191
+ }
192
+ }
193
+ } else if (el.type === "checkbox" || el.type === "radio") {
194
+ if (el.checked) {
195
+ fd.append(el.name, el.value);
196
+ } else if (el.type !== "radio") {
197
+ fd.append(el.name, "0");
198
+ }
199
+ } else {
200
+ fd.append(el.name, el.value === "" ? "" : el.value);
201
+ }
202
+ }
203
+ return fd;
204
+ },
205
+ /**
206
+ * Collect form data and POST it to a URL. Inject response into target.
207
+ *
208
+ * @param formId - DOM id of the form.
209
+ * @param url - URL to POST to.
210
+ * @param target - DOM id to inject response into (default: "message").
211
+ * @param callback - Optional callback.
212
+ */
213
+ submit: function(formId, url, target, callback) {
214
+ const data = form.collect(formId);
215
+ post(url, data, target || "message", callback);
216
+ },
217
+ /**
218
+ * Load a form via the given action and inject response HTML.
219
+ *
220
+ * Accepts friendly names: "create", "edit" map to GET; "delete" maps
221
+ * to DELETE.
222
+ *
223
+ * @param action - HTTP method or friendly name.
224
+ * @param url - URL to fetch.
225
+ * @param target - DOM id to inject into (default: "form").
226
+ * @param callback - Optional callback.
227
+ */
228
+ show: function(action, url, target, callback) {
229
+ let method = action.toUpperCase();
230
+ if (action === "create" || action === "edit") method = "GET";
231
+ if (action === "delete") method = "DELETE";
232
+ const targetId = target || "form";
233
+ request(url, {
234
+ method,
235
+ onSuccess: function(data) {
236
+ let html = "";
237
+ if (data && data.message !== void 0) {
238
+ html = inject(data.message, targetId);
239
+ } else if (document.getElementById(targetId)) {
240
+ html = inject(data, targetId);
241
+ } else {
242
+ if (callback) callback(data);
243
+ return;
244
+ }
245
+ if (callback) callback(html);
246
+ }
247
+ });
248
+ }
249
+ };
250
+ function wsConnect(url, options) {
251
+ const opts = {
252
+ reconnect: true,
253
+ reconnectDelay: 1e3,
254
+ maxReconnectDelay: 3e4,
255
+ maxReconnectAttempts: Infinity,
256
+ protocols: [],
257
+ onOpen: function() {
258
+ },
259
+ onClose: function() {
260
+ },
261
+ onError: function() {
262
+ },
263
+ ...options || {}
264
+ };
265
+ let socket = null;
266
+ let intentionalClose = false;
267
+ let currentDelay = opts.reconnectDelay;
268
+ let attempts = 0;
269
+ let reconnectTimer = null;
270
+ const listeners = {
271
+ message: [],
272
+ open: [],
273
+ close: [],
274
+ error: []
275
+ };
276
+ const managed = {
277
+ status: "connecting",
278
+ send: function(data) {
279
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
280
+ throw new Error("[frond] WebSocket is not connected");
281
+ }
282
+ socket.send(typeof data === "string" ? data : JSON.stringify(data));
283
+ },
284
+ on: function(event, handler) {
285
+ if (!listeners[event]) listeners[event] = [];
286
+ listeners[event].push(handler);
287
+ return function() {
288
+ const arr = listeners[event];
289
+ const idx = arr.indexOf(handler);
290
+ if (idx >= 0) arr.splice(idx, 1);
291
+ };
292
+ },
293
+ close: function(code, reason) {
294
+ intentionalClose = true;
295
+ if (reconnectTimer) {
296
+ clearTimeout(reconnectTimer);
297
+ reconnectTimer = null;
298
+ }
299
+ if (socket) {
300
+ socket.close(code || 1e3, reason || "");
301
+ }
302
+ managed.status = "closed";
303
+ }
304
+ };
305
+ function parseMessage(data) {
306
+ if (typeof data !== "string") return data;
307
+ try {
308
+ return JSON.parse(data);
309
+ } catch {
310
+ return data;
311
+ }
312
+ }
313
+ function scheduleReconnect() {
314
+ if (!opts.reconnect || attempts >= opts.maxReconnectAttempts) return;
315
+ attempts++;
316
+ managed.status = "reconnecting";
317
+ reconnectTimer = setTimeout(function() {
318
+ reconnectTimer = null;
319
+ connect();
320
+ }, currentDelay);
321
+ currentDelay = Math.min(currentDelay * 2, opts.maxReconnectDelay);
322
+ }
323
+ function connect() {
324
+ managed.status = attempts > 0 ? "reconnecting" : "connecting";
325
+ try {
326
+ socket = new WebSocket(url, opts.protocols);
327
+ } catch {
328
+ managed.status = "closed";
329
+ return;
330
+ }
331
+ socket.onopen = function() {
332
+ managed.status = "open";
333
+ attempts = 0;
334
+ currentDelay = opts.reconnectDelay;
335
+ opts.onOpen();
336
+ for (const fn of listeners.open) fn();
337
+ };
338
+ socket.onmessage = function(event) {
339
+ const parsed = parseMessage(event.data);
340
+ for (const fn of listeners.message) fn(parsed);
341
+ };
342
+ socket.onclose = function(event) {
343
+ managed.status = "closed";
344
+ opts.onClose(event.code, event.reason);
345
+ for (const fn of listeners.close) fn(event.code, event.reason);
346
+ if (!intentionalClose) {
347
+ scheduleReconnect();
348
+ }
349
+ };
350
+ socket.onerror = function(event) {
351
+ opts.onError(event);
352
+ for (const fn of listeners.error) fn(event);
353
+ };
354
+ }
355
+ connect();
356
+ return managed;
357
+ }
358
+ function sseConnect(url, options) {
359
+ const opts = {
360
+ reconnect: true,
361
+ reconnectDelay: 1e3,
362
+ maxReconnectDelay: 3e4,
363
+ maxReconnectAttempts: Infinity,
364
+ events: [],
365
+ json: true,
366
+ onOpen: function() {
367
+ },
368
+ onClose: function() {
369
+ },
370
+ onError: function() {
371
+ },
372
+ ...options || {}
373
+ };
374
+ let source = null;
375
+ let intentionalClose = false;
376
+ let currentDelay = opts.reconnectDelay;
377
+ let attempts = 0;
378
+ let reconnectTimer = null;
379
+ const listeners = {
380
+ message: [],
381
+ open: [],
382
+ close: [],
383
+ error: []
384
+ };
385
+ const managed = {
386
+ status: "connecting",
387
+ on: function(event, handler) {
388
+ if (!listeners[event]) listeners[event] = [];
389
+ listeners[event].push(handler);
390
+ return function() {
391
+ const arr = listeners[event];
392
+ const idx = arr.indexOf(handler);
393
+ if (idx >= 0) arr.splice(idx, 1);
394
+ };
395
+ },
396
+ close: function() {
397
+ intentionalClose = true;
398
+ if (reconnectTimer) {
399
+ clearTimeout(reconnectTimer);
400
+ reconnectTimer = null;
401
+ }
402
+ if (source) {
403
+ source.close();
404
+ source = null;
405
+ }
406
+ managed.status = "closed";
407
+ }
408
+ };
409
+ function parseData(raw) {
410
+ if (!opts.json) return raw;
411
+ try {
412
+ return JSON.parse(raw);
413
+ } catch {
414
+ return raw;
415
+ }
416
+ }
417
+ function dispatch(data, eventName) {
418
+ for (const fn of listeners.message) fn(data, eventName || void 0);
419
+ }
420
+ function scheduleReconnect() {
421
+ if (!opts.reconnect || attempts >= opts.maxReconnectAttempts) return;
422
+ attempts++;
423
+ managed.status = "reconnecting";
424
+ reconnectTimer = setTimeout(function() {
425
+ reconnectTimer = null;
426
+ connect();
427
+ }, currentDelay);
428
+ currentDelay = Math.min(currentDelay * 2, opts.maxReconnectDelay);
429
+ }
430
+ function connect() {
431
+ managed.status = attempts > 0 ? "reconnecting" : "connecting";
432
+ try {
433
+ source = new EventSource(url);
434
+ } catch {
435
+ managed.status = "closed";
436
+ return;
437
+ }
438
+ source.onopen = function() {
439
+ managed.status = "open";
440
+ attempts = 0;
441
+ currentDelay = opts.reconnectDelay;
442
+ opts.onOpen();
443
+ for (const fn of listeners.open) fn(null);
444
+ };
445
+ source.onmessage = function(event) {
446
+ dispatch(parseData(event.data), null);
447
+ };
448
+ for (const name of opts.events) {
449
+ source.addEventListener(name, function(e) {
450
+ dispatch(parseData(e.data), name);
451
+ });
452
+ }
453
+ source.onerror = function(event) {
454
+ opts.onError(event);
455
+ for (const fn of listeners.error) fn(event);
456
+ if (source && source.readyState === 2) {
457
+ source = null;
458
+ managed.status = "closed";
459
+ opts.onClose();
460
+ for (const fn of listeners.close) fn(null);
461
+ if (!intentionalClose) {
462
+ scheduleReconnect();
463
+ }
464
+ }
465
+ };
466
+ }
467
+ connect();
468
+ return managed;
469
+ }
470
+ var cookie = {
471
+ /**
472
+ * Set a browser cookie.
473
+ *
474
+ * @param name - Cookie name.
475
+ * @param value - Cookie value.
476
+ * @param days - Optional lifetime in days.
477
+ */
478
+ set: function(name, value, days) {
479
+ let expires = "";
480
+ if (days) {
481
+ const d = /* @__PURE__ */ new Date();
482
+ d.setTime(d.getTime() + days * 24 * 60 * 60 * 1e3);
483
+ expires = "; expires=" + d.toUTCString();
484
+ }
485
+ document.cookie = name + "=" + (value || "") + expires + "; path=/";
486
+ },
487
+ /**
488
+ * Retrieve a cookie value by name.
489
+ *
490
+ * @param name - Cookie name.
491
+ * @returns Cookie value, or null if not found.
492
+ */
493
+ get: function(name) {
494
+ const nameEQ = name + "=";
495
+ const parts = document.cookie.split(";");
496
+ for (let i = 0; i < parts.length; i++) {
497
+ let c = parts[i];
498
+ while (c.charAt(0) === " ") c = c.substring(1);
499
+ if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length);
500
+ }
501
+ return null;
502
+ },
503
+ /**
504
+ * Delete a cookie by name.
505
+ *
506
+ * @param name - Cookie name.
507
+ */
508
+ remove: function(name) {
509
+ document.cookie = name + "=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/";
510
+ }
511
+ };
512
+ function message(text, type) {
513
+ const el = document.getElementById("message");
514
+ if (!el) return;
515
+ const alertType = type || "info";
516
+ el.innerHTML = '<div class="alert alert-' + alertType + ' alert-dismissible">' + text + '<button type="button" class="btn-close" data-t4-dismiss="alert">&times;</button></div>';
517
+ }
518
+ function popup(url, title, w, h) {
519
+ const dualLeft = window.screenLeft !== void 0 ? window.screenLeft : window.screenX;
520
+ const dualTop = window.screenTop !== void 0 ? window.screenTop : window.screenY;
521
+ const width = window.innerWidth || document.documentElement.clientWidth || screen.width;
522
+ const height = window.innerHeight || document.documentElement.clientHeight || screen.height;
523
+ const zoom = width / window.screen.availWidth;
524
+ const left = (width - w) / 2 / zoom + dualLeft;
525
+ const top = (height - h) / 2 / zoom + dualTop;
526
+ const win = window.open(
527
+ url,
528
+ title,
529
+ "directories=no,toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width=" + w / zoom + ",height=" + h / zoom + ",top=" + top + ",left=" + left
530
+ );
531
+ if (window.focus && win) win.focus();
532
+ return win;
533
+ }
534
+ function report(url) {
535
+ if (url.indexOf("No data available") >= 0) {
536
+ window.alert("No data available for this report.");
537
+ return;
538
+ }
539
+ window.open(
540
+ url,
541
+ "_blank",
542
+ "toolbar=no,scrollbars=yes,resizable=yes,width=800,height=600,top=0,left=0"
543
+ );
544
+ }
545
+ function graphql(url, query, variables, callback) {
546
+ request(url, {
547
+ method: "POST",
548
+ body: { query, variables: variables || {} },
549
+ onSuccess: function(response) {
550
+ if (callback) {
551
+ callback(response.data || null, response.errors || void 0);
552
+ }
553
+ },
554
+ onError: function(status) {
555
+ if (callback) {
556
+ callback(null, [{ message: "GraphQL request failed with status " + status }]);
557
+ }
558
+ }
559
+ });
560
+ }
561
+ var frond = {
562
+ /** Core HTTP request. */
563
+ request,
564
+ /** GET + inject HTML into target element. */
565
+ load,
566
+ /** POST + inject HTML into target element. */
567
+ post,
568
+ /** Parse HTML string, inject into element, execute scripts. */
569
+ inject,
570
+ /** Form helpers: collect, submit, show. */
571
+ form,
572
+ /** WebSocket with auto-reconnect. */
573
+ ws: wsConnect,
574
+ /** Server-Sent Events with auto-reconnect. */
575
+ sse: sseConnect,
576
+ /** Cookie helpers: get, set, remove. */
577
+ cookie,
578
+ /** Display alert message in #message element. */
579
+ message,
580
+ /** Open centred popup window. */
581
+ popup,
582
+ /** Open PDF report in new window. */
583
+ report,
584
+ /** Execute a GraphQL query/mutation. */
585
+ graphql,
586
+ /** Current bearer token (read/write). */
587
+ get token() {
588
+ return _token;
589
+ },
590
+ set token(value) {
591
+ _token = value;
592
+ }
593
+ };
594
+ if (typeof window !== "undefined") {
595
+ window.frond = frond;
596
+ }
597
+ return __toCommonJS(frond_exports);
598
+ })();
599
+ /* Frond v2.1.3 — tina4.com */
600
+ //# sourceMappingURL=frond.js.map
@@ -1,2 +1,2 @@
1
1
  var _frondModule=(()=>{var b=Object.defineProperty;var k=Object.getOwnPropertyDescriptor;var x=Object.getOwnPropertyNames;var C=Object.prototype.hasOwnProperty;var O=(o,s)=>{for(var e in s)b(o,e,{get:s[e],enumerable:!0})},M=(o,s,e,t)=>{if(s&&typeof s=="object"||typeof s=="function")for(let n of x(s))!C.call(o,n)&&n!==e&&b(o,n,{get:()=>s[n],enumerable:!(t=k(s,n))||t.enumerable});return o};var q=o=>M(b({},"__esModule",{value:!0}),o);var j={};O(j,{frond:()=>R});var g=null;function w(o,s){let e;typeof s=="function"?e={onSuccess:s}:e=s||{};let t=(e.method||"GET").toUpperCase(),n=new XMLHttpRequest;if(n.open(t,o,!0),g!==null&&n.setRequestHeader("Authorization","Bearer "+g),e.headers)for(let r in e.headers)Object.prototype.hasOwnProperty.call(e.headers,r)&&n.setRequestHeader(r,e.headers[r]);let i=null;e.body!==void 0&&e.body!==null&&(e.body instanceof FormData?i=e.body:typeof e.body=="object"?(i=JSON.stringify(e.body),n.setRequestHeader("Content-Type","application/json; charset=UTF-8")):typeof e.body=="string"&&(i=e.body,n.setRequestHeader("Content-Type","text/plain; charset=UTF-8"))),n.onload=function(){let r=n.getResponseHeader("FreshToken");r&&r!==""&&(g=r);let u=n.response;try{u=JSON.parse(u)}catch{}if(n.responseURL){let c=new URL(o,window.location.href).href;if(n.responseURL!==c){window.location.href=n.responseURL;return}}n.status>=200&&n.status<400?e.onSuccess&&e.onSuccess(u,n.status,n):e.onError&&e.onError(n.status,n)},n.onerror=function(){e.onError&&e.onError(n.status,n)},n.send(i)}function h(o,s){if(!o)return"";let e=new DOMParser,t=o.includes("<html>")?o:"<body>"+o+"</body></html>",i=e.parseFromString(t,"text/html").querySelector("body"),r=i.querySelectorAll("script");if(r.forEach(function(u){u.remove()}),s!==null){let u=document.getElementById(s);return u&&(i.children.length>0?u.replaceChildren.apply(u,Array.from(i.children)):u.innerHTML=i.innerHTML,r.forEach(function(c){let d=document.createElement("script");d.type="text/javascript",d.async=!0,c.src?d.src=c.src:d.textContent=c.textContent,u.appendChild(d)})),""}return r.forEach(function(u){let c=document.createElement("script");c.type="text/javascript",c.async=!0,c.textContent=u.textContent,document.body.appendChild(c)}),i.innerHTML}function H(o,s,e){let t=s||"content";w(o,{method:"GET",onSuccess:function(n,i){if(document.getElementById(t)){let r=h(n,t);e&&e(r,n)}else e&&e(n)}})}function S(o,s,e,t){let n=e||"content";w(o,{method:"POST",body:s,onSuccess:function(i){let r="";if(i&&i.message!==void 0)r=h(i.message,n);else if(document.getElementById(n))r=h(i,n);else{t&&t(i);return}t&&t(r,i)}})}var T={collect:function(o){let s=new FormData,e=document.querySelectorAll("#"+o+" select, #"+o+" input, #"+o+" textarea");for(let t=0;t<e.length;t++){let n=e[t];if(n.name==="formToken"&&g!==null&&(n.value=g),!!n.name)if(n.type==="file"){let i=n.files;if(i)for(let r=0;r<i.length;r++){let u=i[r];if(u!==void 0){let c=n.name;i.length>1&&!c.includes("[")&&(c=c+"[]"),s.append(c,u,u.name)}}}else n.type==="checkbox"||n.type==="radio"?n.checked?s.append(n.name,n.value):n.type!=="radio"&&s.append(n.name,"0"):s.append(n.name,n.value===""?"":n.value)}return s},submit:function(o,s,e,t){let n=T.collect(o);S(s,n,e||"message",t)},show:function(o,s,e,t){let n=o.toUpperCase();(o==="create"||o==="edit")&&(n="GET"),o==="delete"&&(n="DELETE");let i=e||"form";w(s,{method:n,onSuccess:function(r){let u="";if(r&&r.message!==void 0)u=h(r.message,i);else if(document.getElementById(i))u=h(r,i);else{t&&t(r);return}t&&t(u)}})}};function L(o,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,protocols:[],onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},t=null,n=!1,i=e.reconnectDelay,r=0,u=null,c={message:[],open:[],close:[],error:[]},d={status:"connecting",send:function(l){if(!t||t.readyState!==WebSocket.OPEN)throw new Error("[frond] WebSocket is not connected");t.send(typeof l=="string"?l:JSON.stringify(l))},on:function(l,a){return c[l]||(c[l]=[]),c[l].push(a),function(){let f=c[l],m=f.indexOf(a);m>=0&&f.splice(m,1)}},close:function(l,a){n=!0,u&&(clearTimeout(u),u=null),t&&t.close(l||1e3,a||""),d.status="closed"}};function y(l){if(typeof l!="string")return l;try{return JSON.parse(l)}catch{return l}}function p(){!e.reconnect||r>=e.maxReconnectAttempts||(r++,d.status="reconnecting",u=setTimeout(function(){u=null,v()},i),i=Math.min(i*2,e.maxReconnectDelay))}function v(){d.status=r>0?"reconnecting":"connecting";try{t=new WebSocket(o,e.protocols)}catch{d.status="closed";return}t.onopen=function(){d.status="open",r=0,i=e.reconnectDelay,e.onOpen();for(let l of c.open)l()},t.onmessage=function(l){let a=y(l.data);for(let f of c.message)f(a)},t.onclose=function(l){d.status="closed",e.onClose(l.code,l.reason);for(let a of c.close)a(l.code,l.reason);n||p()},t.onerror=function(l){e.onError(l);for(let a of c.error)a(l)}}return v(),d}function D(o,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,events:[],json:!0,onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},t=null,n=!1,i=e.reconnectDelay,r=0,u=null,c={message:[],open:[],close:[],error:[]},d={status:"connecting",on:function(a,f){return c[a]||(c[a]=[]),c[a].push(f),function(){let m=c[a],E=m.indexOf(f);E>=0&&m.splice(E,1)}},close:function(){n=!0,u&&(clearTimeout(u),u=null),t&&(t.close(),t=null),d.status="closed"}};function y(a){if(!e.json)return a;try{return JSON.parse(a)}catch{return a}}function p(a,f){for(let m of c.message)m(a,f||void 0)}function v(){!e.reconnect||r>=e.maxReconnectAttempts||(r++,d.status="reconnecting",u=setTimeout(function(){u=null,l()},i),i=Math.min(i*2,e.maxReconnectDelay))}function l(){d.status=r>0?"reconnecting":"connecting";try{t=new EventSource(o)}catch{d.status="closed";return}t.onopen=function(){d.status="open",r=0,i=e.reconnectDelay,e.onOpen();for(let a of c.open)a(null)},t.onmessage=function(a){p(y(a.data),null)};for(let a of e.events)t.addEventListener(a,function(f){p(y(f.data),a)});t.onerror=function(a){e.onError(a);for(let f of c.error)f(a);if(t&&t.readyState===2){t=null,d.status="closed",e.onClose();for(let f of c.close)f(null);n||v()}}}return l(),d}var W={set:function(o,s,e){let t="";if(e){let n=new Date;n.setTime(n.getTime()+e*24*60*60*1e3),t="; expires="+n.toUTCString()}document.cookie=o+"="+(s||"")+t+"; path=/"},get:function(o){let s=o+"=",e=document.cookie.split(";");for(let t=0;t<e.length;t++){let n=e[t];for(;n.charAt(0)===" ";)n=n.substring(1);if(n.indexOf(s)===0)return n.substring(s.length)}return null},remove:function(o){document.cookie=o+"=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/"}};function A(o,s){let e=document.getElementById("message");if(!e)return;let t=s||"info";e.innerHTML='<div class="alert alert-'+t+' alert-dismissible">'+o+'<button type="button" class="btn-close" data-t4-dismiss="alert">&times;</button></div>'}function I(o,s,e,t){let n=window.screenLeft!==void 0?window.screenLeft:window.screenX,i=window.screenTop!==void 0?window.screenTop:window.screenY,r=window.innerWidth||document.documentElement.clientWidth||screen.width,u=window.innerHeight||document.documentElement.clientHeight||screen.height,c=r/window.screen.availWidth,d=(r-e)/2/c+n,y=(u-t)/2/c+i,p=window.open(o,s,"directories=no,toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width="+e/c+",height="+t/c+",top="+y+",left="+d);return window.focus&&p&&p.focus(),p}function N(o){if(o.indexOf("No data available")>=0){window.alert("No data available for this report.");return}window.open(o,"_blank","toolbar=no,scrollbars=yes,resizable=yes,width=800,height=600,top=0,left=0")}function U(o,s,e,t){w(o,{method:"POST",body:{query:s,variables:e||{}},onSuccess:function(n){t&&t(n.data||null,n.errors||void 0)},onError:function(n){t&&t(null,[{message:"GraphQL request failed with status "+n}])}})}var R={request:w,load:H,post:S,inject:h,form:T,ws:L,sse:D,cookie:W,message:A,popup:I,report:N,graphql:U,get token(){return g},set token(o){g=o}};typeof window<"u"&&(window.frond=R);return q(j);})();
2
- /* Frond v2 — tina4.com */
2
+ /* Frond v2.1.3 — tina4.com */
@@ -32,7 +32,7 @@ function base64urlDecode(str: string): Buffer {
32
32
  /**
33
33
  * Create a signed JWT token.
34
34
  *
35
- * Secret is always read from `process.env.SECRET`.
35
+ * Secret is always read from `process.env.TINA4_SECRET`.
36
36
  * Algorithm is read from `process.env.TINA4_JWT_ALGORITHM` (default "HS256").
37
37
  *
38
38
  * @param payload - Claims to encode (e.g. `{ userId: 1, role: "admin" }`)
@@ -50,15 +50,15 @@ export function getToken(
50
50
  let resolvedSecret: string;
51
51
  let resolvedExpiresIn: number;
52
52
  if (typeof secretOrExpiresIn === "number") {
53
- resolvedSecret = process.env.SECRET ?? "";
53
+ resolvedSecret = process.env.TINA4_SECRET ?? "";
54
54
  resolvedExpiresIn = secretOrExpiresIn;
55
55
  } else {
56
- resolvedSecret = secretOrExpiresIn ?? process.env.SECRET ?? "";
56
+ resolvedSecret = secretOrExpiresIn ?? process.env.TINA4_SECRET ?? "";
57
57
  resolvedExpiresIn = expiresIn;
58
58
  }
59
59
 
60
60
  if (!resolvedSecret) {
61
- console.warn("Auth: SECRET not set in .env — using blank secret (insecure)");
61
+ console.warn("Auth: TINA4_SECRET not set in .env — using blank secret (insecure)");
62
62
  }
63
63
  const resolvedAlgorithm = algorithm ?? process.env.TINA4_JWT_ALGORITHM ?? "HS256";
64
64
 
@@ -81,13 +81,13 @@ export function getToken(
81
81
  /**
82
82
  * Validate a JWT token and return the decoded payload, or false if invalid/expired.
83
83
  *
84
- * Secret is always read from `process.env.SECRET`.
84
+ * Secret is always read from `process.env.TINA4_SECRET`.
85
85
  * Algorithm is read from `process.env.TINA4_JWT_ALGORITHM` (default "HS256").
86
86
  */
87
87
  export function validToken(token: string, secret?: string, algorithm?: string): boolean {
88
- const resolvedSecret = secret ?? process.env.SECRET ?? "";
88
+ const resolvedSecret = secret ?? process.env.TINA4_SECRET ?? "";
89
89
  if (!resolvedSecret) {
90
- console.warn("Auth: SECRET not set in .env — using blank secret (insecure)");
90
+ console.warn("Auth: TINA4_SECRET not set in .env — using blank secret (insecure)");
91
91
  }
92
92
  const resolvedAlgorithm = algorithm ?? process.env.TINA4_JWT_ALGORITHM ?? "HS256";
93
93
  try {
@@ -240,7 +240,7 @@ export function authMiddleware(secret?: string, algorithm: string = "HS256"): Mi
240
240
  * Refresh a JWT token — validate the existing token then re-sign
241
241
  * with a fresh expiry.
242
242
  *
243
- * Secret is always read from `process.env.SECRET`.
243
+ * Secret is always read from `process.env.TINA4_SECRET`.
244
244
  *
245
245
  * @param token - Existing JWT to refresh
246
246
  * @param expiresIn - New lifetime in seconds (default 3600)
@@ -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") {
@@ -822,21 +822,21 @@ async function createAdapterFromUrl(url: string, username?: string, password?: s
822
822
  }
823
823
 
824
824
  /**
825
- * 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.
826
826
  * Now returns a Database wrapper instance.
827
827
  *
828
828
  * Priority:
829
829
  * 1. config.url (explicit URL)
830
- * 2. process.env.DATABASE_URL
830
+ * 2. process.env.TINA4_DATABASE_URL
831
831
  * 3. config.type + config.path (legacy)
832
832
  */
833
833
  export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
834
- // Resolve credentials: config.user > config.username > env DATABASE_USERNAME
835
- const resolvedUser = config?.user ?? config?.username ?? process.env.DATABASE_USERNAME;
836
- 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;
837
837
 
838
838
  // Resolve from URL if provided
839
- const url = config?.url ?? process.env.DATABASE_URL;
839
+ const url = config?.url ?? process.env.TINA4_DATABASE_URL;
840
840
 
841
841
  if (url) {
842
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: {} },