tina4-nodejs 3.13.97 → 3.13.99

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.
Files changed (96) hide show
  1. package/CLAUDE.md +60 -25
  2. package/package.json +1 -2
  3. package/packages/cli/dist/bin.js +20620 -18995
  4. package/packages/cli/src/bin.ts +28 -71
  5. package/packages/cli/src/commands/migrate.ts +36 -75
  6. package/packages/cli/src/commands/migrateRollback.ts +10 -1
  7. package/packages/cli/src/commands/test.ts +92 -21
  8. package/packages/core/dist/index.js +20459 -18815
  9. package/packages/core/public/js/tina4-dev-admin.min.js +23 -19
  10. package/packages/core/src/ai.ts +28 -12
  11. package/packages/core/src/api.ts +13 -5
  12. package/packages/core/src/background.ts +9 -3
  13. package/packages/core/src/devAdmin.ts +135 -20
  14. package/packages/core/src/dispatchPipeline.ts +185 -1
  15. package/packages/core/src/docs.ts +33 -5
  16. package/packages/core/src/env.ts +1 -1
  17. package/packages/core/src/errorOverlay.ts +39 -48
  18. package/packages/core/src/fakeData.ts +15 -0
  19. package/packages/core/src/index.ts +17 -6
  20. package/packages/core/src/logger.ts +892 -572
  21. package/packages/core/src/mcp.ts +9 -1
  22. package/packages/core/src/messenger.ts +31 -4
  23. package/packages/core/src/middleware.ts +169 -43
  24. package/packages/core/src/portTakeover.ts +232 -0
  25. package/packages/core/src/request.ts +57 -8
  26. package/packages/core/src/response.ts +67 -0
  27. package/packages/core/src/router.ts +35 -7
  28. package/packages/core/src/server.ts +450 -190
  29. package/packages/core/src/static.ts +81 -12
  30. package/packages/core/src/testClient.ts +126 -137
  31. package/packages/core/src/testing.ts +16 -12
  32. package/packages/core/src/types.ts +21 -9
  33. package/packages/core/src/version.ts +66 -0
  34. package/packages/core/src/websocket.ts +2 -2
  35. package/packages/core/src/websocketBackplane.ts +2 -2
  36. package/packages/frond/dist/index.js +31 -13
  37. package/packages/frond/src/engine.ts +39 -7
  38. package/packages/orm/dist/index.js +10879 -9258
  39. package/packages/orm/src/adapters/firebird.ts +200 -27
  40. package/packages/orm/src/adapters/mongodb.ts +160 -10
  41. package/packages/orm/src/adapters/mssql.ts +38 -11
  42. package/packages/orm/src/adapters/mysql.ts +24 -1
  43. package/packages/orm/src/adapters/odbc.ts +127 -29
  44. package/packages/orm/src/adapters/postgres.ts +18 -0
  45. package/packages/orm/src/adapters/sqlite.ts +93 -14
  46. package/packages/orm/src/autoCrud.ts +72 -8
  47. package/packages/orm/src/baseModel.ts +323 -71
  48. package/packages/orm/src/cachedDatabase.ts +48 -1
  49. package/packages/orm/src/database.ts +162 -59
  50. package/packages/orm/src/fakeData.ts +6 -2
  51. package/packages/orm/src/index.ts +4 -1
  52. package/packages/orm/src/migration.ts +95 -52
  53. package/packages/orm/src/query.ts +16 -4
  54. package/packages/orm/src/seeder.ts +43 -25
  55. package/packages/orm/src/sqlTranslator.ts +104 -19
  56. package/packages/orm/src/types.ts +97 -21
  57. package/packages/orm/src/validation.ts +5 -1
  58. package/packages/swagger/dist/index.js +3 -2
  59. package/packages/swagger/src/generator.ts +19 -4
  60. package/packages/swagger/src/ui.ts +6 -4
  61. package/types/cli/src/bin.d.ts +0 -22
  62. package/types/core/src/api.d.ts +11 -4
  63. package/types/core/src/background.d.ts +5 -2
  64. package/types/core/src/devAdmin.d.ts +35 -0
  65. package/types/core/src/dispatchPipeline.d.ts +41 -1
  66. package/types/core/src/errorOverlay.d.ts +13 -13
  67. package/types/core/src/index.d.ts +9 -6
  68. package/types/core/src/logger.d.ts +111 -185
  69. package/types/core/src/middleware.d.ts +40 -5
  70. package/types/core/src/portTakeover.d.ts +50 -0
  71. package/types/core/src/request.d.ts +15 -0
  72. package/types/core/src/response.d.ts +29 -0
  73. package/types/core/src/server.d.ts +92 -0
  74. package/types/core/src/testClient.d.ts +29 -3
  75. package/types/core/src/testing.d.ts +16 -12
  76. package/types/core/src/types.d.ts +21 -9
  77. package/types/core/src/version.d.ts +11 -0
  78. package/types/core/src/websocketBackplane.d.ts +1 -1
  79. package/types/frond/src/engine.d.ts +10 -0
  80. package/types/orm/src/adapters/firebird.d.ts +61 -2
  81. package/types/orm/src/adapters/mongodb.d.ts +20 -0
  82. package/types/orm/src/adapters/mssql.d.ts +11 -0
  83. package/types/orm/src/adapters/mysql.d.ts +11 -0
  84. package/types/orm/src/adapters/odbc.d.ts +35 -4
  85. package/types/orm/src/adapters/postgres.d.ts +11 -0
  86. package/types/orm/src/adapters/sqlite.d.ts +23 -4
  87. package/types/orm/src/baseModel.d.ts +45 -25
  88. package/types/orm/src/cachedDatabase.d.ts +27 -1
  89. package/types/orm/src/database.d.ts +56 -6
  90. package/types/orm/src/index.d.ts +3 -2
  91. package/types/orm/src/migration.d.ts +23 -5
  92. package/types/orm/src/query.d.ts +3 -0
  93. package/types/orm/src/seeder.d.ts +15 -2
  94. package/types/orm/src/sqlTranslator.d.ts +17 -4
  95. package/types/orm/src/types.d.ts +75 -16
  96. package/packages/core/src/errorOverlay.test.ts +0 -122
@@ -20,6 +20,7 @@ import * as os from "node:os";
20
20
  import { spawnSync } from "node:child_process";
21
21
  import { createRequire } from "node:module";
22
22
  import { randomBytes } from "node:crypto";
23
+ import { TINA4_VERSION } from "./version.js";
23
24
 
24
25
  // Synchronous CommonJS-style require that works under real ESM (where the
25
26
  // bare `require` global is undefined). Dev-tool handlers are synchronous, so
@@ -728,7 +729,14 @@ let _defaultToolsRegistered = false;
728
729
 
729
730
  function _getDefaultServer(): McpServer {
730
731
  if (_defaultServer === null) {
731
- _defaultServer = new McpServer("/__dev/mcp", "Tina4 Dev Tools");
732
+ // VERSION-DEC-01 (feature 130): the built-in dev server's serverInfo must
733
+ // report the SAME version every other surface does, not the constructor's
734
+ // generic '1.0.0' default -- TINA4_VERSION is the one shared resolver
735
+ // (health, banner, dashboard already read it). A user's OWN custom
736
+ // `new McpServer(path, name)` (no third arg) is unaffected -- that default
737
+ // stays '1.0.0' for app authors who have not set their own tool-server
738
+ // version.
739
+ _defaultServer = new McpServer("/__dev/mcp", "Tina4 Dev Tools", TINA4_VERSION);
732
740
  }
733
741
  return _defaultServer;
734
742
  }
@@ -43,6 +43,17 @@ function tlsRejectUnauthorized(): boolean {
43
43
  return !isTruthy(process.env.TINA4_MAIL_TLS_INSECURE);
44
44
  }
45
45
 
46
+ /**
47
+ * Parse TINA4_MAIL_REDIRECT_TO (MAIL-DEC-01): comma-separated addresses, each
48
+ * trimmed, blanks dropped. Unset/empty -> [] (redirect off, no behaviour
49
+ * change). Read fresh on every send() call — same style as shouldCapture()'s
50
+ * TINA4_MAIL_CAPTURE read — so a changed env is honoured without restart.
51
+ */
52
+ function parseMailRedirectList(raw: string | undefined): string[] {
53
+ if (!raw) return [];
54
+ return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
55
+ }
56
+
46
57
  // ── Types ────────────────────────────────────────────────────
47
58
 
48
59
  export interface SendResult {
@@ -476,10 +487,10 @@ export class Messenger {
476
487
  headers?: Record<string, string>,
477
488
  ): Promise<SendResult> {
478
489
  const options: SendOptions = { to, subject, body, html, text, cc, bcc, replyTo, attachments, headers };
479
- const toList = Array.isArray(options.to) ? options.to : [options.to];
480
- const ccList = Array.isArray(options.cc) ? options.cc : (options.cc ? [options.cc] : []);
481
- const bccList = Array.isArray(options.bcc) ? options.bcc : (options.bcc ? [options.bcc] : []);
482
- const allRecipients = [...toList, ...ccList, ...bccList];
490
+ let toList = Array.isArray(options.to) ? options.to : [options.to];
491
+ let ccList = Array.isArray(options.cc) ? options.cc : (options.cc ? [options.cc] : []);
492
+ let bccList = Array.isArray(options.bcc) ? options.bcc : (options.bcc ? [options.bcc] : []);
493
+ let allRecipients = [...toList, ...ccList, ...bccList];
483
494
 
484
495
  // Dev capture is a BRANCH here, not a different object returned by the factory.
485
496
  // createMessenger() used to hand back a DevMailbox, which has capture() and no
@@ -491,6 +502,22 @@ export class Messenger {
491
502
  );
492
503
  }
493
504
 
505
+ // TINA4_MAIL_REDIRECT_TO (MAIL-DEC-01): on the REAL-SEND path only — capture
506
+ // already returned above, so this never touches the capture branch. When the
507
+ // list is non-empty, replace every recipient with the redirect list (so ONLY
508
+ // the dev list receives the mail, never the real recipients) and preserve the
509
+ // original recipients in X-Tina4-Original-To. Subject/body/attachments are
510
+ // untouched, and send()'s return shape is unchanged.
511
+ const redirectTo = parseMailRedirectList(process.env.TINA4_MAIL_REDIRECT_TO);
512
+ if (redirectTo.length > 0) {
513
+ const originalTo = allRecipients.join(", ");
514
+ toList = redirectTo;
515
+ ccList = [];
516
+ bccList = [];
517
+ allRecipients = [...toList];
518
+ options.headers = { ...(options.headers ?? {}), "X-Tina4-Original-To": originalTo };
519
+ }
520
+
494
521
  const messageId = `${randomUUID()}@${this.host}`;
495
522
 
496
523
  if (allRecipients.length === 0) {
@@ -5,6 +5,7 @@ import { Log } from "./logger.js";
5
5
  import { isTruthy } from "./dotenv.js";
6
6
  import { defaultRouter, type Router } from "./router.js";
7
7
  import { resolveClientIp } from "./trustedProxy.js";
8
+ import { getFrond, getFrameworkFrond, wantsJson, negotiatedErrorBody } from "./response.js";
8
9
 
9
10
  /**
10
11
  * Whether to emit a per-request log line (v3.13.14). TINA4_LOG_REQUESTS is
@@ -101,6 +102,55 @@ function isResponse(value: unknown): value is Tina4Response {
101
102
  && typeof (value as Tina4Response).raw?.end === "function";
102
103
  }
103
104
 
105
+ /**
106
+ * The 403 a hook gets when it says no without saying what to send
107
+ * (ERR-DEC-01/ERR-DEC-02). Routed through the SAME negotiated renderer
108
+ * 404/500 use (server.ts's serveNotFound/renderDispatchError share the same
109
+ * getFrond/getFrameworkFrond singletons via response.ts), so a middleware
110
+ * refusal looks like every other error page - a user template if the app
111
+ * ships one, the framework's errors/403.twig otherwise, negotiated JSON for
112
+ * an API client - instead of the old bare `res.raw.statusCode = 403` with no
113
+ * body at all.
114
+ */
115
+ async function renderForbidden(req: Tina4Request, res: Tina4Response): Promise<void> {
116
+ const requestId = Log.getRequestId() ?? "";
117
+
118
+ if (wantsJson(req)) {
119
+ const body = negotiatedErrorBody(403, "Forbidden", requestId);
120
+ res.raw.statusCode = HTTP_FORBIDDEN;
121
+ res.raw.setHeader("Content-Type", "application/json");
122
+ res.raw.end(JSON.stringify(body));
123
+ return;
124
+ }
125
+
126
+ const data = { path: req.path ?? "", error_message: "Forbidden", request_id: requestId, status_code: 403 };
127
+ let html: string | null = null;
128
+ try {
129
+ html = (await getFrond()).render("errors/403.twig", data);
130
+ } catch {
131
+ // fall through to the framework default
132
+ }
133
+ if (!html) {
134
+ try {
135
+ const fw = await getFrameworkFrond();
136
+ html = fw ? fw.render("errors/403.twig", data) : null;
137
+ } catch {
138
+ html = null;
139
+ }
140
+ }
141
+
142
+ if (html) {
143
+ res.raw.writeHead(HTTP_FORBIDDEN, { "Content-Type": "text/html; charset=utf-8" });
144
+ res.raw.end(html);
145
+ return;
146
+ }
147
+
148
+ const body = negotiatedErrorBody(403, "Forbidden", requestId);
149
+ res.raw.statusCode = HTTP_FORBIDDEN;
150
+ res.raw.setHeader("Content-Type", "application/json");
151
+ res.raw.end(JSON.stringify(body));
152
+ }
153
+
104
154
  /**
105
155
  * ONE return-value table, for EVERY beforeX/afterX hook, at EVERY scope
106
156
  * (global and per-route):
@@ -111,17 +161,19 @@ function isResponse(value: unknown): value is Tina4Response {
111
161
  * the [req, res] pair rebind both, continue (length >= 2, mirroring Python's
112
162
  * `isinstance(result, tuple) and len(result) >= 2`)
113
163
  * false SHORT-CIRCUIT. Send the response AS SET; a still
114
- * default and still unwritten response becomes a 403,
115
- * because a bare `return false` is a deny.
164
+ * default and still unwritten response becomes a
165
+ * NEGOTIATED 403 (renderForbidden), because a bare
166
+ * `return false` is a deny.
116
167
  * undefined / null continue
117
168
  *
169
+ * ASYNC because the false-row now renders a template (await getFrond()).
118
170
  * Returns [req, res, stop].
119
171
  */
120
- function interpretHookResult(
172
+ async function interpretHookResult(
121
173
  result: unknown,
122
174
  req: Tina4Request,
123
175
  res: Tina4Response,
124
- ): [Tina4Request, Tina4Response, boolean] {
176
+ ): Promise<[Tina4Request, Tina4Response, boolean]> {
125
177
  if (Array.isArray(result)) {
126
178
  return result.length >= 2
127
179
  ? [result[0] as Tina4Request, result[1] as Tina4Response, false]
@@ -130,7 +182,7 @@ function interpretHookResult(
130
182
  if (isResponse(result)) return [req, result, true];
131
183
  if (result === false) {
132
184
  if (!res.raw.writableEnded && res.raw.statusCode === HTTP_OK) {
133
- res.raw.statusCode = HTTP_FORBIDDEN;
185
+ await renderForbidden(req, res);
134
186
  }
135
187
  return [req, res, true];
136
188
  }
@@ -319,7 +371,7 @@ export class MiddlewareRunner {
319
371
  for (const method of MiddlewareRunner.methodNames(cls, "before")) {
320
372
  try {
321
373
  const [nextReq, nextRes, stop] =
322
- interpretHookResult(await cls[method](req, res), req, res);
374
+ await interpretHookResult(await cls[method](req, res), req, res);
323
375
  req = nextReq;
324
376
  res = nextRes;
325
377
  if (stop) return [req, res, false];
@@ -376,7 +428,7 @@ export class MiddlewareRunner {
376
428
  for (const method of MiddlewareRunner.methodNames(cls, "after")) {
377
429
  try {
378
430
  const [nextReq, nextRes, stop] =
379
- interpretHookResult(await cls[method](req, res), req, res);
431
+ await interpretHookResult(await cls[method](req, res), req, res);
380
432
  req = nextReq;
381
433
  res = nextRes;
382
434
  if (stop) return [req, res];
@@ -822,8 +874,13 @@ export class SecurityHeadersMiddleware {
822
874
 
823
875
  res.header("X-Content-Type-Options", "nosniff");
824
876
 
877
+ // HSTS is HTTPS-only (SECHDR-DEC-02): a downgrade-protection header on a
878
+ // plain-HTTP response is inert at best and ships a bad max-age on an
879
+ // unencrypted scheme at worst. Emit it ONLY when TINA4_HSTS is set AND the
880
+ // request is HTTPS (x-forwarded-proto first hop, else the native TLS socket)
881
+ // — the same proxy-aware scheme the session cookie's Secure flag uses.
825
882
  const hsts = process.env.TINA4_HSTS ?? "";
826
- if (hsts) {
883
+ if (hsts && SecurityHeadersMiddleware.isSecureRequest(req)) {
827
884
  res.header(
828
885
  "Strict-Transport-Security",
829
886
  `max-age=${hsts}; includeSubDomains`,
@@ -849,31 +906,71 @@ export class SecurityHeadersMiddleware {
849
906
 
850
907
  return [req, res];
851
908
  }
909
+
910
+ /**
911
+ * True when the client request is HTTPS. Proxy-aware and byte-parity with
912
+ * Python (request.is_secure_scheme), PHP (Request::isSecureScheme) and Ruby
913
+ * (Request.secure_scheme?): a TLS-terminating proxy forwards plain HTTP with
914
+ * `x-forwarded-proto`, whose FIRST hop is the client-facing scheme; falling
915
+ * back to the native TLS socket when no such header is present. Its name is
916
+ * not before- or after-prefixed, so hook discovery never calls it as a hook.
917
+ */
918
+ private static isSecureRequest(req: Tina4Request): boolean {
919
+ const xfProto = (req.headers as Record<string, string | string[] | undefined>)[
920
+ "x-forwarded-proto"
921
+ ];
922
+ const firstHop = (Array.isArray(xfProto) ? xfProto[0] : xfProto)
923
+ ?.split(",")[0]
924
+ ?.trim()
925
+ .toLowerCase();
926
+ if (firstHop) return firstHop === "https";
927
+ return Boolean((req.socket as { encrypted?: boolean } | undefined)?.encrypted);
928
+ }
852
929
  }
853
930
 
854
931
  /**
855
932
  * Class-based CSRF middleware using the before/after convention.
856
933
  * Validates form tokens on state-changing requests (POST, PUT, PATCH, DELETE).
857
934
  *
858
- * Off by default — only active when TINA4_CSRF=true in .env or when
859
- * registered explicitly via Router.use(CsrfMiddleware).
935
+ * OFF by default — a default app has NO CSRF gate because the middleware is
936
+ * NOT attached. Set TINA4_CSRF=true (or 1/yes/on) and the framework
937
+ * auto-attaches it at boot (see attachCsrfFromEnv); or register it explicitly
938
+ * via Router.use(CsrfMiddleware). Once attached, TINA4_CSRF=false (or 0/no) is
939
+ * the kill switch that disables enforcement again.
860
940
  *
861
- * Behaviour:
941
+ * Behaviour (identical to the Python master, feature 37):
862
942
  * - Skips GET, HEAD, OPTIONS requests.
863
943
  * - Skips routes marked .noAuth().
944
+ * - Fails CLOSED: with TINA4_SECRET unset the signing secret resolves to
945
+ * blank (there is NO built-in default), and a blank HMAC key is publicly
946
+ * reproducible — so no token can be trusted and every write is rejected
947
+ * (403). This is the SEC-01 / CSRF-DEC-01 no-default-secret guarantee.
864
948
  * - Skips requests with a valid Authorization: Bearer header (API clients).
865
949
  * - Checks request body formToken then X-Form-Token header.
866
950
  * - Rejects if token found in query string formToken (log warning, 403).
867
- * - Validates token with validToken using SECRET env var.
951
+ * - Validates token with validToken using the resolved SECRET, and enforces
952
+ * that the token's `type` claim is "form" — a non-form JWT presented in the
953
+ * formToken slot is rejected (CSRF-DEC-02).
868
954
  * - If token payload has session_id, verifies it matches request session.
869
- * - Returns 403 on failure.
955
+ * - Every rejection is 403 with the CSRF_INVALID envelope
956
+ * { error: true, code: "CSRF_INVALID", message, status: 403 }.
870
957
  *
871
958
  * Usage:
872
959
  * Router.use(CsrfMiddleware);
873
960
  */
874
961
  export class CsrfMiddleware {
875
962
  static beforeCsrf(req: Tina4Request, res: Tina4Response): [Tina4Request, Tina4Response] {
876
- // Skip CSRF validation entirely if disabled via env
963
+ // Every CSRF rejection carries the SAME 403 envelope across all four
964
+ // frameworks (Python master's shape): a real client recognises a CSRF
965
+ // failure by one stable code + status regardless of the framework.
966
+ const reject = (message: string): [Tina4Request, Tina4Response] => {
967
+ res({ error: true, code: "CSRF_INVALID", message, status: HTTP_FORBIDDEN }, HTTP_FORBIDDEN);
968
+ return [req, res];
969
+ };
970
+
971
+ // TINA4_CSRF=false (or 0/no) disables all CSRF checks, even when the
972
+ // middleware is attached — the documented kill switch. Unset defaults to
973
+ // enabled (the middleware only runs at all once attached).
877
974
  const csrfEnv = process.env.TINA4_CSRF;
878
975
  if (csrfEnv === "false" || csrfEnv === "0" || csrfEnv === "no") {
879
976
  return [req, res];
@@ -891,26 +988,35 @@ export class CsrfMiddleware {
891
988
  return [req, res];
892
989
  }
893
990
 
894
- // Skip requests with valid Bearer token (API clients)
991
+ // Resolve the signing secret ONCE, fail-closed IDENTICAL to the validator
992
+ // (auth.ts validToken: `secret ?? process.env.TINA4_SECRET ?? ""`). Blank
993
+ // when TINA4_SECRET is unset; there is NO built-in default.
994
+ const secret = process.env.TINA4_SECRET ?? "";
995
+
996
+ // BLANK-SECRET HARD-FAIL (SEC-01 / CSRF-DEC-01): a blank HMAC key is
997
+ // publicly reproducible, so a token signed with it (or with the retired
998
+ // public 'tina4-default-secret') is a forgery. Reject every write rather
999
+ // than validate against a guessable key — fail closed, hard.
1000
+ if (secret === "") {
1001
+ return reject("CSRF token cannot be validated: TINA4_SECRET is not set");
1002
+ }
1003
+
1004
+ // Skip requests with a valid Bearer token (API clients). Pass the resolved
1005
+ // secret so the Bearer check uses the SAME key as the form-token check.
895
1006
  const authHeader = req.headers.authorization ?? "";
896
1007
  if (authHeader.startsWith("Bearer ")) {
897
1008
  const bearerToken = authHeader.slice(7).trim();
898
- if (bearerToken) {
899
- if (validToken(bearerToken)) {
900
- return [req, res];
901
- }
1009
+ if (bearerToken && validToken(bearerToken, secret)) {
1010
+ return [req, res];
902
1011
  }
903
1012
  }
904
1013
 
905
- // Reject if token is in query string (security risk)
1014
+ // Reject if token is in query string (security risk — a URL leaks through
1015
+ // logs, referers and history).
906
1016
  const query = (req as any).query ?? {};
907
1017
  if (query.formToken) {
908
1018
  console.warn("[Tina4 CSRF] Token found in query string — rejected for security");
909
- res({
910
- error: "CSRF_INVALID",
911
- message: "Form token must not be sent in the URL query string",
912
- }, 403);
913
- return [req, res];
1019
+ return reject("Form token must not be sent in the URL query string");
914
1020
  }
915
1021
 
916
1022
  // Extract token: body first, then header
@@ -925,25 +1031,25 @@ export class CsrfMiddleware {
925
1031
  }
926
1032
 
927
1033
  if (!token) {
928
- res({
929
- error: "CSRF_INVALID",
930
- message: "Invalid or missing form token",
931
- }, 403);
932
- return [req, res];
1034
+ return reject("Invalid or missing form token");
933
1035
  }
934
1036
 
935
- // Validate the token
936
- if (!validToken(token)) {
937
- res({
938
- error: "CSRF_INVALID",
939
- message: "Invalid or missing form token",
940
- }, 403);
941
- return [req, res];
1037
+ // Validate the token signature / expiry against the resolved secret.
1038
+ if (!validToken(token, secret)) {
1039
+ return reject("Invalid or missing form token");
942
1040
  }
943
1041
 
944
1042
  const payload = getPayload(token) ?? {};
945
1043
 
946
- // Session binding if token has session_id, verify it matches
1044
+ // TYPE ENFORCEMENT (CSRF-DEC-02): a valid signature is not enough. A
1045
+ // non-form JWT (e.g. an auth/session token) must never be accepted in the
1046
+ // formToken slot — the token's `type` claim MUST be "form".
1047
+ if (payload.type !== "form") {
1048
+ return reject("Invalid or missing form token");
1049
+ }
1050
+
1051
+ // Session binding — if token has session_id, verify it matches the request
1052
+ // session. A token minted for one session cannot be replayed against another.
947
1053
  const tokenSessionId = payload.session_id as string | undefined;
948
1054
  if (tokenSessionId) {
949
1055
  const session = (req as any).session;
@@ -956,11 +1062,7 @@ export class CsrfMiddleware {
956
1062
  }
957
1063
 
958
1064
  if (currentSessionId && tokenSessionId !== currentSessionId) {
959
- res({
960
- error: "CSRF_INVALID",
961
- message: "Invalid or missing form token",
962
- }, 403);
963
- return [req, res];
1065
+ return reject("Invalid or missing form token");
964
1066
  }
965
1067
  }
966
1068
 
@@ -968,6 +1070,30 @@ export class CsrfMiddleware {
968
1070
  }
969
1071
  }
970
1072
 
1073
+ /**
1074
+ * Auto-attach CsrfMiddleware when TINA4_CSRF is enabled in the environment.
1075
+ *
1076
+ * CSRF is OFF by default: with TINA4_CSRF unset the middleware is never
1077
+ * attached, so a default app has no CSRF gate. Setting TINA4_CSRF to a truthy
1078
+ * value (true/1/yes/on, case-insensitive, trimmed) attaches it globally at boot
1079
+ * so every state-changing route is gated — the env flag is the switch, no code
1080
+ * change needed. Idempotent (MiddlewareRunner.use de-dupes). Returns true when
1081
+ * the middleware is now attached.
1082
+ *
1083
+ * The framework calls this once during startServer (after route discovery,
1084
+ * before listen); a false/0/no value still lets an explicit Router.use opt-in
1085
+ * be disabled at runtime by the kill switch in beforeCsrf. Mirrors Python's
1086
+ * attach_csrf_from_env.
1087
+ */
1088
+ export function attachCsrfFromEnv(): boolean {
1089
+ const value = (process.env.TINA4_CSRF ?? "").trim().toLowerCase();
1090
+ if (value === "true" || value === "1" || value === "yes" || value === "on") {
1091
+ MiddlewareRunner.use(CsrfMiddleware);
1092
+ return true;
1093
+ }
1094
+ return false;
1095
+ }
1096
+
971
1097
  // Built-in request logger middleware.
972
1098
  //
973
1099
  // v3.13.14: routes through the Tina4 Log (was a bare console.log) so the
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Identity-checked port takeover, shared by the CLI and the runtime paths.
3
+ *
4
+ * `tina4 serve` reclaims a busy port so the edit-restart loop does not fail with
5
+ * "address already in use". The convenience has a sharp edge: "whatever is
6
+ * listening" is not always the old Tina4 server, and before this module BOTH
7
+ * takeover paths (the CLI `killProcessOnPort` and the runtime bind-failure
8
+ * `killPort`) SIGTERM'd whatever held the port, with NO check that the victim was
9
+ * a Tina4 dev server -- a foreign holder (another dev server, a database, a stray
10
+ * listener) was killed.
11
+ *
12
+ * This is the ONE takeover implementation both paths call (TAKEOVER-DEC-02), so
13
+ * the runtime path can never again be a weaker twin of the CLI path. It adds:
14
+ *
15
+ * - Identity (TAKEOVER-DEC-01): a Tina4 dev server writes a per-port PID file
16
+ * (`data/.tina4-serve-<port>.pid`) when it binds and removes it on clean exit.
17
+ * Takeover only signals a holder whose PID matches that file; a holder with no
18
+ * matching Tina4 PID file is REFUSED, never killed.
19
+ * - Dev gate + opt-out (TAKEOVER-DEC-03): takeover runs only in dev
20
+ * (`TINA4_DEBUG` truthy) and only when not opted out (`TINA4_NO_TAKEOVER` /
21
+ * `tina4 serve --no-kill`). A production bind never kills a port holder.
22
+ * - The existing PID safety filter and container guard, unchanged, on top.
23
+ *
24
+ * Refusing is always safe (the developer frees the port by hand); over-killing
25
+ * was the bug this fixes.
26
+ */
27
+ import { execFileSync } from "node:child_process";
28
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs";
29
+ import { join } from "node:path";
30
+
31
+ export const TAKEOVER_NOTHING = "nothing";
32
+ export const TAKEOVER_KILLED = "killed";
33
+ export const TAKEOVER_REFUSED_FOREIGN = "refused_foreign";
34
+ export const TAKEOVER_REFUSED_OPTOUT = "refused_optout";
35
+ export const TAKEOVER_REFUSED_PROD = "refused_prod";
36
+ export const TAKEOVER_SKIPPED_CONTAINER = "skipped_container";
37
+ /** Statuses that mean a holder was left running on purpose. */
38
+ export const TAKEOVER_REFUSALS = [
39
+ TAKEOVER_REFUSED_FOREIGN,
40
+ TAKEOVER_REFUSED_OPTOUT,
41
+ TAKEOVER_REFUSED_PROD,
42
+ ];
43
+
44
+ export interface TakeoverResult {
45
+ status: string;
46
+ port: number;
47
+ killed: number[];
48
+ message: string;
49
+ }
50
+
51
+ function isTruthy(value: string | undefined): boolean {
52
+ return ["true", "1", "yes", "on"].includes(String(value ?? "").trim().toLowerCase());
53
+ }
54
+
55
+ /** Dev mode = TINA4_DEBUG truthy. Takeover runs only in dev. */
56
+ export function isDev(): boolean {
57
+ return isTruthy(process.env.TINA4_DEBUG);
58
+ }
59
+
60
+ /** True when takeover is disabled via TINA4_NO_TAKEOVER. */
61
+ export function noTakeoverOptedOut(): boolean {
62
+ return isTruthy(process.env.TINA4_NO_TAKEOVER);
63
+ }
64
+
65
+ /**
66
+ * True when this process is running inside a container. Reclaiming a port makes
67
+ * sense on a dev machine; inside a container the server IS the container, so
68
+ * there is no stale sibling to reclaim from.
69
+ */
70
+ export function inContainer(): boolean {
71
+ if (existsSync("/.dockerenv") || existsSync("/run/.containerenv")) return true;
72
+ try {
73
+ const blob = readFileSync("/proc/1/cgroup", "utf-8");
74
+ return blob.includes("docker") || blob.includes("containerd") || blob.includes("kubepods");
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * The PIDs from `lsof -ti` output that are safe to signal.
82
+ *
83
+ * Pure so the safety rule can be tested directly. A non-numeric field parses to
84
+ * NaN/0, and signalling PID 0 hits EVERY process in the caller's own process
85
+ * group -- the server kills itself. Accept only all-digit tokens; never PID 0
86
+ * (our group), PID 1 (init), ourselves, or our own process group. This is the
87
+ * PID-SAFETY gate only; whether a survivor is a Tina4 server is the SEPARATE
88
+ * identity check in takeOverPort().
89
+ */
90
+ export function selectablePids(lsofOutput: string, me: number, myGroup?: number): number[] {
91
+ const pids: number[] = [];
92
+ for (const token of lsofOutput.split(/\s+/)) {
93
+ if (!/^\d+$/.test(token)) continue; // never coerce junk into a PID
94
+ const pid = Number(token);
95
+ if (pid <= 1 || pid === me) continue; // 0 = our group, 1 = init, me = suicide
96
+ if (myGroup !== undefined && pid === myGroup) continue;
97
+ if (!pids.includes(pid)) pids.push(pid);
98
+ }
99
+ return pids;
100
+ }
101
+
102
+ export function runtimeDir(baseDir?: string): string {
103
+ return baseDir ?? join(process.cwd(), "data");
104
+ }
105
+
106
+ export function pidfilePath(port: number, baseDir?: string): string {
107
+ return join(runtimeDir(baseDir), `.tina4-serve-${port}.pid`);
108
+ }
109
+
110
+ /** Record THIS process as the Tina4 dev server on `port` (best-effort). */
111
+ export function writePidfile(port: number, baseDir?: string, pid?: number): void {
112
+ try {
113
+ mkdirSync(runtimeDir(baseDir), { recursive: true });
114
+ writeFileSync(pidfilePath(port, baseDir), String(pid ?? process.pid));
115
+ } catch {
116
+ /* identity is a convenience; never let it break the server */
117
+ }
118
+ }
119
+
120
+ /** The PID a Tina4 dev server recorded for `port`, or null if none/garbage. */
121
+ export function readPidfile(port: number, baseDir?: string): number | null {
122
+ try {
123
+ const token = readFileSync(pidfilePath(port, baseDir), "utf-8").trim();
124
+ return /^\d+$/.test(token) ? Number(token) : null;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ /** Drop the PID file for `port` (clean shutdown, or after reclaiming it). */
131
+ export function removePidfile(port: number, baseDir?: string): void {
132
+ try {
133
+ unlinkSync(pidfilePath(port, baseDir));
134
+ } catch {
135
+ /* ignore */
136
+ }
137
+ }
138
+
139
+ /** Raw lsof/netstat PID tokens for whatever holds `port`. */
140
+ function portHolders(port: number): string[] {
141
+ if (process.platform === "win32") {
142
+ try {
143
+ const out = execFileSync("netstat", ["-ano"], { encoding: "utf-8", timeout: 5000 });
144
+ const tokens: string[] = [];
145
+ for (const line of out.split("\n")) {
146
+ if (line.includes(`:${port}`) && (line.includes("LISTENING") || line.includes("ESTABLISHED"))) {
147
+ const parts = line.trim().split(/\s+/);
148
+ const last = parts[parts.length - 1];
149
+ if (/^\d+$/.test(last)) tokens.push(last);
150
+ }
151
+ }
152
+ return tokens;
153
+ } catch {
154
+ return [];
155
+ }
156
+ }
157
+ try {
158
+ return execFileSync("lsof", ["-ti", `:${port}`], { encoding: "utf-8", timeout: 5000 })
159
+ .split(/\s+/)
160
+ .filter(Boolean);
161
+ } catch {
162
+ return [];
163
+ }
164
+ }
165
+
166
+ /** A real synchronous pause, so the OS can reclaim the port -- no subprocess. */
167
+ function sleepSync(ms: number): void {
168
+ if (ms <= 0) return;
169
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
170
+ }
171
+
172
+ /**
173
+ * Reclaim `port` ONLY from an identity-confirmed Tina4 dev server. The single
174
+ * guarded path for both the CLI (`tina4 serve`) and the runtime bind-failure
175
+ * fallback. `dev`/`noTakeover` are passed in so this stays pure and directly
176
+ * testable; callers resolve them from isDev() / noTakeoverOptedOut().
177
+ */
178
+ export function takeOverPort(
179
+ port: number,
180
+ dev: boolean,
181
+ noTakeover: boolean,
182
+ baseDir?: string,
183
+ grace = 500,
184
+ ): TakeoverResult {
185
+ const make = (status: string, killed: number[] = [], message = ""): TakeoverResult => ({
186
+ status,
187
+ port,
188
+ killed,
189
+ message,
190
+ });
191
+
192
+ if (noTakeover) {
193
+ return make(TAKEOVER_REFUSED_OPTOUT, [],
194
+ `Port ${port} is in use and takeover is disabled (TINA4_NO_TAKEOVER/--no-kill) `
195
+ + `-- free it or choose another port.`);
196
+ }
197
+ if (!dev) {
198
+ return make(TAKEOVER_REFUSED_PROD, [],
199
+ `Port ${port} is in use; takeover is disabled outside dev mode `
200
+ + `-- free it or choose another port.`);
201
+ }
202
+ if (inContainer()) return make(TAKEOVER_SKIPPED_CONTAINER);
203
+
204
+ const tokens = portHolders(port);
205
+ if (tokens.length === 0) return make(TAKEOVER_NOTHING);
206
+
207
+ const holders = selectablePids(tokens.join(" "), process.pid);
208
+ if (holders.length === 0) return make(TAKEOVER_NOTHING);
209
+
210
+ const recorded = readPidfile(port, baseDir);
211
+ const tina4Holders = recorded === null ? [] : holders.filter((pid) => pid === recorded);
212
+ if (tina4Holders.length === 0) {
213
+ return make(TAKEOVER_REFUSED_FOREIGN, [],
214
+ `Port ${port} is held by a non-Tina4 process -- free it or choose another port.`);
215
+ }
216
+
217
+ const killed: number[] = [];
218
+ for (const pid of tina4Holders) {
219
+ try {
220
+ process.kill(pid, "SIGTERM");
221
+ killed.push(pid);
222
+ } catch {
223
+ /* already gone or no permission */
224
+ }
225
+ }
226
+ if (killed.length === 0) return make(TAKEOVER_NOTHING);
227
+
228
+ removePidfile(port, baseDir);
229
+ sleepSync(grace);
230
+ return make(TAKEOVER_KILLED, killed,
231
+ `Reclaimed port ${port} from Tina4 dev server (PID: ${killed.join(", ")}).`);
232
+ }