/** * Customer feedback widget — Tier 4 port from tina4-python. * * End-users of a shipped Tina4 app give UX feedback via a floating bubble * widget. Widget visibility + API are gated by TWO env flags: * * - TINA4_ENABLE_FEEDBACK master switch (explicit opt-in) * - TINA4_FEEDBACK_WHITELIST comma-separated emails / user IDs * * Architecture (mirrors Python `tina4_python/dev_admin/__init__.py` * lines 1440-1645): * * 1. Framework middleware injects '; return html.slice(0, lastBody) + snippet + html.slice(lastBody); } // ── Route handlers ────────────────────────────────────────────── /** * POST /__feedback/api/turn — proxy one conversational turn to the Rust * agent's `/feedback/intake`. Server stamps `sender` from the verified * identity so the client cannot inject who they are. Mirrors Python's * `_api_feedback_turn()`. */ export const handleFeedbackTurn: RouteHandler = async (req, res) => { const [allowed, user] = feedbackIsWhitelisted(req); if (!allowed || !user) { res.json({ error: "not authorised for feedback" }, 403); return; } if (!feedbackRateLimitOk(user)) { res.json( { error: "rate limit exceeded", hint: `max ${RATE_LIMIT_MAX} turns per hour`, }, 429, ); return; } const body = (req as Tina4Request).body; if (!body || typeof body !== "object" || Array.isArray(body)) { res.json({ error: "expected JSON body" }, 400); return; } // Stamp sender server-side — client cannot override identity. const forwardBody = { ...(body as Record), sender: user }; const base = supervisorBaseUrl(); const target = `${base}/feedback/intake`; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 60_000); let upstream: Response; try { upstream = await fetch(target, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(forwardBody), signal: ctrl.signal, }); } catch (e) { clearTimeout(timer); res.json( { error: "agent unreachable", detail: (e as Error).message }, 502, ); return; } clearTimeout(timer); const raw = await upstream.text(); const status = upstream.status || 200; try { res.json(JSON.parse(raw), status); } catch { res.raw.writeHead(status, { "Content-Type": upstream.headers.get("content-type") ?? "text/plain; charset=utf-8", }); res.raw.end(raw); } }; // Widget bundle lives at packages/core/src/__feedback/widget.js so that // it isn't auto-served by the static-file handler (which would skip the // no-cache headers below). const __feedbackDirname = dirname(fileURLToPath(import.meta.url)); const WIDGET_BUNDLE_PATH = resolve(__feedbackDirname, "__feedback", "widget.js"); /** * GET /__feedback/widget.js — serve the widget bundle with no-cache * headers so a broken bundle doesn't get stuck in browser caches. * Mirrors Python's `_api_feedback_widget_js()`. */ export const handleFeedbackWidgetJs: RouteHandler = (_req, res) => { let body: Buffer | string; if (existsSync(WIDGET_BUNDLE_PATH)) { body = readFileSync(WIDGET_BUNDLE_PATH); } else { body = "console.warn('tina4-feedback-widget bundle not built yet');"; } res.raw.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8", "Cache-Control": "no-cache, must-revalidate", Pragma: "no-cache", }); res.raw.end(body); }; /** * Register the two feedback routes on a Router. Called from the dev * admin setup so the routes only exist when the dev surface is * enabled — production deployments without TINA4_DEBUG also skip them. */ export function registerFeedbackRoutes(router: Router): void { router.addRoute({ method: "POST", pattern: "/__feedback/api/turn", handler: handleFeedbackTurn, }); router.addRoute({ method: "GET", pattern: "/__feedback/widget.js", handler: handleFeedbackWidgetJs, }); } // Re-export the bundle path so other tools (e.g. CLI builds) can find it. export { WIDGET_BUNDLE_PATH }; // Silence unused-import lint where the helpers are imported but `join` isn't // used in this file's current code path. (Kept available for future tweaks.) void join;