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 +17 -17
- package/package.json +14 -5
- package/packages/cli/src/commands/migrate.ts +14 -4
- package/packages/cli/src/commands/migrateRollback.ts +12 -4
- package/packages/cli/src/commands/migrateStatus.ts +10 -4
- package/packages/core/src/__feedback/widget.js +96 -0
- package/packages/core/src/api.ts +65 -3
- package/packages/core/src/auth.ts +15 -8
- package/packages/core/src/devAdmin.ts +228 -10
- package/packages/core/src/errorOverlay.ts +41 -3
- package/packages/core/src/feedback.ts +277 -0
- package/packages/core/src/graphql.ts +99 -1
- package/packages/core/src/index.ts +15 -2
- package/packages/core/src/mcp.test.ts +301 -0
- package/packages/core/src/mcp.ts +302 -7
- package/packages/core/src/plan.ts +56 -15
- package/packages/core/src/request.ts +17 -1
- package/packages/core/src/routeDiscovery.ts +69 -1
- package/packages/core/src/router.ts +75 -16
- package/packages/core/src/server.ts +102 -3
- package/packages/core/src/service.ts +87 -0
- package/packages/core/src/static.ts +9 -2
- package/packages/core/src/test.ts +246 -0
- package/packages/core/src/types.ts +18 -0
- package/packages/orm/src/database.ts +62 -0
package/CLAUDE.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# CLAUDE.md — AI Developer Guide for tina4-nodejs (v3.
|
|
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.
|
|
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 `
|
|
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 `
|
|
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
|
-
|
|
964
|
-
|
|
963
|
+
TINA4_DATABASE_URL=sqlite:///path/to/db.sqlite
|
|
964
|
+
TINA4_DATABASE_URL=sqlite://./data/tina4.db
|
|
965
965
|
|
|
966
966
|
# PostgreSQL
|
|
967
|
-
|
|
968
|
-
|
|
967
|
+
TINA4_DATABASE_URL=postgres://localhost:5432/mydb
|
|
968
|
+
TINA4_DATABASE_URL=postgresql://localhost:5432/mydb
|
|
969
969
|
|
|
970
970
|
# MySQL
|
|
971
|
-
|
|
971
|
+
TINA4_DATABASE_URL=mysql://localhost:3306/mydb
|
|
972
972
|
|
|
973
973
|
# MSSQL / SQL Server (both schemes work)
|
|
974
|
-
|
|
975
|
-
|
|
974
|
+
TINA4_DATABASE_URL=mssql://localhost:1433/mydb
|
|
975
|
+
TINA4_DATABASE_URL=sqlserver://localhost:1433/mydb
|
|
976
976
|
|
|
977
977
|
# Firebird
|
|
978
|
-
|
|
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
|
-
|
|
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
|
-
|
|
990
|
-
|
|
991
|
-
|
|
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` > `
|
|
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.
|
|
6
|
+
"version": "3.13.1",
|
|
7
7
|
|
|
8
8
|
"type": "module",
|
|
9
|
-
"description": "Tina4 for Node.js/TypeScript
|
|
10
|
-
"keywords": [
|
|
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": ">=
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}})();
|
package/packages/core/src/api.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
82
|
+
* Validate a JWT token. Returns the decoded payload on success, `null` if
|
|
83
|
+
* invalid/expired/malformed.
|
|
83
84
|
*
|
|
84
|
-
*
|
|
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):
|
|
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
|
|
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
|
|
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
|
|
114
|
+
return null;
|
|
108
115
|
}
|
|
109
116
|
|
|
110
|
-
return
|
|
117
|
+
return payload;
|
|
111
118
|
} catch {
|
|
112
|
-
return
|
|
119
|
+
return null;
|
|
113
120
|
}
|
|
114
121
|
}
|
|
115
122
|
|