tina4-nodejs 3.12.10 → 3.13.1

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,10 +1,10 @@
1
- # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.12.10)
1
+ # CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.13.1)
2
2
 
3
3
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
4
4
 
5
5
  ## What This Project Is
6
6
 
7
- Tina4 for Node.js/TypeScript v3.12.10 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
7
+ Tina4 for Node.js/TypeScript v3.13.1 — The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
8
8
 
9
9
  The philosophy: zero ceremony, batteries included, file system as source of truth.
10
10
 
@@ -548,7 +548,7 @@ r.group("/api/v1", (g) => {
548
548
 
549
549
  ## Module: Database (`packages/orm/src/database.ts`)
550
550
 
551
- Full Database API. The same instance covers all five drivers (sqlite, postgres, mysql, mssql, firebird) — pick the driver via `DATABASE_URL` or pass a `DatabaseConfig` to `initDatabase()`.
551
+ Full Database API. The same instance covers all five drivers (sqlite, postgres, mysql, mssql, firebird) — pick the driver via `TINA4_DATABASE_URL` or pass a `DatabaseConfig` to `initDatabase()`.
552
552
 
553
553
  ```typescript
554
554
  import { initDatabase, Database, DatabaseResult } from "@tina4/orm";
@@ -956,26 +956,26 @@ import { Router } from "./router.js"; // .js even though the file is .ts
956
956
  ## Database Configuration
957
957
 
958
958
  ### Connection string format
959
- Set `DATABASE_URL` in your `.env` file using `driver://host:port/database` format:
959
+ Set `TINA4_DATABASE_URL` in your `.env` file using `driver://host:port/database` format:
960
960
 
961
961
  ```bash
962
962
  # SQLite (default if nothing configured)
963
- DATABASE_URL=sqlite:///path/to/db.sqlite
964
- DATABASE_URL=sqlite://./data/tina4.db
963
+ TINA4_DATABASE_URL=sqlite:///path/to/db.sqlite
964
+ TINA4_DATABASE_URL=sqlite://./data/tina4.db
965
965
 
966
966
  # PostgreSQL
967
- DATABASE_URL=postgres://localhost:5432/mydb
968
- DATABASE_URL=postgresql://localhost:5432/mydb
967
+ TINA4_DATABASE_URL=postgres://localhost:5432/mydb
968
+ TINA4_DATABASE_URL=postgresql://localhost:5432/mydb
969
969
 
970
970
  # MySQL
971
- DATABASE_URL=mysql://localhost:3306/mydb
971
+ TINA4_DATABASE_URL=mysql://localhost:3306/mydb
972
972
 
973
973
  # MSSQL / SQL Server (both schemes work)
974
- DATABASE_URL=mssql://localhost:1433/mydb
975
- DATABASE_URL=sqlserver://localhost:1433/mydb
974
+ TINA4_DATABASE_URL=mssql://localhost:1433/mydb
975
+ TINA4_DATABASE_URL=sqlserver://localhost:1433/mydb
976
976
 
977
977
  # Firebird
978
- DATABASE_URL=firebird://localhost:3050/mydb
978
+ TINA4_DATABASE_URL=firebird://localhost:3050/mydb
979
979
  ```
980
980
 
981
981
  ### Credentials
@@ -983,15 +983,15 @@ Credentials can be embedded in the URL or provided separately:
983
983
 
984
984
  ```bash
985
985
  # In the URL
986
- DATABASE_URL=postgres://user:pass@localhost:5432/mydb
986
+ TINA4_DATABASE_URL=postgres://user:pass@localhost:5432/mydb
987
987
 
988
988
  # Or as separate env vars (merged when URL has no credentials)
989
- DATABASE_URL=postgres://localhost:5432/mydb
990
- DATABASE_USERNAME=myuser
991
- DATABASE_PASSWORD=mypass
989
+ TINA4_DATABASE_URL=postgres://localhost:5432/mydb
990
+ TINA4_DATABASE_USERNAME=myuser
991
+ TINA4_DATABASE_PASSWORD=mypass
992
992
  ```
993
993
 
994
- Credential priority: `config.user` > `config.username` > `DATABASE_USERNAME` env var.
994
+ Credential priority: `config.user` > `config.username` > `TINA4_DATABASE_USERNAME` env var.
995
995
 
996
996
  ### Programmatic configuration
997
997
  ```typescript
package/package.json CHANGED
@@ -3,11 +3,20 @@
3
3
 
4
4
 
5
5
 
6
- "version": "3.12.10",
6
+ "version": "3.13.1",
7
7
 
8
8
  "type": "module",
9
- "description": "Tina4 for Node.js/TypeScript 54 built-in features, zero dependencies",
10
- "keywords": ["tina4", "framework", "web", "api", "orm", "graphql", "websocket", "typescript"],
9
+ "description": "Tina4 for Node.js/TypeScript \u2014 54 built-in features, zero dependencies",
10
+ "keywords": [
11
+ "tina4",
12
+ "framework",
13
+ "web",
14
+ "api",
15
+ "orm",
16
+ "graphql",
17
+ "websocket",
18
+ "typescript"
19
+ ],
11
20
  "homepage": "https://tina4.com/nodejs",
12
21
  "repository": {
13
22
  "type": "git",
@@ -51,7 +60,7 @@
51
60
  "test": "tsx test/run-all.ts"
52
61
  },
53
62
  "engines": {
54
- "node": ">=20.0.0"
63
+ "node": ">=22.0.0"
55
64
  },
56
65
  "dependencies": {},
57
66
  "devDependencies": {
@@ -59,4 +68,4 @@
59
68
  "tsx": "^4.19.0",
60
69
  "esbuild": "^0.24.0"
61
70
  }
62
- }
71
+ }
@@ -10,8 +10,14 @@
10
10
  */
11
11
  import { existsSync, readdirSync, readFileSync } from "node:fs";
12
12
  import { join, resolve } from "node:path";
13
+ import { loadEnv } from "../../../core/src/dotenv.js";
13
14
 
14
15
  export async function runMigrations(migrationDir?: string): Promise<void> {
16
+ // Load .env before initialising the DB so DATABASE_URL/TINA4_DATABASE_URL
17
+ // from the project's .env is visible. Without this the migrate command
18
+ // falls back to ./data/tina4.db regardless of what the project configured.
19
+ loadEnv();
20
+
15
21
  const dir = resolve(migrationDir ?? "migrations");
16
22
 
17
23
  if (!existsSync(dir)) {
@@ -40,11 +46,15 @@ export async function runMigrations(migrationDir?: string): Promise<void> {
40
46
  process.exit(1);
41
47
  }
42
48
 
43
- // Ensure database is initialised (uses DATABASE_URL or defaults to sqlite)
49
+ // Ensure database is initialised (uses TINA4_DATABASE_URL/DATABASE_URL or
50
+ // defaults to sqlite). initDatabase() is async — MUST be awaited, otherwise
51
+ // setAdapter() has not run by the time ensureMigrationTable() asks for the
52
+ // adapter and the whole CLI crashes with "No database adapter configured."
44
53
  try {
45
- initDatabase();
46
- } catch {
47
- // Adapter may already be set ignore
54
+ await initDatabase();
55
+ } catch (err) {
56
+ console.error(` Error initialising database: ${err instanceof Error ? err.message : String(err)}`);
57
+ process.exit(1);
48
58
  }
49
59
 
50
60
  ensureMigrationTable();
@@ -9,8 +9,13 @@
9
9
  * tina4 migrate:rollback ./path/to/migrations
10
10
  */
11
11
  import { resolve } from "node:path";
12
+ import { loadEnv } from "../../../core/src/dotenv.js";
12
13
 
13
14
  export async function migrateRollback(migrationDir?: string): Promise<void> {
15
+ // .env must load before initDatabase() — otherwise DATABASE_URL from the
16
+ // project's .env is invisible and we silently fall back to ./data/tina4.db.
17
+ loadEnv();
18
+
14
19
  const dir = resolve(migrationDir ?? "migrations");
15
20
 
16
21
  let initDatabase: typeof import("../../../orm/src/index.js").initDatabase;
@@ -29,11 +34,14 @@ export async function migrateRollback(migrationDir?: string): Promise<void> {
29
34
  process.exit(1);
30
35
  }
31
36
 
32
- // Ensure database is initialised
37
+ // Ensure database is initialised — MUST await; initDatabase() is async
38
+ // and calls setAdapter() inside the promise. Without await, the next call
39
+ // to getAdapter() throws "No database adapter configured."
33
40
  try {
34
- initDatabase();
35
- } catch {
36
- // Adapter may already be set ignore
41
+ await initDatabase();
42
+ } catch (err) {
43
+ console.error(` Error initialising database: ${err instanceof Error ? err.message : String(err)}`);
44
+ process.exit(1);
37
45
  }
38
46
 
39
47
  ensureMigrationTable();
@@ -6,8 +6,12 @@
6
6
  * tina4 migrate:status ./path/to/migrations
7
7
  */
8
8
  import { resolve } from "node:path";
9
+ import { loadEnv } from "../../../core/src/dotenv.js";
9
10
 
10
11
  export async function migrateStatus(migrationDir?: string): Promise<void> {
12
+ // .env must load before initDatabase() so the project's DATABASE_URL is seen.
13
+ loadEnv();
14
+
11
15
  const dir = resolve(migrationDir ?? "migrations");
12
16
 
13
17
  let initDatabase: typeof import("../../../orm/src/index.js").initDatabase;
@@ -24,11 +28,13 @@ export async function migrateStatus(migrationDir?: string): Promise<void> {
24
28
  process.exit(1);
25
29
  }
26
30
 
27
- // Ensure database is initialised
31
+ // Ensure database is initialised — MUST await; initDatabase() is async
32
+ // and calls setAdapter() inside. Without await, getAdapter() throws.
28
33
  try {
29
- initDatabase();
30
- } catch {
31
- // Adapter may already be set ignore
34
+ await initDatabase();
35
+ } catch (err) {
36
+ console.error(` Error initialising database: ${err instanceof Error ? err.message : String(err)}`);
37
+ process.exit(1);
32
38
  }
33
39
 
34
40
  ensureMigrationTable();
@@ -0,0 +1,96 @@
1
+ (function(){"use strict";(()=>{try{const t=window.location.pathname||"";return t.startsWith("/__dev")||t.startsWith("/__feedback")}catch{return!1}})()?console.info("tina4-feedback-widget: skipping on developer path"):window.__tina4FeedbackLoaded?console.warn("tina4-feedback-widget already loaded; skipping"):(window.__tina4FeedbackLoaded=!0,b());function b(){const c=(getComputedStyle(document.documentElement).getPropertyValue("--primary")||"").trim()||"#3b82f6";h(c);const l=m();document.body.appendChild(l);let e=null,u;const r=[];l.addEventListener("click",()=>{if(e){e.remove(),e=null,l.style.display="";return}e=g(),document.body.appendChild(e),l.style.display="none",setTimeout(()=>e?.querySelector("textarea")?.focus(),0)});function g(){const o=document.createElement("div");o.className="tina4-fb-modal",o.innerHTML=`
2
+ <div class="tina4-fb-head">
3
+ <span class="tina4-fb-title">Tell us what's not working</span>
4
+ <button type="button" class="tina4-fb-close" aria-label="Close">×</button>
5
+ </div>
6
+ <div class="tina4-fb-context">
7
+ <span>📍 ${p(location.pathname+location.search)}</span>
8
+ <span>📐 ${window.innerWidth}×${window.innerHeight}</span>
9
+ </div>
10
+ <div class="tina4-fb-chat" role="log"></div>
11
+ <form class="tina4-fb-form">
12
+ <textarea
13
+ rows="3"
14
+ placeholder="What's hard to use here? Be specific — which field, which button, what you expected."
15
+ aria-label="Feedback message"
16
+ ></textarea>
17
+ <button type="submit" class="tina4-fb-send">Send</button>
18
+ </form>
19
+ `,o.querySelector(".tina4-fb-close")?.addEventListener("click",()=>{o.remove(),e=null,l.style.display=""});const a=o.querySelector("form");return a.addEventListener("submit",n=>{n.preventDefault();const i=a.querySelector("textarea"),f=i.value.trim();f&&(i.value="",x(f))}),s(o),o}function s(o){const a=o.querySelector(".tina4-fb-chat");if(a){if(!r.length){a.innerHTML=`<div class="tina4-fb-hint">Your feedback lands directly with the team — no email loop. We'll ask a quick follow-up if we need to.</div>`;return}a.innerHTML=r.map(n=>`<div class="tina4-fb-msg ${n.role==="user"?"tina4-fb-user":"tina4-fb-ai"}">${p(n.text)}</div>`).join(""),a.scrollTop=a.scrollHeight}}async function x(o){if(!e)return;r.push({role:"user",text:o}),s(e),d(e,!0);const a={message:o,context:{url:location.pathname+location.search,viewport:`${window.innerWidth}x${window.innerHeight}`,ua:navigator.userAgent},conversation_id:u};let n;try{const i=await fetch("/__feedback/api/turn",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)});if(n=await i.json(),!i.ok){const f=n?.error||`HTTP ${i.status}`;r.push({role:"ai",text:`Couldn't send: ${f}`}),s(e),d(e,!1);return}}catch(i){r.push({role:"ai",text:`Network issue: ${i?.message||i}`}),s(e),d(e,!1);return}if("ask"in n)u=n.conversation_id,r.push({role:"ai",text:n.ask}),s(e),d(e,!1),e?.querySelector("textarea")?.focus();else if("final"in n)r.push({role:"ai",text:`Thanks — filed as: "${n.final.title}". The team will take it from here.`}),s(e),d(e,!1),u=void 0,r.length=0,setTimeout(()=>{e?.remove(),e=null,l.style.display=""},4500);else{const i=n?.error||"unexpected response";r.push({role:"ai",text:`Issue: ${i}`}),s(e),d(e,!1)}}function d(o,a){const n=o.querySelector(".tina4-fb-send"),i=o.querySelector("textarea");n&&(n.disabled=a,n.textContent=a?"Sending…":"Send"),i&&(i.disabled=a)}}function m(){const t=document.createElement("button");return t.type="button",t.className="tina4-fb-btn",t.setAttribute("aria-label","Send feedback"),t.innerHTML="💬",t.title="Tell us what's not working",t}function h(t){const c=document.createElement("style");c.id="tina4-fb-styles",c.textContent=`
20
+ .tina4-fb-btn {
21
+ position: fixed; bottom: 1.25rem; right: 1.25rem;
22
+ width: 48px; height: 48px; border-radius: 50%; border: none;
23
+ background: ${t}; color: white; font-size: 1.4rem;
24
+ box-shadow: 0 4px 12px rgba(0,0,0,0.18); cursor: pointer;
25
+ z-index: 2147483646; transition: transform 0.15s, box-shadow 0.15s;
26
+ display: flex; align-items: center; justify-content: center;
27
+ line-height: 1; padding: 0;
28
+ }
29
+ .tina4-fb-btn:hover { transform: scale(1.06); box-shadow: 0 6px 16px rgba(0,0,0,0.22); }
30
+ .tina4-fb-btn:active { transform: scale(0.96); }
31
+ .tina4-fb-modal {
32
+ position: fixed; bottom: 5rem; right: 1.25rem;
33
+ width: 340px; max-height: 480px; display: flex; flex-direction: column;
34
+ background: #1e1e2e; color: #cdd6f4;
35
+ border: 1px solid #313244; border-radius: 8px;
36
+ box-shadow: 0 8px 32px rgba(0,0,0,0.35);
37
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
38
+ font-size: 0.85rem; z-index: 2147483647;
39
+ animation: tina4-fb-in 0.18s ease-out;
40
+ }
41
+ @keyframes tina4-fb-in {
42
+ from { opacity: 0; transform: translateY(8px); }
43
+ to { opacity: 1; transform: translateY(0); }
44
+ }
45
+ .tina4-fb-head {
46
+ display: flex; align-items: center; justify-content: space-between;
47
+ padding: 0.6rem 0.8rem; border-bottom: 1px solid #313244;
48
+ }
49
+ .tina4-fb-title { font-weight: 600; font-size: 0.9rem; }
50
+ .tina4-fb-close {
51
+ background: transparent; border: none; color: #9399b2;
52
+ font-size: 1.4rem; line-height: 1; cursor: pointer; padding: 0 0.2rem;
53
+ }
54
+ .tina4-fb-close:hover { color: #cdd6f4; }
55
+ .tina4-fb-context {
56
+ display: flex; gap: 0.6rem; padding: 0.4rem 0.8rem;
57
+ font-size: 0.7rem; color: #9399b2;
58
+ border-bottom: 1px solid #313244;
59
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
60
+ }
61
+ .tina4-fb-chat {
62
+ flex: 1; overflow-y: auto; padding: 0.5rem 0.8rem;
63
+ display: flex; flex-direction: column; gap: 0.4rem;
64
+ min-height: 80px; max-height: 280px;
65
+ }
66
+ .tina4-fb-hint {
67
+ font-size: 0.75rem; color: #9399b2; line-height: 1.4; padding: 0.3rem 0;
68
+ }
69
+ .tina4-fb-msg {
70
+ padding: 0.4rem 0.6rem; border-radius: 6px;
71
+ max-width: 85%; word-wrap: break-word; line-height: 1.35;
72
+ }
73
+ .tina4-fb-user { align-self: flex-end; background: ${t}; color: white; }
74
+ .tina4-fb-ai { align-self: flex-start; background: #313244; }
75
+ .tina4-fb-form {
76
+ display: flex; flex-direction: column; gap: 0.4rem;
77
+ padding: 0.5rem 0.8rem 0.8rem; border-top: 1px solid #313244;
78
+ }
79
+ .tina4-fb-form textarea {
80
+ width: 100%; box-sizing: border-box; resize: vertical;
81
+ min-height: 60px; font-family: inherit; font-size: 0.82rem;
82
+ padding: 0.4rem 0.5rem; border: 1px solid #313244;
83
+ background: #11111b; color: #cdd6f4; border-radius: 4px;
84
+ line-height: 1.3;
85
+ }
86
+ .tina4-fb-form textarea:focus {
87
+ outline: none; border-color: ${t};
88
+ }
89
+ .tina4-fb-send {
90
+ align-self: flex-end; padding: 0.35rem 0.9rem;
91
+ background: ${t}; color: white; border: none; border-radius: 4px;
92
+ font-size: 0.8rem; font-weight: 500; cursor: pointer;
93
+ }
94
+ .tina4-fb-send:disabled { opacity: 0.55; cursor: wait; }
95
+ .tina4-fb-send:hover:not(:disabled) { filter: brightness(1.1); }
96
+ `,document.head.appendChild(c)}function p(t){return t.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}})();
@@ -18,6 +18,23 @@ export interface ApiResult {
18
18
  error: string | null;
19
19
  }
20
20
 
21
+ /**
22
+ * Constructor options for {@link Api}. Used as the second argument to
23
+ * `new Api(url, { ... })` — cross-framework parity with Python
24
+ * `Api(bearer_token=, ...)` kwargs added in 3.13.x.
25
+ */
26
+ export interface ApiOptions {
27
+ authHeader?: string;
28
+ timeout?: number;
29
+ ignoreSsl?: boolean;
30
+ /** Positive form of ignoreSsl — `verifySsl: false` disables verification. */
31
+ verifySsl?: boolean;
32
+ bearerToken?: string;
33
+ username?: string;
34
+ password?: string;
35
+ headers?: Record<string, string>;
36
+ }
37
+
21
38
  export class Api {
22
39
  private baseUrl: string;
23
40
  private headers: Record<string, string>;
@@ -25,11 +42,56 @@ export class Api {
25
42
  private authHeader: string;
26
43
  private ignoreSsl: boolean;
27
44
 
28
- constructor(baseUrl: string = "", authHeader: string = "", timeout: number = 30) {
45
+ /**
46
+ * Construct an Api client.
47
+ *
48
+ * Two construction styles supported:
49
+ *
50
+ * // Legacy positional form
51
+ * new Api("https://api.example.com", "Bearer token", 30);
52
+ *
53
+ * // 3.13.1: ergonomic options bag (recommended) — cross-framework
54
+ * // parity with Python tina4_python.api.Api kwargs.
55
+ * new Api("https://api.example.com", { bearerToken: "sk-abc" });
56
+ * new Api("https://api.example.com", { username: "u", password: "p" });
57
+ * new Api("https://api.example.com", { headers: { "X-Tenant": "acme" } });
58
+ * new Api("https://self-signed.local", { verifySsl: false });
59
+ *
60
+ * Bearer wins over basic-auth when both passed. `verifySsl: false` is
61
+ * the positive form of `ignoreSsl: true`; `ignoreSsl` wins when both
62
+ * supplied for backward compatibility.
63
+ */
64
+ constructor(
65
+ baseUrl: string = "",
66
+ authHeaderOrOptions: string | ApiOptions = "",
67
+ timeout: number = 30
68
+ ) {
29
69
  this.baseUrl = baseUrl.replace(/\/+$/, "");
30
- this.authHeader = authHeader;
31
- this.timeout = timeout;
32
70
  this.headers = {};
71
+
72
+ // Options-bag form — second arg is an object literal
73
+ if (typeof authHeaderOrOptions === "object" && authHeaderOrOptions !== null) {
74
+ const opts = authHeaderOrOptions;
75
+ this.authHeader = opts.authHeader ?? "";
76
+ this.timeout = opts.timeout ?? timeout;
77
+ this.ignoreSsl = (opts.ignoreSsl ?? false) || (opts.verifySsl === false);
78
+
79
+ // Bearer wins over basic-auth when both are passed
80
+ if (opts.bearerToken != null) {
81
+ this.setBearerToken(opts.bearerToken);
82
+ } else if (opts.username != null && opts.password != null) {
83
+ this.setBasicAuth(opts.username, opts.password);
84
+ }
85
+
86
+ if (opts.headers) {
87
+ this.addHeaders(opts.headers);
88
+ }
89
+ return;
90
+ }
91
+
92
+ // Legacy positional form
93
+ this.authHeader = authHeaderOrOptions;
94
+ this.timeout = timeout;
33
95
  this.ignoreSsl = false;
34
96
  }
35
97
 
@@ -79,12 +79,19 @@ export function getToken(
79
79
  }
80
80
 
81
81
  /**
82
- * Validate a JWT token and return the decoded payload, or false if invalid/expired.
82
+ * Validate a JWT token. Returns the decoded payload on success, `null` if
83
+ * invalid/expired/malformed.
83
84
  *
84
- * Secret is always read from `process.env.TINA4_SECRET`.
85
+ * 3.13.0 return type changed from `boolean` to `Record<string, unknown> | null`.
86
+ * Matches the convention used by `jsonwebtoken` and the Python / PHP / Ruby
87
+ * Auth.validToken signatures shipped at the same time. Legacy
88
+ * `if (validToken(t))` patterns keep working because a non-null object is
89
+ * truthy and null is falsy.
90
+ *
91
+ * Secret is read from `process.env.TINA4_SECRET` when not passed explicitly.
85
92
  * Algorithm is read from `process.env.TINA4_JWT_ALGORITHM` (default "HS256").
86
93
  */
87
- export function validToken(token: string, secret?: string, algorithm?: string): boolean {
94
+ export function validToken(token: string, secret?: string, algorithm?: string): Record<string, unknown> | null {
88
95
  const resolvedSecret = secret ?? process.env.TINA4_SECRET ?? "";
89
96
  if (!resolvedSecret) {
90
97
  console.warn("Auth: TINA4_SECRET not set in .env — using blank secret (insecure)");
@@ -92,24 +99,24 @@ export function validToken(token: string, secret?: string, algorithm?: string):
92
99
  const resolvedAlgorithm = algorithm ?? process.env.TINA4_JWT_ALGORITHM ?? "HS256";
93
100
  try {
94
101
  const parts = token.split(".");
95
- if (parts.length !== 3) return false;
102
+ if (parts.length !== 3) return null;
96
103
 
97
104
  const [h, p, sig] = parts;
98
105
  const signingInput = `${h}.${p}`;
99
106
 
100
107
  if (!verifySignature(signingInput, sig, resolvedSecret, resolvedAlgorithm)) {
101
- return false;
108
+ return null;
102
109
  }
103
110
 
104
111
  const payload = JSON.parse(base64urlDecode(p).toString()) as Record<string, unknown>;
105
112
 
106
113
  if (typeof payload.exp === "number" && Date.now() / 1000 > payload.exp) {
107
- return false;
114
+ return null;
108
115
  }
109
116
 
110
- return true;
117
+ return payload;
111
118
  } catch {
112
- return false;
119
+ return null;
113
120
  }
114
121
  }
115
122