tina4-nodejs 3.12.10 → 3.13.0
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 +13 -4
- 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/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/index.ts +14 -1
- 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/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 +14 -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.0)
|
|
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.0 — 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.0",
|
|
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",
|
|
@@ -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,"'")}})();
|
|
@@ -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
|
|
|
@@ -18,6 +18,7 @@ import type { RouteHandler } from "./types.js";
|
|
|
18
18
|
import { DevMailbox } from "./devMailbox.js";
|
|
19
19
|
import { isTruthy } from "./dotenv.js";
|
|
20
20
|
import { quickMetrics, fullAnalysis, fileDetail } from "./metrics.js";
|
|
21
|
+
import { registerFeedbackRoutes } from "./feedback.js";
|
|
21
22
|
|
|
22
23
|
const cpuCount = osCpus().length;
|
|
23
24
|
|
|
@@ -433,6 +434,12 @@ export class DevAdmin {
|
|
|
433
434
|
// Register error handlers to feed the ErrorTracker
|
|
434
435
|
ErrorTracker.register();
|
|
435
436
|
|
|
437
|
+
// Customer feedback widget routes — gated at request time by
|
|
438
|
+
// TINA4_ENABLE_FEEDBACK + TINA4_FEEDBACK_WHITELIST. The handlers
|
|
439
|
+
// themselves are always registered (so toggling env vars doesn't
|
|
440
|
+
// require a server restart) but each request re-checks the gate.
|
|
441
|
+
registerFeedbackRoutes(router);
|
|
442
|
+
|
|
436
443
|
const routes: Array<{ method: string; pattern: string; handler: RouteHandler }> = [
|
|
437
444
|
// Dashboard
|
|
438
445
|
{ method: "GET", pattern: "/__dev", handler: handleDashboard },
|
|
@@ -478,8 +485,18 @@ export class DevAdmin {
|
|
|
478
485
|
{ method: "POST", pattern: "/__dev/api/websockets/disconnect", handler: handleWebsocketsDisconnect },
|
|
479
486
|
// Tools
|
|
480
487
|
{ method: "POST", pattern: "/__dev/api/tool", handler: handleTool },
|
|
481
|
-
// Chat
|
|
488
|
+
// Chat — proxies to Rust agent /chat (SSE passthrough). Forwards
|
|
489
|
+
// active_file and any other body keys verbatim. See proxyToSupervisor.
|
|
482
490
|
{ method: "POST", pattern: "/__dev/api/chat", handler: handleChat },
|
|
491
|
+
// Threads — proxies to Rust agent /threads. Mirrors Python's
|
|
492
|
+
// _api_threads + _api_threads_sub.
|
|
493
|
+
{ method: "GET", pattern: "/__dev/api/threads", handler: handleThreads },
|
|
494
|
+
{ method: "POST", pattern: "/__dev/api/threads", handler: handleThreads },
|
|
495
|
+
{ method: "GET", pattern: "/__dev/api/threads/{id}", handler: handleThreadsSub },
|
|
496
|
+
{ method: "PATCH", pattern: "/__dev/api/threads/{id}", handler: handleThreadsSub },
|
|
497
|
+
{ method: "DELETE", pattern: "/__dev/api/threads/{id}", handler: handleThreadsSub },
|
|
498
|
+
{ method: "GET", pattern: "/__dev/api/threads/{id}/messages", handler: handleThreadsSub },
|
|
499
|
+
{ method: "POST", pattern: "/__dev/api/threads/{id}/messages", handler: handleThreadsSub },
|
|
483
500
|
// Connections
|
|
484
501
|
{ method: "GET", pattern: "/__dev/api/connections", handler: handleConnections },
|
|
485
502
|
{ method: "POST", pattern: "/__dev/api/connections/test", handler: handleConnectionsTest },
|
|
@@ -623,6 +640,22 @@ const handleReload: RouteHandler = async (req, res) => {
|
|
|
623
640
|
_reloadFile = (body?.file as string) || "";
|
|
624
641
|
const reloadType = (body?.type as string) || "reload";
|
|
625
642
|
console.log(` External reload trigger: ${reloadType}${_reloadFile ? ` (${_reloadFile})` : ""}`);
|
|
643
|
+
|
|
644
|
+
// Re-discover so new files in src/routes/ register without a server restart.
|
|
645
|
+
// rediscoverRoutes() is idempotent — already-loaded files are skipped, only
|
|
646
|
+
// the new ones run. Add the freshly-discovered routes to the default router.
|
|
647
|
+
try {
|
|
648
|
+
const { rediscoverRoutes } = await import("./routeDiscovery.js");
|
|
649
|
+
const newRoutes = await rediscoverRoutes();
|
|
650
|
+
if (newRoutes.length > 0) {
|
|
651
|
+
const { defaultRouter } = await import("./router.js");
|
|
652
|
+
for (const route of newRoutes) defaultRouter.addRoute(route);
|
|
653
|
+
console.log(` Re-discovered ${newRoutes.length} new route(s) on reload`);
|
|
654
|
+
}
|
|
655
|
+
} catch (err) {
|
|
656
|
+
console.error(` Re-discover on reload failed:`, err);
|
|
657
|
+
}
|
|
658
|
+
|
|
626
659
|
res.json({ ok: true, type: reloadType });
|
|
627
660
|
};
|
|
628
661
|
|
|
@@ -1155,19 +1188,204 @@ const handleTool: RouteHandler = (req, res) => {
|
|
|
1155
1188
|
res.json({ tool, status: "executed", message: `Tool '${tool}' executed (stub)`, timestamp: new Date().toISOString() });
|
|
1156
1189
|
};
|
|
1157
1190
|
|
|
1191
|
+
// -- Supervisor proxy helpers --
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* Return the base URL for the co-located Rust agent server.
|
|
1195
|
+
*
|
|
1196
|
+
* Mirrors Python's `_supervisor_base_url()` in
|
|
1197
|
+
* `tina4_python/dev_admin/__init__.py`. Resolution order:
|
|
1198
|
+
* 1. `TINA4_SUPERVISOR_URL` — explicit full URL.
|
|
1199
|
+
* 2. `TINA4_AGENT_PORT` — explicit port on 127.0.0.1.
|
|
1200
|
+
* 3. `PORT` + 2000 — auto-derived (matches `tina4 serve` agent port).
|
|
1201
|
+
* 4. Fallback `http://127.0.0.1:9145` — matches standalone `tina4 agent`.
|
|
1202
|
+
*/
|
|
1203
|
+
export function supervisorBaseUrl(): string {
|
|
1204
|
+
const explicit = (process.env.TINA4_SUPERVISOR_URL ?? "").replace(/\/+$/, "");
|
|
1205
|
+
if (explicit) return explicit;
|
|
1206
|
+
const agentPort = (process.env.TINA4_AGENT_PORT ?? "").trim();
|
|
1207
|
+
if (/^\d+$/.test(agentPort)) return `http://127.0.0.1:${parseInt(agentPort, 10)}`;
|
|
1208
|
+
const fwPort = (process.env.PORT ?? "").trim();
|
|
1209
|
+
if (/^\d+$/.test(fwPort)) return `http://127.0.0.1:${parseInt(fwPort, 10) + 2000}`;
|
|
1210
|
+
return "http://127.0.0.1:9145";
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* Forward a dev-admin request to the Rust agent server.
|
|
1215
|
+
*
|
|
1216
|
+
* Mirrors Python's `_proxy_to_supervisor()`. Strips the `/__dev/api` prefix,
|
|
1217
|
+
* forwards method/body/query verbatim to `<base>{downstreamPath}`, and pipes
|
|
1218
|
+
* the response back. SSE (`text/event-stream`) is streamed chunk-by-chunk so
|
|
1219
|
+
* progress events reach the SPA live instead of after the full multi-agent
|
|
1220
|
+
* run completes. When the agent is unreachable we respond with 503 and a
|
|
1221
|
+
* hint so the SPA can show a useful error.
|
|
1222
|
+
*/
|
|
1223
|
+
async function proxyToSupervisor(
|
|
1224
|
+
req: any,
|
|
1225
|
+
res: any,
|
|
1226
|
+
downstreamPath: string,
|
|
1227
|
+
): Promise<void> {
|
|
1228
|
+
const base = supervisorBaseUrl();
|
|
1229
|
+
|
|
1230
|
+
// Forward query string verbatim
|
|
1231
|
+
let qs = "";
|
|
1232
|
+
try {
|
|
1233
|
+
const reqUrl = new URL(req.url ?? "/", "http://localhost");
|
|
1234
|
+
if (reqUrl.search) qs = reqUrl.search;
|
|
1235
|
+
} catch { /* ignore */ }
|
|
1236
|
+
const target = `${base}${downstreamPath}${qs}`;
|
|
1237
|
+
|
|
1238
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
1239
|
+
|
|
1240
|
+
// Build the body for methods that carry one
|
|
1241
|
+
let bodyText: string | undefined;
|
|
1242
|
+
if (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE") {
|
|
1243
|
+
const body = (req as any).body;
|
|
1244
|
+
if (body !== undefined && body !== null) {
|
|
1245
|
+
if (typeof body === "string") {
|
|
1246
|
+
bodyText = body;
|
|
1247
|
+
} else if (typeof body === "object") {
|
|
1248
|
+
// SPA→agent convention fixup (matches Python): `/execute` sends
|
|
1249
|
+
// plan_file as a bare filename but the rust agent expects a
|
|
1250
|
+
// project-relative path. Prepend `plan/` when no slash is present.
|
|
1251
|
+
let outBody: any = body;
|
|
1252
|
+
if (!Array.isArray(body)) {
|
|
1253
|
+
const pf = (body as any).plan_file;
|
|
1254
|
+
if (typeof pf === "string" && pf && !pf.includes("/")) {
|
|
1255
|
+
outBody = { ...body, plan_file: `plan/${pf}` };
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
bodyText = JSON.stringify(outBody);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// Heavy multi-agent endpoints get a generous timeout; metadata-only
|
|
1264
|
+
// /supervise/* and /threads/* calls return fast.
|
|
1265
|
+
const timeoutMs = downstreamPath === "/execute" || downstreamPath === "/chat" ? 600_000 : 30_000;
|
|
1266
|
+
const ctrl = new AbortController();
|
|
1267
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
1268
|
+
|
|
1269
|
+
let upstream: Response;
|
|
1270
|
+
try {
|
|
1271
|
+
upstream = await fetch(target, {
|
|
1272
|
+
method,
|
|
1273
|
+
headers: { "Content-Type": "application/json" },
|
|
1274
|
+
body: bodyText,
|
|
1275
|
+
signal: ctrl.signal,
|
|
1276
|
+
});
|
|
1277
|
+
} catch (e) {
|
|
1278
|
+
clearTimeout(timer);
|
|
1279
|
+
res.json(
|
|
1280
|
+
{
|
|
1281
|
+
error: "supervisor unavailable",
|
|
1282
|
+
detail: (e as Error).message,
|
|
1283
|
+
hint: "Run `tina4 serve` (starts the agent server) or set TINA4_SUPERVISOR_URL",
|
|
1284
|
+
},
|
|
1285
|
+
503,
|
|
1286
|
+
);
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
const ct = (upstream.headers.get("content-type") ?? "").toLowerCase();
|
|
1291
|
+
|
|
1292
|
+
// SSE / event-stream — stream chunks through as they arrive.
|
|
1293
|
+
if (ct.includes("text/event-stream")) {
|
|
1294
|
+
res.raw.writeHead(upstream.status || 200, {
|
|
1295
|
+
"Content-Type": upstream.headers.get("content-type") ?? "text/event-stream",
|
|
1296
|
+
"Cache-Control": "no-cache",
|
|
1297
|
+
Connection: "keep-alive",
|
|
1298
|
+
});
|
|
1299
|
+
if (typeof (res.raw as any).flushHeaders === "function") {
|
|
1300
|
+
(res.raw as any).flushHeaders();
|
|
1301
|
+
}
|
|
1302
|
+
if (!upstream.body) {
|
|
1303
|
+
res.raw.end();
|
|
1304
|
+
clearTimeout(timer);
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
const reader = upstream.body.getReader();
|
|
1308
|
+
try {
|
|
1309
|
+
while (true) {
|
|
1310
|
+
const { done, value } = await reader.read();
|
|
1311
|
+
if (done) break;
|
|
1312
|
+
if (value) res.raw.write(Buffer.from(value));
|
|
1313
|
+
}
|
|
1314
|
+
} finally {
|
|
1315
|
+
clearTimeout(timer);
|
|
1316
|
+
res.raw.end();
|
|
1317
|
+
}
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
clearTimeout(timer);
|
|
1322
|
+
|
|
1323
|
+
// JSON / other — drain the body and return as before.
|
|
1324
|
+
const raw = await upstream.text();
|
|
1325
|
+
const status = upstream.status || 200;
|
|
1326
|
+
try {
|
|
1327
|
+
res.json(JSON.parse(raw), status);
|
|
1328
|
+
} catch {
|
|
1329
|
+
// Non-JSON upstream — pass through as text with the same status.
|
|
1330
|
+
res.raw.writeHead(status, {
|
|
1331
|
+
"Content-Type": upstream.headers.get("content-type") ?? "text/plain; charset=utf-8",
|
|
1332
|
+
});
|
|
1333
|
+
res.raw.end(raw);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1158
1337
|
// -- Chat handler --
|
|
1338
|
+
//
|
|
1339
|
+
// Proxies POST /__dev/api/chat → Rust agent `POST /chat`. The SPA's Chat
|
|
1340
|
+
// view POSTs `{message, settings?, thread_id?, active_file?, files?}` and
|
|
1341
|
+
// expects an SSE stream of `event: status / message / done` chunks.
|
|
1342
|
+
// active_file (and any other body keys) are forwarded verbatim.
|
|
1343
|
+
const handleChat: RouteHandler = async (req, res) => {
|
|
1344
|
+
await proxyToSupervisor(req, res, "/chat");
|
|
1345
|
+
};
|
|
1346
|
+
|
|
1347
|
+
// -- Threads handlers --
|
|
1159
1348
|
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1349
|
+
/**
|
|
1350
|
+
* Proxy /__dev/api/threads → Rust agent /threads.
|
|
1351
|
+
* GET → list threads
|
|
1352
|
+
* POST → create thread
|
|
1353
|
+
* Method-multiplexed — anything else gets a 405.
|
|
1354
|
+
*/
|
|
1355
|
+
const handleThreads: RouteHandler = async (req, res) => {
|
|
1356
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
1357
|
+
if (method !== "GET" && method !== "POST") {
|
|
1358
|
+
res.json({ error: "method not allowed" }, 405);
|
|
1164
1359
|
return;
|
|
1165
1360
|
}
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1361
|
+
await proxyToSupervisor(req, res, "/threads");
|
|
1362
|
+
};
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* Proxy /__dev/api/threads/{id}[/messages] → Rust agent.
|
|
1366
|
+
*
|
|
1367
|
+
* Strips the dev-admin prefix and forwards the remaining path verbatim so
|
|
1368
|
+
* /__dev/api/threads/abc/messages becomes /threads/abc/messages on the
|
|
1369
|
+
* agent side. Mirrors Python's `_api_threads_sub`.
|
|
1370
|
+
*/
|
|
1371
|
+
const handleThreadsSub: RouteHandler = async (req, res) => {
|
|
1372
|
+
let pathname = "";
|
|
1373
|
+
try {
|
|
1374
|
+
pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
1375
|
+
} catch {
|
|
1376
|
+
pathname = req.url ?? "";
|
|
1377
|
+
}
|
|
1378
|
+
const prefix = "/__dev/api";
|
|
1379
|
+
if (!pathname.startsWith(prefix)) {
|
|
1380
|
+
res.json({ error: "not found" }, 404);
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
const suffix = pathname.slice(prefix.length); // "/threads/abc[/messages]"
|
|
1384
|
+
if (!suffix.startsWith("/threads/")) {
|
|
1385
|
+
res.json({ error: "not found" }, 404);
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
await proxyToSupervisor(req, res, suffix);
|
|
1171
1389
|
};
|
|
1172
1390
|
|
|
1173
1391
|
// ---------------------------------------------------------------------------
|