/**
* 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;