tina4-nodejs 3.13.29 → 3.13.31
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.
|
|
1
|
+
# CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.31)
|
|
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,
|
|
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
|
@@ -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
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
53
|
-
|
|
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
|
}
|