tina4-nodejs 3.8.1 → 3.8.3

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.8.1",
3
+ "version": "3.8.3",
4
4
  "type": "module",
5
5
  "description": "This is not a framework. Tina4 for Node.js/TypeScript — zero deps, 38 built-in features.",
6
6
  "keywords": ["tina4", "framework", "web", "api", "orm", "graphql", "websocket", "typescript"],
@@ -17,10 +17,10 @@ export { Router, RouteGroup, RouteRef, defaultRouter, runRouteMiddlewares } from
17
17
  export { get, post, put, patch, del, any, websocket, del as delete } from "./router.js";
18
18
  export type { RouteInfo } from "./router.js";
19
19
  export { discoverRoutes } from "./routeDiscovery.js";
20
- export { MiddlewareChain, MiddlewareRunner, cors, requestLogger, CorsMiddleware, RateLimiterMiddleware, RequestLogger } from "./middleware.js";
20
+ export { MiddlewareChain, MiddlewareRunner, cors, requestLogger, CorsMiddleware, RateLimiterMiddleware, RequestLogger, SecurityHeadersMiddleware } from "./middleware.js";
21
21
  export type { CorsConfig } from "./middleware.js";
22
22
  export { createRequest, parseBody } from "./request.js";
23
- export { createResponse } from "./response.js";
23
+ export { createResponse, errorResponse } from "./response.js";
24
24
  export { tryServeStatic } from "./static.js";
25
25
  export { loadEnv, getEnv, requireEnv, hasEnv, allEnv, resetEnv, isTruthy } from "./dotenv.js";
26
26
  export { Log } from "./logger.js";
@@ -95,4 +95,6 @@ export { RedisNpmSessionHandler } from "./sessionHandlers/redisHandler.js";
95
95
  export type { RedisNpmSessionConfig } from "./sessionHandlers/redisHandler.js";
96
96
  export { tests, assertEqual, assertThrows, assertTrue, assertFalse, runAllTests, resetTests } from "./testing.js";
97
97
  export { Container, container } from "./container.js";
98
+ export { Validator } from "./validator.js";
99
+ export type { ValidationError } from "./validator.js";
98
100
  export type { WebSocketConnection } from "./websocketConnection.js";
@@ -349,6 +349,59 @@ export class RequestLogger {
349
349
  }
350
350
  }
351
351
 
352
+ /**
353
+ * Class-based security headers middleware using the before/after convention.
354
+ * Auto-injects security headers on every response.
355
+ *
356
+ * Configuration via env vars:
357
+ * TINA4_FRAME_OPTIONS — X-Frame-Options (default: "SAMEORIGIN")
358
+ * TINA4_HSTS — Strict-Transport-Security max-age value
359
+ * (default: "" = off; set to "31536000" to enable)
360
+ * TINA4_CSP — Content-Security-Policy (default: "default-src 'self'")
361
+ * TINA4_REFERRER_POLICY — Referrer-Policy (default: "strict-origin-when-cross-origin")
362
+ * TINA4_PERMISSIONS_POLICY — Permissions-Policy (default: "camera=(), microphone=(), geolocation=()")
363
+ *
364
+ * Usage:
365
+ * Router.use(SecurityHeadersMiddleware);
366
+ */
367
+ export class SecurityHeadersMiddleware {
368
+ static beforeSecurity(req: Tina4Request, res: Tina4Response): [Tina4Request, Tina4Response] {
369
+ res.header(
370
+ "X-Frame-Options",
371
+ process.env.TINA4_FRAME_OPTIONS ?? "SAMEORIGIN",
372
+ );
373
+
374
+ res.header("X-Content-Type-Options", "nosniff");
375
+
376
+ const hsts = process.env.TINA4_HSTS ?? "";
377
+ if (hsts) {
378
+ res.header(
379
+ "Strict-Transport-Security",
380
+ `max-age=${hsts}; includeSubDomains`,
381
+ );
382
+ }
383
+
384
+ res.header(
385
+ "Content-Security-Policy",
386
+ process.env.TINA4_CSP ?? "default-src 'self'",
387
+ );
388
+
389
+ res.header(
390
+ "Referrer-Policy",
391
+ process.env.TINA4_REFERRER_POLICY ?? "strict-origin-when-cross-origin",
392
+ );
393
+
394
+ res.header("X-XSS-Protection", "0");
395
+
396
+ res.header(
397
+ "Permissions-Policy",
398
+ process.env.TINA4_PERMISSIONS_POLICY ?? "camera=(), microphone=(), geolocation=()",
399
+ );
400
+
401
+ return [req, res];
402
+ }
403
+ }
404
+
352
405
  // Built-in request logger middleware (function form — kept for backwards compat)
353
406
  export function requestLogger(): Middleware {
354
407
  return (req, res, next) => {
@@ -27,10 +27,27 @@ export function createRequest(req: IncomingMessage): Tina4Request {
27
27
  return tReq;
28
28
  }
29
29
 
30
+ /** Maximum upload size in bytes (default 10 MB). Override via TINA4_MAX_UPLOAD_SIZE env var. */
31
+ const TINA4_MAX_UPLOAD_SIZE = parseInt(process.env.TINA4_MAX_UPLOAD_SIZE ?? "10485760", 10);
32
+
33
+ export class PayloadTooLargeError extends Error {
34
+ public statusCode = 413;
35
+ constructor(actual: number, limit: number) {
36
+ super(`Request body (${actual} bytes) exceeds TINA4_MAX_UPLOAD_SIZE (${limit} bytes)`);
37
+ this.name = "PayloadTooLargeError";
38
+ }
39
+ }
40
+
30
41
  export async function parseBody(req: Tina4Request): Promise<void> {
31
42
  const method = req.method?.toUpperCase();
32
43
  if (method === "GET" || method === "HEAD" || method === "OPTIONS") return;
33
44
 
45
+ // Check content-length header against upload size limit before reading body
46
+ const declaredLength = parseInt(req.headers["content-length"] ?? "0", 10);
47
+ if (declaredLength > TINA4_MAX_UPLOAD_SIZE) {
48
+ throw new PayloadTooLargeError(declaredLength, TINA4_MAX_UPLOAD_SIZE);
49
+ }
50
+
34
51
  const contentType = req.headers["content-type"] ?? "";
35
52
  const chunks: Buffer[] = [];
36
53
 
@@ -43,6 +60,11 @@ export async function parseBody(req: Tina4Request): Promise<void> {
43
60
  const raw = Buffer.concat(chunks);
44
61
  if (raw.length === 0) return;
45
62
 
63
+ // Check actual body size against upload size limit
64
+ if (raw.length > TINA4_MAX_UPLOAD_SIZE) {
65
+ throw new PayloadTooLargeError(raw.length, TINA4_MAX_UPLOAD_SIZE);
66
+ }
67
+
46
68
  if (contentType.includes("multipart/form-data")) {
47
69
  const boundary = extractBoundary(contentType);
48
70
  if (boundary) {
@@ -128,6 +128,11 @@ export function createResponse(res: ServerResponse): Tina4Response {
128
128
  return response.cookie(name, "", { ...options, maxAge: 0, expires: new Date(0) });
129
129
  };
130
130
 
131
+ response.error = function (code: string, message: string, status?: number): Tina4Response {
132
+ const statusCode = status ?? 400;
133
+ return response.json({ error: true, code, message, status: statusCode }, statusCode);
134
+ };
135
+
131
136
  response.file = function (filePath: string, options?: { download?: boolean; contentType?: string }): Tina4Response {
132
137
  if (!fs.existsSync(filePath)) {
133
138
  res.statusCode = 404;
@@ -190,3 +195,13 @@ export function createResponse(res: ServerResponse): Tina4Response {
190
195
 
191
196
  return response;
192
197
  }
198
+
199
+ /**
200
+ * Build a standard error response envelope (standalone helper).
201
+ *
202
+ * Usage:
203
+ * return response(errorResponse("VALIDATION_FAILED", "Email is required", 400), 400);
204
+ */
205
+ export function errorResponse(code: string, message: string, status: number = 400): Record<string, unknown> {
206
+ return { error: true, code, message, status };
207
+ }
@@ -37,6 +37,7 @@ export interface Tina4ResponseMethods {
37
37
  cookie(name: string, value: string, options?: CookieOptions): Tina4Response;
38
38
  clearCookie(name: string, options?: CookieOptions): Tina4Response;
39
39
  file(path: string, options?: { download?: boolean; contentType?: string }): Tina4Response;
40
+ error(code: string, message: string, status?: number): Tina4Response;
40
41
  render(template: string, data?: Record<string, unknown>): Promise<Tina4Response>;
41
42
  template(name: string, data?: Record<string, unknown>): Promise<Tina4Response>;
42
43
  /** The underlying ServerResponse for advanced use */
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Tina4 Validator — Request body validation.
3
+ *
4
+ * Usage:
5
+ * import { Validator } from "@tina4/core";
6
+ *
7
+ * const validator = new Validator(req.body as Record<string, unknown>);
8
+ * validator.required("name", "email");
9
+ * validator.email("email");
10
+ * validator.minLength("name", 2);
11
+ * validator.maxLength("name", 100);
12
+ * validator.integer("age");
13
+ * validator.min("age", 0);
14
+ * validator.max("age", 150);
15
+ * validator.inList("role", ["admin", "user", "guest"]);
16
+ * validator.regex("phone", /^\+?[\d\s\-]+$/);
17
+ *
18
+ * if (!validator.isValid()) {
19
+ * return res.error("VALIDATION_FAILED", validator.errors()[0].message, 400);
20
+ * }
21
+ */
22
+
23
+ export interface ValidationError {
24
+ field: string;
25
+ message: string;
26
+ }
27
+
28
+ const EMAIL_REGEX = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/;
29
+
30
+ export class Validator {
31
+ private data: Record<string, unknown>;
32
+ private validationErrors: ValidationError[] = [];
33
+
34
+ constructor(data?: Record<string, unknown> | null) {
35
+ this.data = data && typeof data === "object" && !Array.isArray(data)
36
+ ? data as Record<string, unknown>
37
+ : {};
38
+ }
39
+
40
+ /** Check that one or more fields are present and non-empty. */
41
+ required(...fields: string[]): this {
42
+ for (const field of fields) {
43
+ const value = this.data[field];
44
+ if (value === undefined || value === null || (typeof value === "string" && value.trim() === "")) {
45
+ this.validationErrors.push({ field, message: `${field} is required` });
46
+ }
47
+ }
48
+ return this;
49
+ }
50
+
51
+ /** Check that a field contains a valid email address. */
52
+ email(field: string): this {
53
+ const value = this.data[field];
54
+ if (value === undefined || value === null) return this;
55
+ if (typeof value !== "string" || !EMAIL_REGEX.test(value)) {
56
+ this.validationErrors.push({ field, message: `${field} must be a valid email address` });
57
+ }
58
+ return this;
59
+ }
60
+
61
+ /** Check that a string field has at least `length` characters. */
62
+ minLength(field: string, length: number): this {
63
+ const value = this.data[field];
64
+ if (value === undefined || value === null) return this;
65
+ if (typeof value !== "string" || value.length < length) {
66
+ this.validationErrors.push({ field, message: `${field} must be at least ${length} characters` });
67
+ }
68
+ return this;
69
+ }
70
+
71
+ /** Check that a string field has at most `length` characters. */
72
+ maxLength(field: string, length: number): this {
73
+ const value = this.data[field];
74
+ if (value === undefined || value === null) return this;
75
+ if (typeof value !== "string" || value.length > length) {
76
+ this.validationErrors.push({ field, message: `${field} must be at most ${length} characters` });
77
+ }
78
+ return this;
79
+ }
80
+
81
+ /** Check that a field is an integer (or can be parsed as one). */
82
+ integer(field: string): this {
83
+ const value = this.data[field];
84
+ if (value === undefined || value === null) return this;
85
+ if (typeof value === "boolean" || !Number.isInteger(Number(value)) || String(value).trim() === "") {
86
+ this.validationErrors.push({ field, message: `${field} must be an integer` });
87
+ }
88
+ return this;
89
+ }
90
+
91
+ /** Check that a numeric field is >= `minimum`. */
92
+ min(field: string, minimum: number): this {
93
+ const value = this.data[field];
94
+ if (value === undefined || value === null) return this;
95
+ const num = Number(value);
96
+ if (isNaN(num)) return this;
97
+ if (num < minimum) {
98
+ this.validationErrors.push({ field, message: `${field} must be at least ${minimum}` });
99
+ }
100
+ return this;
101
+ }
102
+
103
+ /** Check that a numeric field is <= `maximum`. */
104
+ max(field: string, maximum: number): this {
105
+ const value = this.data[field];
106
+ if (value === undefined || value === null) return this;
107
+ const num = Number(value);
108
+ if (isNaN(num)) return this;
109
+ if (num > maximum) {
110
+ this.validationErrors.push({ field, message: `${field} must be at most ${maximum}` });
111
+ }
112
+ return this;
113
+ }
114
+
115
+ /** Check that a field's value is one of the allowed values. */
116
+ inList(field: string, allowed: unknown[]): this {
117
+ const value = this.data[field];
118
+ if (value === undefined || value === null) return this;
119
+ if (!allowed.includes(value)) {
120
+ this.validationErrors.push({ field, message: `${field} must be one of ${JSON.stringify(allowed)}` });
121
+ }
122
+ return this;
123
+ }
124
+
125
+ /** Check that a field matches a regular expression. */
126
+ regex(field: string, pattern: RegExp | string): this {
127
+ const value = this.data[field];
128
+ if (value === undefined || value === null) return this;
129
+ const re = pattern instanceof RegExp ? pattern : new RegExp(pattern);
130
+ if (typeof value !== "string" || !re.test(value)) {
131
+ this.validationErrors.push({ field, message: `${field} does not match the required format` });
132
+ }
133
+ return this;
134
+ }
135
+
136
+ /** Return the list of validation errors (empty if valid). */
137
+ errors(): ValidationError[] {
138
+ return [...this.validationErrors];
139
+ }
140
+
141
+ /** Return true if no validation errors have been recorded. */
142
+ isValid(): boolean {
143
+ return this.validationErrors.length === 0;
144
+ }
145
+ }
@@ -185,9 +185,25 @@ export function parseDatabaseUrl(url: string, username?: string, password?: stri
185
185
  * db.update("users", { name: "Bob" }, { id: 1 });
186
186
  * db.delete("users", { id: 1 });
187
187
  * db.close();
188
+ *
189
+ * Connection pooling:
190
+ * const db = await Database.create("sqlite:///data/app.db", undefined, undefined, 4);
191
+ * // 4 connections, round-robin rotation
188
192
  */
189
193
  export class Database {
190
- private adapter: DatabaseAdapter;
194
+ private adapter: DatabaseAdapter | null;
195
+
196
+ /** Connection pool — array of adapters with lazy creation */
197
+ private pool: (DatabaseAdapter | null)[] = [];
198
+
199
+ /** Pool size (0 = single connection) */
200
+ private poolSize: number = 0;
201
+
202
+ /** Round-robin index */
203
+ private poolIndex: number = 0;
204
+
205
+ /** Factory for creating new adapters (used by pool) */
206
+ private adapterFactory: (() => Promise<DatabaseAdapter>) | null = null;
191
207
 
192
208
  /**
193
209
  * Create a Database wrapping an existing adapter.
@@ -201,8 +217,33 @@ export class Database {
201
217
  /**
202
218
  * Async factory: creates a Database from a connection URL.
203
219
  * Works with all adapter types (sqlite, postgres, mysql, mssql, firebird).
220
+ *
221
+ * @param url - Connection URL
222
+ * @param username - Optional username
223
+ * @param password - Optional password
224
+ * @param pool - Number of pooled connections (0 = single, N>0 = round-robin)
204
225
  */
205
- static async create(url: string, username?: string, password?: string): Promise<Database> {
226
+ static async create(url: string, username?: string, password?: string, pool: number = 0): Promise<Database> {
227
+ if (pool > 0) {
228
+ // Pooled mode — create all adapters eagerly
229
+ const adapters: DatabaseAdapter[] = [];
230
+ for (let i = 0; i < pool; i++) {
231
+ adapters.push(await createAdapterFromUrl(url, username, password));
232
+ }
233
+
234
+ // Set the first adapter as the global default
235
+ setAdapter(adapters[0]);
236
+
237
+ const db = new Database(adapters[0]);
238
+ db.poolSize = pool;
239
+ db.pool = adapters;
240
+ db.poolIndex = 0;
241
+ db.adapter = null; // Don't use single-adapter path
242
+ db.adapterFactory = () => createAdapterFromUrl(url, username, password);
243
+ return db;
244
+ }
245
+
246
+ // Single-connection mode — current behavior
206
247
  const adapter = await createAdapterFromUrl(url, username, password);
207
248
  setAdapter(adapter);
208
249
  return new Database(adapter);
@@ -211,84 +252,119 @@ export class Database {
211
252
  /**
212
253
  * Create a Database from an environment variable.
213
254
  * @param envKey - Name of the env var holding the connection URL. Defaults to "DATABASE_URL".
255
+ * @param pool - Number of pooled connections (0 = single, N>0 = round-robin)
214
256
  */
215
- static async fromEnv(envKey = "DATABASE_URL"): Promise<Database> {
257
+ static async fromEnv(envKey = "DATABASE_URL", pool: number = 0): Promise<Database> {
216
258
  const url = process.env[envKey];
217
259
  if (!url) {
218
260
  throw new Error(`Environment variable "${envKey}" is not set.`);
219
261
  }
220
- return Database.create(url);
262
+ return Database.create(url, undefined, undefined, pool);
263
+ }
264
+
265
+ /**
266
+ * Get the next adapter — from pool (round-robin) or single connection.
267
+ */
268
+ private getNextAdapter(): DatabaseAdapter {
269
+ if (this.poolSize > 0) {
270
+ const idx = this.poolIndex;
271
+ this.poolIndex = (this.poolIndex + 1) % this.poolSize;
272
+ return this.pool[idx] as DatabaseAdapter;
273
+ }
274
+
275
+ return this.adapter!;
221
276
  }
222
277
 
223
278
  /** Get the underlying adapter (for advanced / escape-hatch usage). */
224
279
  getAdapter(): DatabaseAdapter {
225
- return this.adapter;
280
+ return this.getNextAdapter();
281
+ }
282
+
283
+ /** Get the pool size (0 = single connection mode). */
284
+ getPoolSize(): number {
285
+ return this.poolSize;
286
+ }
287
+
288
+ /** Get the number of active (created) connections in the pool. */
289
+ getActivePoolCount(): number {
290
+ if (this.poolSize === 0) return this.adapter ? 1 : 0;
291
+ return this.pool.filter(a => a !== null).length;
226
292
  }
227
293
 
228
294
  /** Query rows with optional pagination. Returns a DatabaseResult wrapper. */
229
295
  fetch(sql: string, params?: unknown[], limit?: number, offset?: number): DatabaseResult {
230
- const rows = this.adapter.fetch<Record<string, unknown>>(sql, params, limit, offset);
231
- return new DatabaseResult(rows, undefined, undefined, limit, offset, this.adapter, sql);
296
+ const adapter = this.getNextAdapter();
297
+ const rows = adapter.fetch<Record<string, unknown>>(sql, params, limit, offset);
298
+ return new DatabaseResult(rows, undefined, undefined, limit, offset, adapter, sql);
232
299
  }
233
300
 
234
301
  /** Fetch a single row or null. */
235
302
  fetchOne<T = Record<string, unknown>>(sql: string, params?: unknown[]): T | null {
236
- return this.adapter.fetchOne<T>(sql, params);
303
+ return this.getNextAdapter().fetchOne<T>(sql, params);
237
304
  }
238
305
 
239
306
  /** Execute a statement (INSERT, UPDATE, DELETE, DDL). */
240
307
  execute(sql: string, params?: unknown[]): unknown {
241
- return this.adapter.execute(sql, params);
308
+ return this.getNextAdapter().execute(sql, params);
242
309
  }
243
310
 
244
311
  /** Insert a row into a table. */
245
312
  insert(table: string, data: Record<string, unknown>): DatabaseWriteResult {
246
- return this.adapter.insert(table, data);
313
+ return this.getNextAdapter().insert(table, data);
247
314
  }
248
315
 
249
316
  /** Update rows in a table matching filter. */
250
317
  update(table: string, data: Record<string, unknown>, filter?: Record<string, unknown>): DatabaseWriteResult {
251
- return this.adapter.update(table, data, filter ?? {});
318
+ return this.getNextAdapter().update(table, data, filter ?? {});
252
319
  }
253
320
 
254
321
  /** Delete rows from a table matching filter. */
255
322
  delete(table: string, filter?: Record<string, unknown>): DatabaseWriteResult {
256
- return this.adapter.delete(table, filter ?? {});
323
+ return this.getNextAdapter().delete(table, filter ?? {});
257
324
  }
258
325
 
259
- /** Close the database connection. */
326
+ /** Close all database connections (pool or single). */
260
327
  close(): void {
261
- this.adapter.close();
328
+ if (this.poolSize > 0) {
329
+ for (let i = 0; i < this.pool.length; i++) {
330
+ if (this.pool[i] !== null) {
331
+ this.pool[i]!.close();
332
+ this.pool[i] = null;
333
+ }
334
+ }
335
+ } else if (this.adapter) {
336
+ this.adapter.close();
337
+ }
262
338
  }
263
339
 
264
340
  /** Start a transaction. */
265
341
  startTransaction(): void {
266
- this.adapter.startTransaction();
342
+ this.getNextAdapter().startTransaction();
267
343
  }
268
344
 
269
345
  /** Commit the current transaction. */
270
346
  commit(): void {
271
- this.adapter.commit();
347
+ this.getNextAdapter().commit();
272
348
  }
273
349
 
274
350
  /** Rollback the current transaction. */
275
351
  rollback(): void {
276
- this.adapter.rollback();
352
+ this.getNextAdapter().rollback();
277
353
  }
278
354
 
279
355
  /** Check if a table exists. */
280
356
  tableExists(name: string): boolean {
281
- return this.adapter.tableExists(name);
357
+ return this.getNextAdapter().tableExists(name);
282
358
  }
283
359
 
284
360
  /** List all tables in the database. */
285
361
  getTables(): string[] {
286
- return this.adapter.tables();
362
+ return this.getNextAdapter().tables();
287
363
  }
288
364
 
289
365
  /** Get the last auto-increment id. */
290
366
  getLastId(): string | number {
291
- const id = this.adapter.lastInsertId();
367
+ const id = this.getNextAdapter().lastInsertId();
292
368
  if (id === null) return 0;
293
369
  return typeof id === "bigint" ? id.toString() : id;
294
370
  }