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.
@@ -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
- const handleChat: RouteHandler = (req, res) => {
1161
- const message = (req as any).body?.message ?? "";
1162
- if (!message) {
1163
- res.json({ error: "Missing message parameter" });
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
- // Placeholder AI chat response
1167
- res.json({
1168
- reply: `AI chat is not yet configured. You said: "${message}"`,
1169
- timestamp: new Date().toISOString(),
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
- function formatFrame(frame: StackFrame): string {
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 &mdash; 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;