Module: Tina4::Feedback
- Defined in:
- lib/tina4/feedback.rb
Overview
── Customer feedback widget — server-side plumbing ─────────────────
Mirrors tina4_python/dev_admin/__init__.py lines 1436-1645. The widget is for END USERS of a shipped Tina4 app (not developers). Whitelisted users get a floating bubble that proxies one conversational turn at a time to the Rust agent’s intake endpoint at <supervisor>/feedback/intake.
Flow:
1. Framework middleware injects <script src="/__feedback/widget.js">
into HTML responses for whitelisted users.
2. Widget POSTs to /__feedback/api/turn for each conversational turn.
3. The Ruby handler verifies whitelist + rate-limit, stamps the
user identity server-side (client cannot fake `sender`), then
forwards to the Rust agent's /feedback/intake.
4. Finalised tickets land in .tina4/chat/threads.json with
kind:"feedback". Developer sees them in the dev admin sidebar.
Constant Summary collapse
- RATE_WINDOW =
1 hour
3600- RATE_MAX =
submissions/turns per user per hour
5
Class Attribute Summary collapse
-
.rate_mutex ⇒ Object
readonly
Returns the value of attribute rate_mutex.
Class Method Summary collapse
-
.feedback_enabled? ⇒ Boolean
Hard master switch.
-
.feedback_identify_user(request) ⇒ Object
Best-effort user identity from auth headers.
-
.feedback_is_whitelisted(request) ⇒ Object
Returns [allowed, identity].
-
.feedback_rate_limit_ok(user) ⇒ Object
5 turns/hour per user.
-
.feedback_whitelist ⇒ Object
Comma-separated emails / user IDs in env.
-
.handle_request(env) ⇒ Object
Dispatcher used by RackApp — returns a Rack triple if the path matches a /__feedback route, else nil.
-
.handle_turn(env) ⇒ Object
POST /__feedback/api/turn — whitelist check + rate-limit + stamp ‘sender` server-side, forward to Rust agent <supervisor>/feedback/intake.
-
.handle_widget_js(_env) ⇒ Object
GET /__feedback/widget.js — serve the bundle at lib/tina4/public/__feedback/widget.js.
-
.inject_feedback_widget(request, html) ⇒ Object
Insert the widget <script> into HTML responses for whitelisted users.
-
.reset_rate_limit! ⇒ Object
Test/reset helper — clears the in-memory rate-limit table.
Class Attribute Details
.rate_mutex ⇒ Object (readonly)
Returns the value of attribute rate_mutex.
35 36 37 |
# File 'lib/tina4/feedback.rb', line 35 def rate_mutex @rate_mutex end |
Class Method Details
.feedback_enabled? ⇒ Boolean
Hard master switch.
Both gates required for the widget to render or the API to accept submissions:
- TINA4_ENABLE_FEEDBACK=true (explicit opt-in — off by default)
- TINA4_FEEDBACK_WHITELIST=... (non-empty list of users)
Splitting the toggle from the whitelist lets the developer leave the whitelist intact while pausing the feature in production for a release (set TINA4_ENABLE_FEEDBACK=false → widget vanishes everywhere; whitelist comes back online with one env flip).
53 54 55 56 |
# File 'lib/tina4/feedback.rb', line 53 def feedback_enabled? raw = ENV["TINA4_ENABLE_FEEDBACK"].to_s.strip.downcase %w[1 true yes on].include?(raw) end |
.feedback_identify_user(request) ⇒ Object
Best-effort user identity from auth headers.
Priority:
1. JWT/Bearer token via Tina4::Auth.authenticate_request —
pulls email/sub/user_id claim.
2. TINA4_FEEDBACK_DEV_USER env var override (LOCAL DEV ONLY —
lets the framework owner test the widget without a full
auth setup in the test project).
74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/tina4/feedback.rb', line 74 def feedback_identify_user(request) begin headers = request_headers(request) payload = Tina4::Auth.authenticate_request(headers) if payload.is_a?(Hash) %w[email sub user_id].each do |key| v = payload[key] || payload[key.to_sym] return v.to_s.strip.downcase if v && !v.to_s.strip.empty? end end rescue StandardError # fall through to dev-user override end dev_user = ENV["TINA4_FEEDBACK_DEV_USER"].to_s.strip return dev_user.downcase unless dev_user.empty? nil end |
.feedback_is_whitelisted(request) ⇒ Object
Returns [allowed, identity]. Both halves must be truthy to act.
93 94 95 96 97 98 99 |
# File 'lib/tina4/feedback.rb', line 93 def feedback_is_whitelisted(request) wl = feedback_whitelist return [false, nil] if wl.empty? user = feedback_identify_user(request) return [false, nil] unless user [wl.include?(user), user] end |
.feedback_rate_limit_ok(user) ⇒ Object
5 turns/hour per user. Prunes old timestamps lazily.
102 103 104 105 106 107 108 109 110 111 112 113 114 |
# File 'lib/tina4/feedback.rb', line 102 def feedback_rate_limit_ok(user) now = Time.now.to_f @rate_mutex.synchronize do hits = (@rate_limit[user] || []).select { |t| now - t < RATE_WINDOW } if hits.size >= RATE_MAX @rate_limit[user] = hits return false end hits << now @rate_limit[user] = hits true end end |
.feedback_whitelist ⇒ Object
Comma-separated emails / user IDs in env. Empty = no one allowed.
59 60 61 62 63 64 |
# File 'lib/tina4/feedback.rb', line 59 def feedback_whitelist return [] unless feedback_enabled? raw = ENV["TINA4_FEEDBACK_WHITELIST"].to_s.strip return [] if raw.empty? raw.split(",").map { |e| e.strip.downcase }.reject(&:empty?) end |
.handle_request(env) ⇒ Object
Dispatcher used by RackApp — returns a Rack triple if the path matches a /__feedback route, else nil.
216 217 218 219 220 221 222 223 224 225 |
# File 'lib/tina4/feedback.rb', line 216 def handle_request(env) path = env["PATH_INFO"] || "/" method = env["REQUEST_METHOD"] case [method, path] when ["GET", "/__feedback/widget.js"] (env) when ["POST", "/__feedback/api/turn"] handle_turn(env) end end |
.handle_turn(env) ⇒ Object
POST /__feedback/api/turn — whitelist check + rate-limit + stamp ‘sender` server-side, forward to Rust agent <supervisor>/feedback/intake. Returns a Rack response triple.
148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 |
# File 'lib/tina4/feedback.rb', line 148 def handle_turn(env) request = build_request_wrapper(env) allowed, user = feedback_is_whitelisted(request) return json_response({ error: "not authorised for feedback" }, 403) unless allowed unless feedback_rate_limit_ok(user) return json_response({ error: "rate limit exceeded", hint: "max #{RATE_MAX} turns per hour" }, 429) end body = read_json_body(env) return json_response({ error: "expected JSON body" }, 400) unless body.is_a?(Hash) forward_body = body.dup forward_body["sender"] = user # server-stamped identity base = supervisor_base uri = URI.parse("#{base}/feedback/intake") begin req = Net::HTTP::Post.new(uri) req["Content-Type"] = "application/json" req.body = JSON.generate(forward_body) resp = Net::HTTP.start(uri.host, uri.port, open_timeout: 5, read_timeout: 60) { |h| h.request(req) } parsed = begin JSON.parse(resp.body.to_s) rescue JSON::ParserError nil end status_code = resp.code.to_i status_code = 200 if status_code.zero? if parsed json_response(parsed, status_code) else [status_code, { "content-type" => "text/plain; charset=utf-8" }, [resp.body.to_s]] end rescue StandardError => e json_response({ error: "agent unreachable", detail: e. }, 502) end end |
.handle_widget_js(_env) ⇒ Object
GET /__feedback/widget.js — serve the bundle at lib/tina4/public/__feedback/widget.js. Cache-Control: no-cache, must-revalidate so browsers re-check the bundle on every load. Without this an old cached bundle (e.g. one that pre-dates the path-block guard against rendering on /__dev/) can persist for days and keep painting the bubble on the dev admin even after the server-side script-tag injection is fixed.
199 200 201 202 203 204 205 206 207 208 209 210 211 212 |
# File 'lib/tina4/feedback.rb', line 199 def (_env) js_path = File.("public/__feedback/widget.js", __dir__) body = if File.file?(js_path) File.binread(js_path) else "console.warn('tina4-feedback-widget bundle not built yet');" end headers = { "content-type" => "application/javascript", "cache-control" => "no-cache, must-revalidate", "pragma" => "no-cache" } [200, headers, [body]] end |
.inject_feedback_widget(request, html) ⇒ Object
Insert the widget <script> into HTML responses for whitelisted users.
Called from the Rack middleware right before the body is sent. No-op if:
- The request is for a /__dev or /__feedback path (developer
dashboard / widget assets — never inject the customer widget
on developer pages; the dev admin has its OWN chat trigger).
- TINA4_ENABLE_FEEDBACK + TINA4_FEEDBACK_WHITELIST not both set
- Requesting user isn't in the whitelist
- Response doesn't have a closing </body> tag (fragment, JSON, etc.)
Idempotent: a second call won’t double-inject (looks for marker).
127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 |
# File 'lib/tina4/feedback.rb', line 127 def (request, html) return html if html.nil? || html.empty? # The customer feedback widget is for END USERS of the shipped # app — injecting on developer-only paths creates a confusing # "two bubbles" UX where the dev chat trigger + customer # feedback bubble sit on top of each other. Hard exclusion at # the framework layer. path = request_path(request) return html if path.start_with?("/__dev") || path.start_with?("/__feedback") allowed, _user = feedback_is_whitelisted(request) return html unless allowed return html if html.include?("data-tina4-feedback") snippet = '<script src="/__feedback/widget.js" data-tina4-feedback></script>' idx = html.rindex("</body>") return html unless idx html[0...idx] + snippet + html[idx..] end |
.reset_rate_limit! ⇒ Object
Test/reset helper — clears the in-memory rate-limit table.
38 39 40 |
# File 'lib/tina4/feedback.rb', line 38 def reset_rate_limit! @rate_mutex.synchronize { @rate_limit = {} } end |