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
|
@@ -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
|
// ---------------------------------------------------------------------------
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* In production, call renderProductionError() instead.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { readFileSync } from "node:fs";
|
|
22
|
+
import { readFileSync, statSync } from "node:fs";
|
|
23
23
|
import { resolve } from "node:path";
|
|
24
24
|
import { isTruthy } from "./dotenv.js";
|
|
25
25
|
|
|
@@ -111,8 +111,38 @@ function formatSourceBlock(filename: string, lineno: number): string {
|
|
|
111
111
|
+ rows + `</div>`;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Render one stack frame.
|
|
116
|
+
*
|
|
117
|
+
* When the file was modified AFTER `capturedAt`, append a peach
|
|
118
|
+
* "FILE MODIFIED" badge so a stale browser-cached overlay can't lie
|
|
119
|
+
* about what the source looks like now. The AI coder often rewrites
|
|
120
|
+
* files in place between page loads, leaving the overlay's source
|
|
121
|
+
* view showing different code than what raised the error.
|
|
122
|
+
*
|
|
123
|
+
* `capturedAt` is in seconds (Date.now() / 1000) for parity with
|
|
124
|
+
* Python's time.time().
|
|
125
|
+
*/
|
|
126
|
+
function formatFrame(frame: StackFrame, capturedAt = 0): string {
|
|
115
127
|
const source = frame.file && frame.line > 0 ? formatSourceBlock(frame.file, frame.line) : "";
|
|
128
|
+
let staleBadge = "";
|
|
129
|
+
if (capturedAt && frame.file) {
|
|
130
|
+
try {
|
|
131
|
+
const absPath = resolve(frame.file);
|
|
132
|
+
const mtime = statSync(absPath).mtimeMs / 1000;
|
|
133
|
+
if (mtime > capturedAt + 0.5) { // 0.5s margin for fs noise
|
|
134
|
+
const d = new Date(mtime * 1000);
|
|
135
|
+
const mtimeIso = `${String(d.getUTCHours()).padStart(2, "0")}:`
|
|
136
|
+
+ `${String(d.getUTCMinutes()).padStart(2, "0")}:`
|
|
137
|
+
+ `${String(d.getUTCSeconds()).padStart(2, "0")}`;
|
|
138
|
+
staleBadge = ` <span style="background:${PEACH};color:${BG};padding:1px 8px;`
|
|
139
|
+
+ `border-radius:3px;font-size:11px;font-weight:700;margin-left:6px;">`
|
|
140
|
+
+ `FILE MODIFIED @ ${mtimeIso} UTC — source may not match what failed</span>`;
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
// best-effort — ignore missing files / permission errors
|
|
144
|
+
}
|
|
145
|
+
}
|
|
116
146
|
return `<div style="margin-bottom:16px;">`
|
|
117
147
|
+ `<div style="margin-bottom:4px;">`
|
|
118
148
|
+ `<span style="color:${BLUE};">${esc(frame.file)}</span>`
|
|
@@ -120,6 +150,7 @@ function formatFrame(frame: StackFrame): string {
|
|
|
120
150
|
+ `<span style="color:${YELLOW};">${frame.line}</span>`
|
|
121
151
|
+ `<span style="color:${SUBTEXT};"> in </span>`
|
|
122
152
|
+ `<span style="color:${GREEN};">${esc(frame.func)}</span>`
|
|
153
|
+
+ staleBadge
|
|
123
154
|
+ `</div>`
|
|
124
155
|
+ source
|
|
125
156
|
+ `</div>`;
|
|
@@ -153,14 +184,21 @@ function table(pairs: Array<[string, string]>): string {
|
|
|
153
184
|
* @returns Complete HTML page string.
|
|
154
185
|
*/
|
|
155
186
|
export function renderErrorOverlay(error: Error, request?: any): string {
|
|
187
|
+
// Stamp ONCE per render — every frame compares against this. Seconds-since-epoch
|
|
188
|
+
// matches Python's time.time() so frames stale by < 0.5s of fs noise don't trip.
|
|
189
|
+
const capturedAt = Date.now() / 1000;
|
|
156
190
|
const excType = error.constructor?.name ?? "Error";
|
|
157
191
|
const excMsg = error.message ?? String(error);
|
|
158
192
|
const frames = error.stack ? parseStack(error.stack) : [];
|
|
159
193
|
|
|
160
194
|
// ── Stack trace ──
|
|
195
|
+
// Each frame compares its source file's mtime to capturedAt and flags itself
|
|
196
|
+
// if the file has been modified since — protects against the "browser cached
|
|
197
|
+
// an old overlay, then the AI rewrote the file" confusion where displayed
|
|
198
|
+
// source no longer matches what actually raised the error.
|
|
161
199
|
let framesHtml = "";
|
|
162
200
|
for (const frame of frames) {
|
|
163
|
-
framesHtml += formatFrame(frame);
|
|
201
|
+
framesHtml += formatFrame(frame, capturedAt);
|
|
164
202
|
}
|
|
165
203
|
|
|
166
204
|
// ── Request info ──
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Customer feedback widget — Tier 4 port from tina4-python.
|
|
3
|
+
*
|
|
4
|
+
* End-users of a shipped Tina4 app give UX feedback via a floating bubble
|
|
5
|
+
* widget. Widget visibility + API are gated by TWO env flags:
|
|
6
|
+
*
|
|
7
|
+
* - TINA4_ENABLE_FEEDBACK master switch (explicit opt-in)
|
|
8
|
+
* - TINA4_FEEDBACK_WHITELIST comma-separated emails / user IDs
|
|
9
|
+
*
|
|
10
|
+
* Architecture (mirrors Python `tina4_python/dev_admin/__init__.py`
|
|
11
|
+
* lines 1440-1645):
|
|
12
|
+
*
|
|
13
|
+
* 1. Framework middleware injects <script src="/__feedback/widget.js">
|
|
14
|
+
* into HTML responses for whitelisted users only.
|
|
15
|
+
* 2. Widget POSTs to /__feedback/api/turn for each conversational turn.
|
|
16
|
+
* 3. That handler verifies whitelist + rate-limit, stamps the user
|
|
17
|
+
* identity server-side (client cannot fake `sender`), then forwards
|
|
18
|
+
* to the Rust agent's /feedback/intake.
|
|
19
|
+
*
|
|
20
|
+
* The widget is for END USERS of a shipped app — the /__dev paths get
|
|
21
|
+
* skipped so the dev admin's own chat bubble doesn't sit on top of the
|
|
22
|
+
* customer feedback bubble.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
26
|
+
import { dirname, join, resolve } from "node:path";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
|
|
29
|
+
import { authenticateRequest } from "./auth.js";
|
|
30
|
+
import { supervisorBaseUrl } from "./devAdmin.js";
|
|
31
|
+
import type { RouteHandler, Tina4Request } from "./types.js";
|
|
32
|
+
import type { Router } from "./router.js";
|
|
33
|
+
|
|
34
|
+
// ── Module-level rate-limit state ──────────────────────────────
|
|
35
|
+
// 5 turns/hour per identified user. Stored in-memory only; this is
|
|
36
|
+
// per-process — for multi-instance deployments a shared backend would
|
|
37
|
+
// be needed, but the python reference is the same shape.
|
|
38
|
+
const RATE_LIMIT_WINDOW_SEC = 3600;
|
|
39
|
+
const RATE_LIMIT_MAX = 5;
|
|
40
|
+
const _rateLimitHits = new Map<string, number[]>();
|
|
41
|
+
|
|
42
|
+
// ── Helpers ─────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Master switch — both this AND a non-empty whitelist must be set for
|
|
46
|
+
* the widget to render or the API to accept submissions. Mirrors
|
|
47
|
+
* Python's `_feedback_enabled()`.
|
|
48
|
+
*/
|
|
49
|
+
export function feedbackEnabled(): boolean {
|
|
50
|
+
const raw = (process.env.TINA4_ENABLE_FEEDBACK ?? "").trim().toLowerCase();
|
|
51
|
+
return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Comma-separated emails / user IDs in env, lowercased + trimmed.
|
|
56
|
+
* Returns [] when the master switch is off so callers can short-circuit
|
|
57
|
+
* with a single check. Mirrors Python's `_feedback_whitelist()`.
|
|
58
|
+
*/
|
|
59
|
+
export function feedbackWhitelist(): string[] {
|
|
60
|
+
if (!feedbackEnabled()) return [];
|
|
61
|
+
const raw = (process.env.TINA4_FEEDBACK_WHITELIST ?? "").trim();
|
|
62
|
+
if (!raw) return [];
|
|
63
|
+
return raw
|
|
64
|
+
.split(",")
|
|
65
|
+
.map((e) => e.trim().toLowerCase())
|
|
66
|
+
.filter((e) => e.length > 0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Best-effort user identity from JWT/Bearer auth. Falls back to
|
|
71
|
+
* TINA4_FEEDBACK_DEV_USER (local dev convenience — lets the framework
|
|
72
|
+
* owner test the widget without a full auth setup). Mirrors Python's
|
|
73
|
+
* `_feedback_identify_user()`.
|
|
74
|
+
*/
|
|
75
|
+
export function feedbackIdentifyUser(request: Tina4Request): string | null {
|
|
76
|
+
try {
|
|
77
|
+
const payload = authenticateRequest(
|
|
78
|
+
(request.headers ?? {}) as Record<string, string | string[] | undefined>,
|
|
79
|
+
);
|
|
80
|
+
if (payload && typeof payload === "object") {
|
|
81
|
+
for (const key of ["email", "sub", "user_id"]) {
|
|
82
|
+
const v = (payload as Record<string, unknown>)[key];
|
|
83
|
+
if (v) return String(v).trim().toLowerCase();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
/* ignore — fall through to dev override */
|
|
88
|
+
}
|
|
89
|
+
const dev = (process.env.TINA4_FEEDBACK_DEV_USER ?? "").trim();
|
|
90
|
+
if (dev) return dev.toLowerCase();
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Returns [allowed, userId]. Both halves are required — feature off when
|
|
96
|
+
* either is falsy. Mirrors Python's `_feedback_is_whitelisted()`.
|
|
97
|
+
*/
|
|
98
|
+
export function feedbackIsWhitelisted(
|
|
99
|
+
request: Tina4Request,
|
|
100
|
+
): [boolean, string | null] {
|
|
101
|
+
const wl = feedbackWhitelist();
|
|
102
|
+
if (wl.length === 0) return [false, null];
|
|
103
|
+
const user = feedbackIdentifyUser(request);
|
|
104
|
+
if (!user) return [false, null];
|
|
105
|
+
return [wl.includes(user), user];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 5 turns/hour per user, sliding window. Prunes old timestamps lazily on
|
|
110
|
+
* every call (no background task needed). Mirrors Python's
|
|
111
|
+
* `_feedback_rate_limit_ok()`.
|
|
112
|
+
*/
|
|
113
|
+
export function feedbackRateLimitOk(user: string): boolean {
|
|
114
|
+
const now = Date.now() / 1000;
|
|
115
|
+
const prior = _rateLimitHits.get(user) ?? [];
|
|
116
|
+
const fresh = prior.filter((t) => now - t < RATE_LIMIT_WINDOW_SEC);
|
|
117
|
+
if (fresh.length >= RATE_LIMIT_MAX) {
|
|
118
|
+
_rateLimitHits.set(user, fresh);
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
fresh.push(now);
|
|
122
|
+
_rateLimitHits.set(user, fresh);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Test-only: clear rate-limit state between cases. Not part of public API. */
|
|
127
|
+
export function _resetFeedbackRateLimit(): void {
|
|
128
|
+
_rateLimitHits.clear();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Insert the widget <script> into HTML for whitelisted users. Called
|
|
133
|
+
* from the response pipeline right before the body is flushed. No-op if:
|
|
134
|
+
* - request path starts with /__dev or /__feedback (developer
|
|
135
|
+
* pages have their own chat trigger)
|
|
136
|
+
* - master switch / whitelist not set
|
|
137
|
+
* - user not in whitelist
|
|
138
|
+
* - html lacks </body>
|
|
139
|
+
* Idempotent — looks for the `data-tina4-feedback` marker and bails.
|
|
140
|
+
* Mirrors Python's `inject_feedback_widget()`.
|
|
141
|
+
*/
|
|
142
|
+
export function injectFeedbackWidget(
|
|
143
|
+
request: Tina4Request,
|
|
144
|
+
html: string,
|
|
145
|
+
): string {
|
|
146
|
+
if (!html) return html;
|
|
147
|
+
const path = (request.path ?? "") || "";
|
|
148
|
+
if (path.startsWith("/__dev") || path.startsWith("/__feedback")) return html;
|
|
149
|
+
if (html.includes("data-tina4-feedback")) return html;
|
|
150
|
+
const [allowed] = feedbackIsWhitelisted(request);
|
|
151
|
+
if (!allowed) return html;
|
|
152
|
+
const lastBody = html.lastIndexOf("</body>");
|
|
153
|
+
if (lastBody < 0) return html;
|
|
154
|
+
const snippet =
|
|
155
|
+
'<script src="/__feedback/widget.js" data-tina4-feedback></script>';
|
|
156
|
+
return html.slice(0, lastBody) + snippet + html.slice(lastBody);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Route handlers ──────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* POST /__feedback/api/turn — proxy one conversational turn to the Rust
|
|
163
|
+
* agent's `/feedback/intake`. Server stamps `sender` from the verified
|
|
164
|
+
* identity so the client cannot inject who they are. Mirrors Python's
|
|
165
|
+
* `_api_feedback_turn()`.
|
|
166
|
+
*/
|
|
167
|
+
export const handleFeedbackTurn: RouteHandler = async (req, res) => {
|
|
168
|
+
const [allowed, user] = feedbackIsWhitelisted(req);
|
|
169
|
+
if (!allowed || !user) {
|
|
170
|
+
res.json({ error: "not authorised for feedback" }, 403);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (!feedbackRateLimitOk(user)) {
|
|
174
|
+
res.json(
|
|
175
|
+
{
|
|
176
|
+
error: "rate limit exceeded",
|
|
177
|
+
hint: `max ${RATE_LIMIT_MAX} turns per hour`,
|
|
178
|
+
},
|
|
179
|
+
429,
|
|
180
|
+
);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const body = (req as Tina4Request).body;
|
|
185
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
186
|
+
res.json({ error: "expected JSON body" }, 400);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Stamp sender server-side — client cannot override identity.
|
|
191
|
+
const forwardBody = { ...(body as Record<string, unknown>), sender: user };
|
|
192
|
+
const base = supervisorBaseUrl();
|
|
193
|
+
const target = `${base}/feedback/intake`;
|
|
194
|
+
|
|
195
|
+
const ctrl = new AbortController();
|
|
196
|
+
const timer = setTimeout(() => ctrl.abort(), 60_000);
|
|
197
|
+
|
|
198
|
+
let upstream: Response;
|
|
199
|
+
try {
|
|
200
|
+
upstream = await fetch(target, {
|
|
201
|
+
method: "POST",
|
|
202
|
+
headers: { "Content-Type": "application/json" },
|
|
203
|
+
body: JSON.stringify(forwardBody),
|
|
204
|
+
signal: ctrl.signal,
|
|
205
|
+
});
|
|
206
|
+
} catch (e) {
|
|
207
|
+
clearTimeout(timer);
|
|
208
|
+
res.json(
|
|
209
|
+
{ error: "agent unreachable", detail: (e as Error).message },
|
|
210
|
+
502,
|
|
211
|
+
);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
clearTimeout(timer);
|
|
215
|
+
|
|
216
|
+
const raw = await upstream.text();
|
|
217
|
+
const status = upstream.status || 200;
|
|
218
|
+
try {
|
|
219
|
+
res.json(JSON.parse(raw), status);
|
|
220
|
+
} catch {
|
|
221
|
+
res.raw.writeHead(status, {
|
|
222
|
+
"Content-Type":
|
|
223
|
+
upstream.headers.get("content-type") ?? "text/plain; charset=utf-8",
|
|
224
|
+
});
|
|
225
|
+
res.raw.end(raw);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// Widget bundle lives at packages/core/src/__feedback/widget.js so that
|
|
230
|
+
// it isn't auto-served by the static-file handler (which would skip the
|
|
231
|
+
// no-cache headers below).
|
|
232
|
+
const __feedbackDirname = dirname(fileURLToPath(import.meta.url));
|
|
233
|
+
const WIDGET_BUNDLE_PATH = resolve(__feedbackDirname, "__feedback", "widget.js");
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* GET /__feedback/widget.js — serve the widget bundle with no-cache
|
|
237
|
+
* headers so a broken bundle doesn't get stuck in browser caches.
|
|
238
|
+
* Mirrors Python's `_api_feedback_widget_js()`.
|
|
239
|
+
*/
|
|
240
|
+
export const handleFeedbackWidgetJs: RouteHandler = (_req, res) => {
|
|
241
|
+
let body: Buffer | string;
|
|
242
|
+
if (existsSync(WIDGET_BUNDLE_PATH)) {
|
|
243
|
+
body = readFileSync(WIDGET_BUNDLE_PATH);
|
|
244
|
+
} else {
|
|
245
|
+
body = "console.warn('tina4-feedback-widget bundle not built yet');";
|
|
246
|
+
}
|
|
247
|
+
res.raw.writeHead(200, {
|
|
248
|
+
"Content-Type": "application/javascript; charset=utf-8",
|
|
249
|
+
"Cache-Control": "no-cache, must-revalidate",
|
|
250
|
+
Pragma: "no-cache",
|
|
251
|
+
});
|
|
252
|
+
res.raw.end(body);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Register the two feedback routes on a Router. Called from the dev
|
|
257
|
+
* admin setup so the routes only exist when the dev surface is
|
|
258
|
+
* enabled — production deployments without TINA4_DEBUG also skip them.
|
|
259
|
+
*/
|
|
260
|
+
export function registerFeedbackRoutes(router: Router): void {
|
|
261
|
+
router.addRoute({
|
|
262
|
+
method: "POST",
|
|
263
|
+
pattern: "/__feedback/api/turn",
|
|
264
|
+
handler: handleFeedbackTurn,
|
|
265
|
+
});
|
|
266
|
+
router.addRoute({
|
|
267
|
+
method: "GET",
|
|
268
|
+
pattern: "/__feedback/widget.js",
|
|
269
|
+
handler: handleFeedbackWidgetJs,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Re-export the bundle path so other tools (e.g. CLI builds) can find it.
|
|
274
|
+
export { WIDGET_BUNDLE_PATH };
|
|
275
|
+
// Silence unused-import lint where the helpers are imported but `join` isn't
|
|
276
|
+
// used in this file's current code path. (Kept available for future tweaks.)
|
|
277
|
+
void join;
|