tina4-nodejs 3.13.29 → 3.13.30

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 CHANGED
@@ -1,4 +1,4 @@
1
- # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.29)
1
+ # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.30)
2
2
 
3
3
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
4
4
 
@@ -193,7 +193,7 @@ Max upload size: `TINA4_MAX_UPLOAD_SIZE` env var (default 10MB).
193
193
  getToken(payload, secret?, expiresIn=60): string
194
194
  validToken(token, secret?): Record | null
195
195
  getPayload(token): Record | null
196
- refreshToken(token, secret?, expiresIn=60): string | null
196
+ refreshToken(token, expiresIn=60): string | null // reads SECRET from env
197
197
  hashPassword(password, salt?, iterations=260000): string // PBKDF2-SHA256, $ delimiter
198
198
  checkPassword(password, hash): boolean // timing-safe
199
199
  validateApiKey(provided, expected?): boolean // reads TINA4_API_KEY from env
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
 
5
5
 
6
- "version": "3.13.29",
6
+ "version": "3.13.30",
7
7
 
8
8
  "type": "module",
9
9
  "description": "Tina4 for Node.js/TypeScript \u2014 54 built-in features, zero dependencies",
@@ -36,17 +36,17 @@ function base64urlDecode(str: string): Buffer {
36
36
  * Algorithm is read from `process.env.TINA4_JWT_ALGORITHM` (default "HS256").
37
37
  *
38
38
  * @param payload - Claims to encode (e.g. `{ userId: 1, role: "admin" }`)
39
- * @param secretOrExpiresIn - Signing secret string, OR expiresIn number (back-compat with old 2-arg form)
40
- * @param expiresIn - Lifetime in seconds (default 3600). Only used when secret is a string.
39
+ * @param secretOrExpiresIn - Signing secret string, OR expiresIn number in MINUTES (back-compat with old 2-arg form)
40
+ * @param expiresIn - Lifetime in MINUTES (default 60). `0` ⇒ no `exp` claim (non-expiring). Only used when secret is a string.
41
41
  * @returns Signed JWT string: header.payload.signature
42
42
  */
43
43
  export function getToken(
44
44
  payload: Record<string, unknown>,
45
45
  secretOrExpiresIn?: string | number,
46
- expiresIn: number = 3600,
46
+ expiresIn: number = 60,
47
47
  algorithm?: string,
48
48
  ): string {
49
- // Back-compat: if second arg is a number, treat it as expiresIn (old 2-arg form)
49
+ // Back-compat: if second arg is a number, treat it as expiresIn in minutes (old 2-arg form)
50
50
  let resolvedSecret: string;
51
51
  let resolvedExpiresIn: number;
52
52
  if (typeof secretOrExpiresIn === "number") {
@@ -66,8 +66,9 @@ export function getToken(
66
66
  const now = Math.floor(Date.now() / 1000);
67
67
 
68
68
  const claims: Record<string, unknown> = { ...payload, iat: now };
69
+ // expiresIn is in MINUTES (parity with Python/PHP/Ruby); 0 ⇒ non-expiring.
69
70
  if (resolvedExpiresIn !== 0) {
70
- claims.exp = now + resolvedExpiresIn;
71
+ claims.exp = now + resolvedExpiresIn * 60;
71
72
  }
72
73
 
73
74
  const h = base64urlEncode(Buffer.from(JSON.stringify(header)));
@@ -173,7 +174,7 @@ function verifySignature(input: string, sig: string, secret: string, algorithm:
173
174
  *
174
175
  * @param password - Plaintext password
175
176
  * @param salt - Hex-encoded salt (auto-generated if omitted)
176
- * @param iterations - PBKDF2 iterations (default 100000)
177
+ * @param iterations - PBKDF2 iterations (default 260000)
177
178
  * @returns Format: `pbkdf2_sha256$iterations$salt$hash` (all hex-encoded)
178
179
  */
179
180
  export function hashPassword(
@@ -250,12 +251,12 @@ export function authMiddleware(secret?: string, algorithm: string = "HS256"): Mi
250
251
  * Secret is always read from `process.env.TINA4_SECRET`.
251
252
  *
252
253
  * @param token - Existing JWT to refresh
253
- * @param expiresIn - New lifetime in seconds (default 3600)
254
+ * @param expiresIn - New lifetime in MINUTES (default 60)
254
255
  * @returns New signed JWT string, or null if the input token is invalid/expired
255
256
  */
256
257
  export function refreshToken(
257
258
  token: string,
258
- expiresIn: number = 3600,
259
+ expiresIn: number = 60,
259
260
  ): string | null {
260
261
  if (!validToken(token)) return null;
261
262
 
@@ -101,7 +101,7 @@ export function createRequest(req: IncomingMessage): Tina4Request {
101
101
  return null;
102
102
  };
103
103
 
104
- tReq.param = function (key: string, defaultValue?: string): string | undefined {
104
+ tReq.param = function (key: string, defaultValue?: string | number): string | number | undefined {
105
105
  return tReq.params[key] ?? tReq.query[key] ?? defaultValue;
106
106
  };
107
107
 
@@ -12,9 +12,35 @@ export function isTrailingSlashRedirectEnabled(): boolean {
12
12
  return isTruthy(process.env.TINA4_TRAILING_SLASH_REDIRECT);
13
13
  }
14
14
 
15
+ /**
16
+ * Coerce a matched path param to its declared type. Mirrors Python/PHP/Ruby:
17
+ * `{id:int}`/`{id:integer}` arrive as a JS integer `number`, `{p:float}`/
18
+ * `{p:number}` as a float `number`, and every other type (string/alpha/alnum/
19
+ * slug/uuid/path) plus untyped `{id}` stay the decoded string. The URL regex
20
+ * already constrains what reaches here (`int` only matches `\d+`), so a cast
21
+ * should never fail — but we never throw: a NaN result falls back to the raw
22
+ * string so a malformed value can't take the router down.
23
+ */
24
+ function coerceParam(raw: string, type: string | undefined): string | number {
25
+ switch (type) {
26
+ case "int":
27
+ case "integer": {
28
+ const n = parseInt(raw, 10);
29
+ return Number.isNaN(n) ? raw : n;
30
+ }
31
+ case "float":
32
+ case "number": {
33
+ const n = parseFloat(raw);
34
+ return Number.isNaN(n) ? raw : n;
35
+ }
36
+ default:
37
+ return raw;
38
+ }
39
+ }
40
+
15
41
  interface MatchResult {
16
42
  handler: RouteHandler;
17
- params: Record<string, string>;
43
+ params: Record<string, string | number>;
18
44
  pattern: string;
19
45
  meta?: RouteMeta;
20
46
  middlewares?: Middleware[];
@@ -28,6 +54,7 @@ interface CompiledRoute {
28
54
  pattern: string;
29
55
  regex: RegExp;
30
56
  paramNames: string[];
57
+ paramTypes: string[];
31
58
  handler: RouteHandler;
32
59
  meta?: RouteMeta;
33
60
  filePath?: string;
@@ -117,7 +144,7 @@ export class Router {
117
144
  */
118
145
  addRoute(definition: RouteDefinition): RouteRef {
119
146
  const method = definition.method.toUpperCase();
120
- const { regex, paramNames } = this.compilePattern(definition.pattern);
147
+ const { regex, paramNames, paramTypes } = this.compilePattern(definition.pattern);
121
148
 
122
149
  if (!this.routes.has(method)) {
123
150
  this.routes.set(method, []);
@@ -143,6 +170,7 @@ export class Router {
143
170
  pattern: definition.pattern,
144
171
  regex,
145
172
  paramNames,
173
+ paramTypes,
146
174
  handler: definition.handler,
147
175
  meta: definition.meta,
148
176
  filePath: definition.filePath,
@@ -309,9 +337,10 @@ export class Router {
309
337
  for (const route of routes) {
310
338
  const match = route.regex.exec(path);
311
339
  if (match) {
312
- const params: Record<string, string> = {};
340
+ const params: Record<string, string | number> = {};
313
341
  for (let i = 0; i < route.paramNames.length; i++) {
314
- params[route.paramNames[i]] = decodeURIComponent(match[i + 1]);
342
+ const raw = decodeURIComponent(match[i + 1]);
343
+ params[route.paramNames[i]] = coerceParam(raw, route.paramTypes[i]);
315
344
  }
316
345
  return {
317
346
  handler: route.handler,
@@ -505,8 +534,13 @@ export class Router {
505
534
  ".*": ".+",
506
535
  };
507
536
 
508
- private compilePattern(pattern: string): { regex: RegExp; paramNames: string[] } {
537
+ private compilePattern(pattern: string): { regex: RegExp; paramNames: string[]; paramTypes: string[] } {
509
538
  const paramNames: string[] = [];
539
+ // Declared type per capture group, parallel to paramNames. Drives value
540
+ // coercion at match time (int/integer/float/number → JS number). Every
541
+ // capture below pushes a type so the two arrays stay index-aligned; only
542
+ // {name:type} carries a non-"string" type — all other forms are "string".
543
+ const paramTypes: string[] = [];
510
544
 
511
545
  // Supports {id} (primary, matches Python), [id] (file-based dirs), and :id (Express-style)
512
546
  const regexStr = pattern
@@ -519,6 +553,7 @@ export class Router {
519
553
  ) {
520
554
  const name = segment.startsWith("{") ? segment.slice(4, -1) : segment.slice(4, -1);
521
555
  paramNames.push(name);
556
+ paramTypes.push("string");
522
557
  return "(.+)";
523
558
  }
524
559
  // Dynamic param: {id}, {id:int}, {id:float}, {id:path} (matching Python/Ruby)
@@ -528,6 +563,7 @@ export class Router {
528
563
  const name = colonIdx >= 0 ? inner.slice(0, colonIdx) : inner;
529
564
  const type = colonIdx >= 0 ? inner.slice(colonIdx + 1) : "string";
530
565
  paramNames.push(name);
566
+ paramTypes.push(type);
531
567
  const table = Router.PARAM_TYPE_PATTERNS;
532
568
  if (!Object.prototype.hasOwnProperty.call(table, type)) {
533
569
  const valid = Object.keys(table).filter((k) => k !== ".*").sort().join(", ");
@@ -541,17 +577,20 @@ export class Router {
541
577
  if (segment.startsWith("[") && segment.endsWith("]")) {
542
578
  const name = segment.slice(1, -1);
543
579
  paramNames.push(name);
580
+ paramTypes.push("string");
544
581
  return "([^/]+)";
545
582
  }
546
583
  // Express-style param: :id
547
584
  if (segment.startsWith(":")) {
548
585
  const name = segment.slice(1);
549
586
  paramNames.push(name);
587
+ paramTypes.push("string");
550
588
  return "([^/]+)";
551
589
  }
552
590
  // Wildcard: * (catch-all, param key is "*")
553
591
  if (segment === "*") {
554
592
  paramNames.push("*");
593
+ paramTypes.push("string");
555
594
  return "(.+)";
556
595
  }
557
596
  return segment;
@@ -561,6 +600,7 @@ export class Router {
561
600
  return {
562
601
  regex: new RegExp(`^${regexStr}$`),
563
602
  paramNames,
603
+ paramTypes,
564
604
  };
565
605
  }
566
606
  }
@@ -18,7 +18,12 @@ export interface Tina4Session {
18
18
  }
19
19
 
20
20
  export interface Tina4Request extends IncomingMessage {
21
- params: Record<string, string>;
21
+ /**
22
+ * Path params. Typed params arrive coerced: `{id:int}`/`{id:integer}` and
23
+ * `{p:float}`/`{p:number}` are JS `number`s; every other type and untyped
24
+ * `{id}` stay `string` (parity with Python/PHP/Ruby).
25
+ */
26
+ params: Record<string, string | number>;
22
27
  query: Record<string, string>;
23
28
  /**
24
29
  * Request path only — no query string. Matches `request.path` in
@@ -49,8 +54,11 @@ export interface Tina4Request extends IncomingMessage {
49
54
  header(name: string): string | undefined;
50
55
  /** Extract the Bearer token from the Authorization header. */
51
56
  bearerToken(): string | null;
52
- /** Get a parameter by key from merged params (route + query). */
53
- param(key: string, defaultValue?: string): string | undefined;
57
+ /**
58
+ * Get a parameter by key from merged params (route + query). Route params
59
+ * may be coerced numbers (e.g. `{id:int}`); query params are always strings.
60
+ */
61
+ param(key: string, defaultValue?: string | number): string | number | undefined;
54
62
  /** Parse the request body based on content type. */
55
63
  parseBody(): Promise<void>;
56
64
  }