tina4-nodejs 3.13.38 → 3.13.40

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.
@@ -67,6 +67,23 @@ interface CompiledRoute {
67
67
  template?: string;
68
68
  }
69
69
 
70
+ /**
71
+ * Thin reference to a registered WebSocket route, enabling chained modifiers
72
+ * — the WS analogue of {@link RouteRef}.
73
+ *
74
+ * Usage:
75
+ * router.websocket("/ws/secure", handler).secure();
76
+ */
77
+ export class WsRouteRef {
78
+ constructor(private route: WebSocketRouteDefinition) {}
79
+
80
+ /** Mark this WS route as requiring a valid JWT on the upgrade handshake. */
81
+ secure(): this {
82
+ this.route.authRequired = true;
83
+ return this;
84
+ }
85
+ }
86
+
70
87
  /**
71
88
  * Thin reference to a registered route, enabling chained modifiers.
72
89
  *
@@ -412,11 +429,29 @@ export class Router {
412
429
 
413
430
  /**
414
431
  * Register a WebSocket route.
432
+ *
433
+ * A WS route is PUBLIC by default (mirrors GET). It can be marked secured in
434
+ * EITHER way the HTTP routes support:
435
+ * • imperatively — `websocket(path, fn, { secured: true })`, or chain the
436
+ * returned ref: `websocket(path, fn).secure()`;
437
+ * • decorator-style — a `_secured` flag on the handler function, set in
438
+ * either order relative to registration (the ref keeps a back-reference
439
+ * to the route so a later `.secure()` / `_secured` still flips it).
440
+ *
441
+ * When secured, the upgrade handshake requires a valid JWT (Authorization
442
+ * header / `bearer` subprotocol / `?token=`) or the upgrade is rejected.
415
443
  */
416
- websocket(path: string, handler: WebSocketRouteHandler): void {
444
+ websocket(path: string, handler: WebSocketRouteHandler, options?: { secured?: boolean }): WsRouteRef {
417
445
  // Remove existing ws route with same pattern (for hot-reload)
418
446
  this.wsRoutes = this.wsRoutes.filter((r) => r.pattern !== path);
419
- this.wsRoutes.push({ pattern: path, handler });
447
+ const route: WebSocketRouteDefinition = {
448
+ pattern: path,
449
+ handler,
450
+ // Public unless explicitly secured via options OR a handler `_secured` flag.
451
+ authRequired: Boolean(options?.secured ?? handler._secured ?? false),
452
+ };
453
+ this.wsRoutes.push(route);
454
+ return new WsRouteRef(route);
420
455
  }
421
456
 
422
457
  /**
@@ -503,8 +538,21 @@ export class Router {
503
538
  /**
504
539
  * Register a WebSocket route on the default global router.
505
540
  */
506
- static websocket(path: string, handler: WebSocketRouteHandler): void {
507
- defaultRouter.websocket(path, handler);
541
+ static websocket(path: string, handler: WebSocketRouteHandler, options?: { secured?: boolean }): WsRouteRef {
542
+ return defaultRouter.websocket(path, handler, options);
543
+ }
544
+
545
+ /**
546
+ * Match a WebSocket upgrade path against routes on the default global router.
547
+ * Returns the matched route definition (with its `authRequired` flag) or null.
548
+ */
549
+ static matchWebSocket(pathname: string): WebSocketRouteDefinition | null {
550
+ return defaultRouter.matchWebSocket(pathname);
551
+ }
552
+
553
+ /** All WebSocket route definitions on the default global router. */
554
+ static getWebSocketRoutes(): WebSocketRouteDefinition[] {
555
+ return defaultRouter.getWebSocketRoutes();
508
556
  }
509
557
 
510
558
  /**
@@ -816,8 +864,8 @@ export function any(path: string, handler: RouteHandler, middlewares?: Middlewar
816
864
  return defaultRouter.any(path, handler, middlewares, meta);
817
865
  }
818
866
 
819
- export function websocket(path: string, handler: WebSocketRouteHandler): void {
820
- defaultRouter.websocket(path, handler);
867
+ export function websocket(path: string, handler: WebSocketRouteHandler, options?: { secured?: boolean }): WsRouteRef {
868
+ return defaultRouter.websocket(path, handler, options);
821
869
  }
822
870
 
823
871
  // Re-export "del" as "delete" for developer convenience (use: import { delete as del } from "@tina4/core")
@@ -20,7 +20,7 @@ import { createHealthRoutes } from "./health.js";
20
20
  import { rateLimiter } from "./rateLimiter.js";
21
21
  import { Log } from "./logger.js";
22
22
  import { DevAdmin, RequestInspector, WsTracker } from "./devAdmin.js";
23
- import { devReloadWs } from "./websocket.js";
23
+ import { devReloadWs, serveWebSocketRoute, wsRouteManager } from "./websocket.js";
24
24
  import { feedbackEnabled, injectFeedbackWidget } from "./feedback.js";
25
25
  import { I18n } from "./i18n.js";
26
26
  import { stopAllBackgroundTasks } from "./background.js";
@@ -34,6 +34,72 @@ const BUILTIN_ERROR_TEMPLATES_DIR = resolve(__dirname, "..", "templates");
34
34
  /** Built-in public directory for framework-bundled static assets. */
35
35
  const BUILTIN_PUBLIC_DIR = resolve(__dirname, "..", "public");
36
36
 
37
+ /**
38
+ * Apply pending DB migrations on startup — NON-BREAKING.
39
+ *
40
+ * When a `migrations/` folder exists (with at least one `.sql` file, excluding
41
+ * `.down.sql`) and `TINA4_AUTO_MIGRATE` is not disabled (default "true";
42
+ * false/0/no/off disable), pending migrations are applied during boot so the
43
+ * schema is current with no manual `tina4 migrate` step. A failure here is
44
+ * logged LOUD via `Log.error` and the service STILL starts — a bad migration
45
+ * must never take the backend down. (The explicit `tina4 migrate` CLI stays
46
+ * fail-fast so CI still gets a non-zero exit. Only this startup hook swallows.)
47
+ *
48
+ * Disable with `TINA4_AUTO_MIGRATE=false` — e.g. multi-instance production that
49
+ * migrates as a separate deploy step (concurrent first-apply can race).
50
+ *
51
+ * @param migrationDir - migrations directory (default "migrations", relative to base)
52
+ * @param base - project root used to resolve the migrations directory
53
+ */
54
+ export async function autoMigrateOnStartup(
55
+ migrationDir = "migrations",
56
+ base = process.cwd(),
57
+ ): Promise<void> {
58
+ const dir = resolve(base, migrationDir);
59
+
60
+ // Gate 1: a migrations/ folder with at least one .sql (non-down) file.
61
+ if (!existsSync(dir)) return;
62
+ let hasSql = false;
63
+ try {
64
+ hasSql = readdirSync(dir).some((f) => f.endsWith(".sql") && !f.endsWith(".down.sql"));
65
+ } catch {
66
+ return; // unreadable dir → nothing to do (silent)
67
+ }
68
+ if (!hasSql) return;
69
+
70
+ // Gate 2: TINA4_AUTO_MIGRATE not falsy (default "true").
71
+ const flag = process.env.TINA4_AUTO_MIGRATE;
72
+ if (flag != null && !isTruthy(flag)) {
73
+ Log.debug("TINA4_AUTO_MIGRATE is off — skipping startup migrations");
74
+ return;
75
+ }
76
+
77
+ // Gate 3: a DB adapter must be resolvable. (initDatabase() has already run by
78
+ // the time this is called from startServer.)
79
+ let orm: typeof import("../../orm/src/index.js");
80
+ try {
81
+ orm = await import("../../orm/src/index.js");
82
+ orm.getAdapter(); // throws if no adapter configured
83
+ } catch (err) {
84
+ Log.debug(`Startup migrations skipped (no database configured): ${err instanceof Error ? err.message : String(err)}`);
85
+ return;
86
+ }
87
+
88
+ // Run the EXISTING migrate runner inside try/catch — NEVER re-raise out of
89
+ // the startup hook (non-breaking).
90
+ try {
91
+ const result = await orm.migrate(undefined, { migrationsDir: dir });
92
+ if (result.applied.length > 0) {
93
+ Log.info(`Applied ${result.applied.length} pending migration(s) on startup`);
94
+ }
95
+ } catch (err) {
96
+ Log.error(
97
+ `Startup auto-migration failed: ${err instanceof Error ? err.message : String(err)} — ` +
98
+ "the service is starting anyway. Run `tina4 migrate` to retry.",
99
+ );
100
+ }
101
+ }
102
+
37
103
  /** Read version from root package.json so the banner always matches the published version. */
38
104
  function readPackageVersion(): string {
39
105
  try {
@@ -117,8 +183,13 @@ export function _checkLegacyEnvVars(): void {
117
183
  }
118
184
  lines.push(
119
185
  "",
120
- "Run `tina4 env --migrate` to rewrite your .env automatically,",
121
- "or rename manually. See https://tina4.com/release/3.12.0",
186
+ "Note: these may come from a .env file loaded by dotenv, not just",
187
+ "the runtime environment check your image / build context (a .env",
188
+ "baked into a Docker image is loaded at startup) as well as k8s/CI env.",
189
+ "",
190
+ "FIX: run `tina4 env --migrate` to rewrite your .env automatically",
191
+ "(it renames every legacy name to its TINA4_ form in place).",
192
+ "Or rename manually. See https://tina4.com/release/3.12.0",
122
193
  "Set TINA4_ALLOW_LEGACY_ENV=true to bypass during migration.",
123
194
  bar,
124
195
  "",
@@ -884,6 +955,12 @@ ${reset}
884
955
  } catch (err) {
885
956
  console.warn(`\n ORM not available (install @tina4/orm to enable):`, err);
886
957
  }
958
+
959
+ // Auto-run pending migrations on startup — AFTER initDatabase()/model sync,
960
+ // BEFORE the server listens. Non-breaking: a failure is logged and boot
961
+ // continues (the helper never throws). Gated on a migrations/ dir + the
962
+ // TINA4_AUTO_MIGRATE flag (default on) + a resolvable DB adapter.
963
+ await autoMigrateOnStartup("migrations", base);
887
964
  }
888
965
 
889
966
  // Initialize Swagger — gated on TINA4_SWAGGER_ENABLED (default: enabled
@@ -1415,31 +1492,52 @@ ${reset}
1415
1492
  // posts /__dev/api/reload to the MAIN port. Matches Python (master).
1416
1493
  const server = createServer(dispatch);
1417
1494
 
1418
- // WebSocket-primary DevReload: accept and hold /__dev_reload upgrades on the
1419
- // MAIN port (debug only) so POST /__dev/api/reload can push an instant reload.
1420
- // Mirrors Python's _register_dev_reload_ws + _ws_manager.broadcast(path=…).
1421
- // Without this the handshake 404s and the whole stack silently falls back to
1422
- // polling. Track connections in WsTracker so they appear in the dev-admin list.
1495
+ // WebSocket upgrade handling on the MAIN port. Two responsibilities:
1496
+ //
1497
+ // 1. WebSocket-primary DevReload (debug only): accept and hold /__dev_reload
1498
+ // upgrades so POST /__dev/api/reload can push an instant reload. Mirrors
1499
+ // Python's _register_dev_reload_ws + _ws_manager.broadcast(path=…).
1500
+ //
1501
+ // 2. USER WS ROUTES (always): a route registered via Router.websocket() /
1502
+ // WebSocketServer.route() is dispatched to a live open/message/close
1503
+ // lifecycle on the real connection — parity with Python/PHP/Ruby. Per-route
1504
+ // auth is enforced here on the upgrade (serveWebSocketRoute): a @secured /
1505
+ // .secure() route rejects a missing/invalid JWT before accepting; public
1506
+ // routes pass. Previously the integrated server only handled /__dev_reload,
1507
+ // so user WS routes never reached a live connection.
1508
+ //
1509
+ // Tracking goes through WsTracker so connections show in the dev-admin list.
1423
1510
  if (isDevMode()) {
1424
1511
  devReloadWs.setTracker(
1425
1512
  (remoteAddress, p) => WsTracker.add(remoteAddress, p),
1426
1513
  (id) => { WsTracker.remove(id); },
1427
1514
  );
1428
- server.on("upgrade", (req: IncomingMessage, socket: Socket, head: Buffer) => {
1429
- const upPath = (req.url ?? "/").split("?")[0];
1430
- if (upPath === "/__dev_reload") {
1431
- devReloadWs.handleUpgrade(req, socket, head);
1432
- return;
1433
- }
1434
- // Not a dev-reload upgrade — refuse cleanly rather than leaving it hanging.
1435
- try {
1436
- socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
1437
- socket.destroy();
1438
- } catch {
1439
- /* socket already gone */
1440
- }
1441
- });
1515
+ wsRouteManager.setTracker(
1516
+ (remoteAddress, p) => WsTracker.add(remoteAddress, p),
1517
+ (id) => { WsTracker.remove(id); },
1518
+ );
1442
1519
  }
1520
+ server.on("upgrade", (req: IncomingMessage, socket: Socket, head: Buffer) => {
1521
+ const upPath = (req.url ?? "/").split("?")[0];
1522
+ // Dev-reload channel (debug only).
1523
+ if (isDevMode() && upPath === "/__dev_reload") {
1524
+ devReloadWs.handleUpgrade(req, socket, head);
1525
+ return;
1526
+ }
1527
+ // User-registered WS route — enforces per-route auth, then drives the
1528
+ // open/message/close lifecycle on this connection. Returns false only when
1529
+ // no WS route matches this path.
1530
+ if (serveWebSocketRoute(req, socket, head)) {
1531
+ return;
1532
+ }
1533
+ // No dev-reload and no matching user route — refuse cleanly.
1534
+ try {
1535
+ socket.write("HTTP/1.1 404 Not Found\r\n\r\n");
1536
+ socket.destroy();
1537
+ } catch {
1538
+ /* socket already gone */
1539
+ }
1540
+ });
1443
1541
 
1444
1542
  return new Promise((resolvePromise) => {
1445
1543
  server.listen(port, host, () => {
@@ -66,8 +66,10 @@ export class MongoSessionHandler implements SessionHandler {
66
66
  ?? (process.env.TINA4_SESSION_MONGO_PORT
67
67
  ? parseInt(process.env.TINA4_SESSION_MONGO_PORT, 10)
68
68
  : 27017);
69
+ // Canonical TINA4_SESSION_MONGO_URI; TINA4_SESSION_MONGO_URL is a legacy alias.
69
70
  this.uri = config?.uri
70
71
  ?? process.env.TINA4_SESSION_MONGO_URI
72
+ ?? process.env.TINA4_SESSION_MONGO_URL
71
73
  ?? "";
72
74
  this.username = config?.username
73
75
  ?? process.env.TINA4_SESSION_MONGO_USERNAME
@@ -134,6 +134,10 @@ export interface RouteMeta {
134
134
  description?: string;
135
135
  tags?: string[];
136
136
  responses?: Record<string, { description: string }>;
137
+ /** Request-body example surfaced in the OpenAPI requestBody. */
138
+ example?: unknown;
139
+ /** Marks the operation deprecated in the spec. */
140
+ deprecated?: boolean;
137
141
  }
138
142
 
139
143
  export interface Tina4Config {
@@ -179,13 +183,28 @@ export type MiddlewareSpec = Middleware | string;
179
183
  * event — one of "open", "message", or "close".
180
184
  * data — the incoming text message (only present for "message" events).
181
185
  */
182
- export type WebSocketRouteHandler = (
186
+ export type WebSocketRouteHandler = ((
183
187
  connection: import("./websocketConnection.js").WebSocketConnection,
184
188
  event: "open" | "message" | "close",
185
189
  data: string,
186
- ) => void | Promise<void>;
190
+ ) => void | Promise<void>) & {
191
+ /**
192
+ * Decorator-style secured flag — mirrors Python's `handler._secured`. When
193
+ * truthy the WS route requires a valid JWT on the upgrade. `Router.websocket`
194
+ * reads this into the route's `authRequired`. Lets a handler be marked
195
+ * secured in EITHER order (before or after registration).
196
+ */
197
+ _secured?: boolean;
198
+ };
187
199
 
188
200
  export interface WebSocketRouteDefinition {
189
201
  pattern: string;
190
202
  handler: WebSocketRouteHandler;
203
+ /**
204
+ * True when this WS route requires a valid JWT on the upgrade. Public by
205
+ * default (mirrors GET). Set imperatively via `Router.websocket(path, fn,
206
+ * { secured: true })` / the returned `.secure()`, or by a `_secured` flag on
207
+ * the handler (decorator style).
208
+ */
209
+ authRequired?: boolean;
191
210
  }