tina4-nodejs 3.13.98 → 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.
- package/CLAUDE.md +24 -25
- package/package.json +1 -2
- package/packages/cli/dist/bin.js +20620 -18995
- package/packages/cli/src/bin.ts +28 -71
- package/packages/cli/src/commands/migrate.ts +36 -75
- package/packages/cli/src/commands/migrateRollback.ts +10 -1
- package/packages/cli/src/commands/test.ts +92 -21
- package/packages/core/dist/index.js +20459 -18815
- package/packages/core/public/js/tina4-dev-admin.min.js +23 -19
- package/packages/core/src/ai.ts +28 -12
- package/packages/core/src/api.ts +13 -5
- package/packages/core/src/background.ts +9 -3
- package/packages/core/src/devAdmin.ts +135 -20
- package/packages/core/src/dispatchPipeline.ts +185 -1
- package/packages/core/src/docs.ts +33 -5
- package/packages/core/src/env.ts +1 -1
- package/packages/core/src/errorOverlay.ts +39 -48
- package/packages/core/src/fakeData.ts +15 -0
- package/packages/core/src/index.ts +17 -6
- package/packages/core/src/logger.ts +892 -572
- package/packages/core/src/mcp.ts +9 -1
- package/packages/core/src/messenger.ts +31 -4
- package/packages/core/src/middleware.ts +169 -43
- package/packages/core/src/portTakeover.ts +232 -0
- package/packages/core/src/request.ts +57 -8
- package/packages/core/src/response.ts +67 -0
- package/packages/core/src/router.ts +35 -7
- package/packages/core/src/server.ts +450 -190
- package/packages/core/src/static.ts +81 -12
- package/packages/core/src/testClient.ts +126 -137
- package/packages/core/src/testing.ts +16 -12
- package/packages/core/src/types.ts +21 -9
- package/packages/core/src/version.ts +66 -0
- package/packages/core/src/websocket.ts +2 -2
- package/packages/core/src/websocketBackplane.ts +2 -2
- package/packages/frond/dist/index.js +31 -13
- package/packages/frond/src/engine.ts +39 -7
- package/packages/orm/dist/index.js +10879 -9258
- package/packages/orm/src/adapters/firebird.ts +200 -27
- package/packages/orm/src/adapters/mongodb.ts +160 -10
- package/packages/orm/src/adapters/mssql.ts +38 -11
- package/packages/orm/src/adapters/mysql.ts +24 -1
- package/packages/orm/src/adapters/odbc.ts +127 -29
- package/packages/orm/src/adapters/postgres.ts +18 -0
- package/packages/orm/src/adapters/sqlite.ts +93 -14
- package/packages/orm/src/autoCrud.ts +72 -8
- package/packages/orm/src/baseModel.ts +323 -71
- package/packages/orm/src/cachedDatabase.ts +48 -1
- package/packages/orm/src/database.ts +162 -59
- package/packages/orm/src/fakeData.ts +6 -2
- package/packages/orm/src/index.ts +4 -1
- package/packages/orm/src/migration.ts +95 -52
- package/packages/orm/src/query.ts +16 -4
- package/packages/orm/src/seeder.ts +43 -25
- package/packages/orm/src/sqlTranslator.ts +104 -19
- package/packages/orm/src/types.ts +97 -21
- package/packages/orm/src/validation.ts +5 -1
- package/packages/swagger/dist/index.js +3 -2
- package/packages/swagger/src/generator.ts +19 -4
- package/packages/swagger/src/ui.ts +6 -4
- package/types/cli/src/bin.d.ts +0 -22
- package/types/core/src/api.d.ts +11 -4
- package/types/core/src/background.d.ts +5 -2
- package/types/core/src/devAdmin.d.ts +35 -0
- package/types/core/src/dispatchPipeline.d.ts +41 -1
- package/types/core/src/errorOverlay.d.ts +13 -13
- package/types/core/src/index.d.ts +9 -6
- package/types/core/src/logger.d.ts +111 -185
- package/types/core/src/middleware.d.ts +40 -5
- package/types/core/src/portTakeover.d.ts +50 -0
- package/types/core/src/request.d.ts +15 -0
- package/types/core/src/response.d.ts +29 -0
- package/types/core/src/server.d.ts +92 -0
- package/types/core/src/testClient.d.ts +29 -3
- package/types/core/src/testing.d.ts +16 -12
- package/types/core/src/types.d.ts +21 -9
- package/types/core/src/version.d.ts +11 -0
- package/types/core/src/websocketBackplane.d.ts +1 -1
- package/types/frond/src/engine.d.ts +10 -0
- package/types/orm/src/adapters/firebird.d.ts +61 -2
- package/types/orm/src/adapters/mongodb.d.ts +20 -0
- package/types/orm/src/adapters/mssql.d.ts +11 -0
- package/types/orm/src/adapters/mysql.d.ts +11 -0
- package/types/orm/src/adapters/odbc.d.ts +35 -4
- package/types/orm/src/adapters/postgres.d.ts +11 -0
- package/types/orm/src/adapters/sqlite.d.ts +23 -4
- package/types/orm/src/baseModel.d.ts +45 -25
- package/types/orm/src/cachedDatabase.d.ts +27 -1
- package/types/orm/src/database.d.ts +56 -6
- package/types/orm/src/index.d.ts +3 -2
- package/types/orm/src/migration.d.ts +23 -5
- package/types/orm/src/query.d.ts +3 -0
- package/types/orm/src/seeder.d.ts +15 -2
- package/types/orm/src/sqlTranslator.d.ts +17 -4
- package/types/orm/src/types.d.ts +75 -16
- package/packages/core/src/errorOverlay.test.ts +0 -122
package/packages/core/src/mcp.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
|
115
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
859
|
-
*
|
|
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
|
|
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
|
-
* -
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|