tina4ruby 3.12.10 → 3.12.13

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6b3a05320ede8140d3735fd373bfde9772b51c1cb0b8c3a65552863a9cce044a
4
- data.tar.gz: 24b318fd87fe6ba03f46333c5edc47621a4d82c1dbd00154ff5d6d1730fefe22
3
+ metadata.gz: 65d79f819e3ac62c1e8d3447409b8c75530c239db2bb6a7df9066de4104495ed
4
+ data.tar.gz: d0b0af5dd0e914b880cc64a7b9aca29e6f46de65dcec3104b18e01ad174509b8
5
5
  SHA512:
6
- metadata.gz: 4d7634856ac4c74a70cfb4f2ae878164ac0718768a113986578a97d8c2cfa24782b34a275f01e06e75a352e62081b4d8dab9b86359943b8a96a4b94bade21a20
7
- data.tar.gz: c8791df9abcaa3487648e85a2bd9b98bdfd866138697f4541c261f27d948cd8e302895a8333cdc63287b681143a38e55654a09230030d1e756acbad899e6dd3d
6
+ metadata.gz: 2e59eac2e37d78d1f7752cd26680338ff404794f6ff23dbe9ab2a1570ce3f458200fd5f488fefb8ef6f5f0b1764aa355def94dbed13a50149df30b4ea798059a
7
+ data.tar.gz: 7021e0596f5275b4daf0969f0690400e99222fe10f30547b31fc8ee747f266c88086fb577927953813ac53b52dad9d8ce95a7ecb7ae48bfd2c1aa50fa8ff5e47
data/lib/tina4/cli.rb CHANGED
@@ -265,7 +265,7 @@ module Tina4
265
265
 
266
266
  db = Tina4.database
267
267
  unless db
268
- puts "No database configured. Set DATABASE_URL in your .env file."
268
+ puts "No database configured. Set TINA4_DATABASE_URL in your .env file."
269
269
  return
270
270
  end
271
271
 
@@ -298,7 +298,7 @@ module Tina4
298
298
 
299
299
  db = Tina4.database
300
300
  unless db
301
- puts "No database configured. Set DATABASE_URL in your .env file."
301
+ puts "No database configured. Set TINA4_DATABASE_URL in your .env file."
302
302
  return
303
303
  end
304
304
 
@@ -341,7 +341,7 @@ module Tina4
341
341
 
342
342
  db = Tina4.database
343
343
  unless db
344
- puts "No database configured. Set DATABASE_URL in your .env file."
344
+ puts "No database configured. Set TINA4_DATABASE_URL in your .env file."
345
345
  return
346
346
  end
347
347
 
@@ -369,6 +369,15 @@ module Tina4
369
369
  path = env["PATH_INFO"] || "/"
370
370
  method = env["REQUEST_METHOD"]
371
371
 
372
+ # Dynamic-path routes can't live in the case-tuple below — match
373
+ # them up front. /__dev/api/threads/{id}[/messages] is forwarded
374
+ # verbatim to the Rust agent's /threads/{id}[/messages] surface
375
+ # (mirrors Python's `_api_threads_sub`).
376
+ if path.start_with?("/__dev/api/threads/")
377
+ suffix = path[("/__dev/api".length)..] # leaves "/threads/{id}[/messages]"
378
+ return threads_sub_proxy(env, method, suffix)
379
+ end
380
+
372
381
  case [method, path]
373
382
  when ["GET", "/__dev"], ["GET", "/__dev/"]
374
383
  serve_dashboard
@@ -382,6 +391,13 @@ module Tina4
382
391
  @reload_file = body["file"] || ""
383
392
  reload_type = body["type"] || "reload"
384
393
  Tina4::Log.info("External reload trigger: #{reload_type}#{@reload_file.empty? ? '' : " (#{@reload_file})"}")
394
+ # Re-discover so files dropped into src/routes/ register without
395
+ # a server restart. Idempotent — already-loaded files are skipped.
396
+ begin
397
+ Tina4::Router.rescan_routes!
398
+ rescue StandardError => e
399
+ Tina4::Log.error("Re-discover on reload failed: #{e.message}")
400
+ end
385
401
  json_response({ ok: true, type: reload_type })
386
402
  when ["GET", "/__dev/api/status"]
387
403
  json_response(status_payload)
@@ -509,12 +525,21 @@ module Tina4
509
525
  tool = (body && body["tool"]) || ""
510
526
  json_response(run_tool(tool))
511
527
  when ["POST", "/__dev/api/chat"]
512
- body = read_json_body(env)
513
- message = (body && body["message"]) || ""
514
- json_response({
515
- reply: "Chat is not yet connected to an AI backend. You said: \"#{message}\"",
516
- timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
517
- })
528
+ # Proxy dev-admin chat to the Rust agent's /chat endpoint.
529
+ # The SPA POSTs {message, thread_id?, active_file?, settings?}
530
+ # and expects an SSE stream of `event: status/message/done`
531
+ # chunks. We forward the JSON body verbatim (active_file rides
532
+ # along) and pipe upstream bytes back as they arrive. Mirrors
533
+ # Python's `_api_chat` / `_proxy_to_supervisor` SSE path.
534
+ body = read_json_body(env) || {}
535
+ chat_proxy(body)
536
+ when ["GET", "/__dev/api/threads"]
537
+ # Parity with Python `_api_threads` (GET → list threads).
538
+ json_response(proxy_supervisor("/threads", method: "GET", query: env["QUERY_STRING"]))
539
+ when ["POST", "/__dev/api/threads"]
540
+ # Parity with Python `_api_threads` (POST → create thread).
541
+ body = read_json_body(env) || {}
542
+ json_response(proxy_supervisor("/threads", method: "POST", body: body))
518
543
  when ["GET", "/__dev/api/connections"]
519
544
  handle_connections_get
520
545
  when ["POST", "/__dev/api/connections/test"]
@@ -893,10 +918,13 @@ module Tina4
893
918
  key, val = line.split("=", 2)
894
919
  key = key.strip
895
920
  val = (val || "").strip.gsub(/\A["']|["']\z/, "")
921
+ # v3.12+ env vars are TINA4_-prefixed; the boot guard refuses to
922
+ # start with bare DATABASE_URL set, so the dashboard must read
923
+ # and write the prefixed names.
896
924
  case key
897
- when "DATABASE_URL" then url = val
898
- when "DATABASE_USERNAME" then username = val
899
- when "DATABASE_PASSWORD" then password = val.empty? ? "" : "***"
925
+ when "TINA4_DATABASE_URL" then url = val
926
+ when "TINA4_DATABASE_USERNAME" then username = val
927
+ when "TINA4_DATABASE_PASSWORD" then password = val.empty? ? "" : "***"
900
928
  end
901
929
  end
902
930
  end
@@ -954,7 +982,13 @@ module Tina4
954
982
  begin
955
983
  env_path = File.join(Dir.pwd, ".env")
956
984
  lines = File.file?(env_path) ? File.readlines(env_path, chomp: true) : []
957
- keys_found = { "DATABASE_URL" => false, "DATABASE_USERNAME" => false, "DATABASE_PASSWORD" => false }
985
+ # v3.12+ env vars are TINA4_-prefixed; saving bare DATABASE_URL
986
+ # would trip the framework's legacy-env boot guard on next start.
987
+ keys_found = {
988
+ "TINA4_DATABASE_URL" => false,
989
+ "TINA4_DATABASE_USERNAME" => false,
990
+ "TINA4_DATABASE_PASSWORD" => false,
991
+ }
958
992
  new_lines = []
959
993
  lines.each do |line|
960
994
  stripped = line.strip
@@ -964,20 +998,24 @@ module Tina4
964
998
  end
965
999
  key = stripped.split("=", 2).first.strip
966
1000
  case key
967
- when "DATABASE_URL"
968
- new_lines << "DATABASE_URL=#{url}"
969
- keys_found["DATABASE_URL"] = true
970
- when "DATABASE_USERNAME"
971
- new_lines << "DATABASE_USERNAME=#{username}"
972
- keys_found["DATABASE_USERNAME"] = true
973
- when "DATABASE_PASSWORD"
974
- new_lines << "DATABASE_PASSWORD=#{password}"
975
- keys_found["DATABASE_PASSWORD"] = true
1001
+ when "TINA4_DATABASE_URL"
1002
+ new_lines << "TINA4_DATABASE_URL=#{url}"
1003
+ keys_found["TINA4_DATABASE_URL"] = true
1004
+ when "TINA4_DATABASE_USERNAME"
1005
+ new_lines << "TINA4_DATABASE_USERNAME=#{username}"
1006
+ keys_found["TINA4_DATABASE_USERNAME"] = true
1007
+ when "TINA4_DATABASE_PASSWORD"
1008
+ new_lines << "TINA4_DATABASE_PASSWORD=#{password}"
1009
+ keys_found["TINA4_DATABASE_PASSWORD"] = true
976
1010
  else
977
1011
  new_lines << line
978
1012
  end
979
1013
  end
980
- values = { "DATABASE_URL" => url, "DATABASE_USERNAME" => username, "DATABASE_PASSWORD" => password }
1014
+ values = {
1015
+ "TINA4_DATABASE_URL" => url,
1016
+ "TINA4_DATABASE_USERNAME" => username,
1017
+ "TINA4_DATABASE_PASSWORD" => password,
1018
+ }
981
1019
  keys_found.each do |key, found|
982
1020
  new_lines << "#{key}=#{values[key]}" unless found
983
1021
  end
@@ -1075,6 +1113,21 @@ module Tina4
1075
1113
  r["Content-Type"] = "application/json"
1076
1114
  r.body = JSON.generate(body || {})
1077
1115
  r
1116
+ when "PATCH"
1117
+ r = Net::HTTP::Patch.new(uri)
1118
+ r["Content-Type"] = "application/json"
1119
+ r.body = JSON.generate(body || {})
1120
+ r
1121
+ when "PUT"
1122
+ r = Net::HTTP::Put.new(uri)
1123
+ r["Content-Type"] = "application/json"
1124
+ r.body = JSON.generate(body || {})
1125
+ r
1126
+ when "DELETE"
1127
+ r = Net::HTTP::Delete.new(uri)
1128
+ r["Content-Type"] = "application/json"
1129
+ r.body = JSON.generate(body) if body
1130
+ r
1078
1131
  else
1079
1132
  Net::HTTP::Get.new(uri)
1080
1133
  end
@@ -1089,6 +1142,85 @@ module Tina4
1089
1142
  end
1090
1143
  end
1091
1144
 
1145
+ # Proxy /__dev/api/threads/{id}[/messages] through to the Rust
1146
+ # agent. Mirrors Python's `_api_threads_sub`: we already stripped
1147
+ # the dev-admin prefix in handle_request, so `suffix` is the path
1148
+ # the agent expects (e.g. `/threads/abc` or `/threads/abc/messages`).
1149
+ # PATCH /threads/{id} (archive/rename) and GET /threads/{id}/messages
1150
+ # are the two shapes the SPA actually fires.
1151
+ def threads_sub_proxy(env, method, suffix)
1152
+ case method.upcase
1153
+ when "GET"
1154
+ json_response(proxy_supervisor(suffix, method: "GET", query: env["QUERY_STRING"]))
1155
+ when "POST"
1156
+ body = read_json_body(env) || {}
1157
+ json_response(proxy_supervisor(suffix, method: "POST", body: body))
1158
+ when "PATCH"
1159
+ body = read_json_body(env) || {}
1160
+ json_response(proxy_supervisor(suffix, method: "PATCH", body: body))
1161
+ when "PUT"
1162
+ body = read_json_body(env) || {}
1163
+ json_response(proxy_supervisor(suffix, method: "PUT", body: body))
1164
+ when "DELETE"
1165
+ body = read_json_body(env)
1166
+ json_response(proxy_supervisor(suffix, method: "DELETE", body: body))
1167
+ else
1168
+ [405, { "content-type" => "application/json; charset=utf-8" },
1169
+ [JSON.generate({ error: "method not allowed" })]]
1170
+ end
1171
+ end
1172
+
1173
+ # POST /chat — proxy the SPA's chat payload to the Rust agent and
1174
+ # pipe the upstream SSE response back to the browser. Active-file
1175
+ # content (when present in the body) rides along verbatim.
1176
+ #
1177
+ # NOTE on streaming: Tina4 Ruby's Rack app does not currently
1178
+ # expose a chunk-by-chunk streaming API to handlers (see
1179
+ # response.rb — `response.stream(&block)` is not yet wired through
1180
+ # the case-tuple dispatcher used here). We do the next best thing:
1181
+ # use Net::HTTP#request_get with a block so we receive upstream
1182
+ # SSE chunks as they arrive, buffer them, and return the assembled
1183
+ # body with the upstream content-type intact. The SPA's
1184
+ # EventSource reader works either way (it parses `data:` lines
1185
+ # regardless of arrival cadence) — the TODO below tracks
1186
+ # converting this to a true streamed Rack body once dev_admin
1187
+ # routes are migrated off the case-tuple dispatcher.
1188
+ def chat_proxy(body)
1189
+ base = supervisor_base
1190
+ begin
1191
+ uri = URI.parse("#{base}/chat")
1192
+ req = Net::HTTP::Post.new(uri)
1193
+ req["Content-Type"] = "application/json"
1194
+ req["Accept"] = "text/event-stream"
1195
+ req.body = JSON.generate(body || {})
1196
+ http = Net::HTTP.new(uri.host, uri.port)
1197
+ http.open_timeout = 2
1198
+ # /chat runs the supervisor → planner → coder loop with one or
1199
+ # more LLM round-trips. Matches Python's 600s budget.
1200
+ http.read_timeout = 600
1201
+ chunks = []
1202
+ upstream_status = 200
1203
+ upstream_ct = "text/event-stream"
1204
+ http.request(req) do |resp|
1205
+ upstream_status = resp.code.to_i
1206
+ upstream_ct = resp["content-type"] || upstream_ct
1207
+ # TODO: stream chunks straight into the Rack response once
1208
+ # dev_admin migrates to response.stream(). For now we
1209
+ # buffer — the SPA's SSE reader still parses correctly.
1210
+ resp.read_body { |chunk| chunks << chunk }
1211
+ end
1212
+ [upstream_status, { "content-type" => upstream_ct }, [chunks.join]]
1213
+ rescue StandardError => e
1214
+ body_str = JSON.generate({
1215
+ error: "supervisor unavailable",
1216
+ detail: e.message,
1217
+ hint: "Run `tina4 serve` (starts the agent server) or set TINA4_SUPERVISOR_URL",
1218
+ supervisor: base
1219
+ })
1220
+ [503, { "content-type" => "application/json; charset=utf-8" }, [body_str]]
1221
+ end
1222
+ end
1223
+
1092
1224
  def execute_proxy(body)
1093
1225
  # Proxy POST /execute to the supervisor at framework_port + 2000.
1094
1226
  # Pass through the response stream as-is (SSE or JSON).
@@ -42,12 +42,20 @@ module Tina4
42
42
  exc_type = exception.class.name
43
43
  exc_msg = exception.message
44
44
 
45
+ # Stamp captured_at once when the overlay is generated. Each frame
46
+ # compares its source file's mtime to this single timestamp and
47
+ # flags itself stale if the file changed afterwards — protects
48
+ # against the "browser cached an old overlay, then the AI rewrote
49
+ # the file" confusion where the displayed source no longer matches
50
+ # what actually raised the error.
51
+ captured_at = Time.now.to_f
52
+
45
53
  # ── Stack trace ──
46
54
  frames_html = +""
47
55
  backtrace = exception.backtrace || []
48
56
  backtrace.each do |line|
49
57
  file, lineno, method = parse_backtrace_line(line)
50
- frames_html << format_frame(file, lineno, method)
58
+ frames_html << format_frame(file, lineno, method, captured_at: captured_at)
51
59
  end
52
60
 
53
61
  # ── Request info ──
@@ -213,8 +221,9 @@ module Tina4
213
221
  "#{rows}</div>"
214
222
  end
215
223
 
216
- def format_frame(filename, lineno, func_name)
224
+ def format_frame(filename, lineno, func_name, captured_at: 0.0)
217
225
  source = (filename && lineno.positive?) ? format_source_block(filename, lineno) : ""
226
+ stale_badge = stale_file_badge(filename, captured_at)
218
227
  "<div style=\"margin-bottom:16px;\">" \
219
228
  "<div style=\"margin-bottom:4px;\">" \
220
229
  "<span style=\"color:#{BLUE};\">#{esc(filename.to_s)}</span>" \
@@ -222,11 +231,32 @@ module Tina4
222
231
  "<span style=\"color:#{YELLOW};\">#{lineno}</span>" \
223
232
  "<span style=\"color:#{SUBTEXT};\"> in </span>" \
224
233
  "<span style=\"color:#{GREEN};\">#{esc(func_name.to_s)}</span>" \
234
+ "#{stale_badge}" \
225
235
  "</div>" \
226
236
  "#{source}" \
227
237
  "</div>"
228
238
  end
229
239
 
240
+ # When the file on disk was modified AFTER the overlay was generated,
241
+ # emit a peach pill warning that the visible source may not match what
242
+ # actually failed. 0.5s margin to avoid filesystem-noise false positives.
243
+ def stale_file_badge(filename, captured_at)
244
+ return "" if captured_at.nil? || captured_at <= 0
245
+ return "" if filename.nil? || filename.to_s.empty?
246
+ return "" unless File.file?(filename)
247
+
248
+ mtime = File.mtime(filename).to_f
249
+ return "" if mtime <= captured_at + 0.5
250
+
251
+ mtime_str = Time.at(mtime).utc.strftime("%H:%M:%S")
252
+ "<span style=\"background:#{PEACH};color:#{BG};padding:1px 8px;" \
253
+ "border-radius:3px;font-size:11px;font-weight:700;margin-left:6px;\">" \
254
+ "FILE MODIFIED @ #{mtime_str} UTC &mdash; source may not match what failed" \
255
+ "</span>"
256
+ rescue StandardError
257
+ ""
258
+ end
259
+
230
260
  def collapsible(title, content, open_by_default: false)
231
261
  open_attr = open_by_default ? " open" : ""
232
262
  "<details style=\"margin-top:16px;\"#{open_attr}>" \
@@ -0,0 +1,307 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Tina4
8
+ # ── Customer feedback widget — server-side plumbing ─────────────────
9
+ #
10
+ # Mirrors tina4_python/dev_admin/__init__.py lines 1436-1645. The
11
+ # widget is for END USERS of a shipped Tina4 app (not developers).
12
+ # Whitelisted users get a floating bubble that proxies one
13
+ # conversational turn at a time to the Rust agent's intake endpoint
14
+ # at <supervisor>/feedback/intake.
15
+ #
16
+ # Flow:
17
+ # 1. Framework middleware injects <script src="/__feedback/widget.js">
18
+ # into HTML responses for whitelisted users.
19
+ # 2. Widget POSTs to /__feedback/api/turn for each conversational turn.
20
+ # 3. The Ruby handler verifies whitelist + rate-limit, stamps the
21
+ # user identity server-side (client cannot fake `sender`), then
22
+ # forwards to the Rust agent's /feedback/intake.
23
+ # 4. Finalised tickets land in .tina4/chat/threads.json with
24
+ # kind:"feedback". Developer sees them in the dev admin sidebar.
25
+ module Feedback
26
+ RATE_WINDOW = 3600 # 1 hour
27
+ RATE_MAX = 5 # submissions/turns per user per hour
28
+
29
+ # Class-level mutex-guarded hash {user => [timestamps]}. Mirrors the
30
+ # Python `_FEEDBACK_RATE_LIMIT` dict — process-local, no persistence.
31
+ @rate_limit = {}
32
+ @rate_mutex = Mutex.new
33
+
34
+ class << self
35
+ attr_reader :rate_mutex
36
+
37
+ # Test/reset helper — clears the in-memory rate-limit table.
38
+ def reset_rate_limit!
39
+ @rate_mutex.synchronize { @rate_limit = {} }
40
+ end
41
+
42
+ # Hard master switch.
43
+ #
44
+ # Both gates required for the widget to render or the API to
45
+ # accept submissions:
46
+ # - TINA4_ENABLE_FEEDBACK=true (explicit opt-in — off by default)
47
+ # - TINA4_FEEDBACK_WHITELIST=... (non-empty list of users)
48
+ #
49
+ # Splitting the toggle from the whitelist lets the developer leave
50
+ # the whitelist intact while pausing the feature in production for
51
+ # a release (set TINA4_ENABLE_FEEDBACK=false → widget vanishes
52
+ # everywhere; whitelist comes back online with one env flip).
53
+ def feedback_enabled?
54
+ raw = ENV["TINA4_ENABLE_FEEDBACK"].to_s.strip.downcase
55
+ %w[1 true yes on].include?(raw)
56
+ end
57
+
58
+ # Comma-separated emails / user IDs in env. Empty = no one allowed.
59
+ def feedback_whitelist
60
+ return [] unless feedback_enabled?
61
+ raw = ENV["TINA4_FEEDBACK_WHITELIST"].to_s.strip
62
+ return [] if raw.empty?
63
+ raw.split(",").map { |e| e.strip.downcase }.reject(&:empty?)
64
+ end
65
+
66
+ # Best-effort user identity from auth headers.
67
+ #
68
+ # Priority:
69
+ # 1. JWT/Bearer token via Tina4::Auth.authenticate_request —
70
+ # pulls email/sub/user_id claim.
71
+ # 2. TINA4_FEEDBACK_DEV_USER env var override (LOCAL DEV ONLY —
72
+ # lets the framework owner test the widget without a full
73
+ # auth setup in the test project).
74
+ def feedback_identify_user(request)
75
+ begin
76
+ headers = request_headers(request)
77
+ payload = Tina4::Auth.authenticate_request(headers)
78
+ if payload.is_a?(Hash)
79
+ %w[email sub user_id].each do |key|
80
+ v = payload[key] || payload[key.to_sym]
81
+ return v.to_s.strip.downcase if v && !v.to_s.strip.empty?
82
+ end
83
+ end
84
+ rescue StandardError
85
+ # fall through to dev-user override
86
+ end
87
+ dev_user = ENV["TINA4_FEEDBACK_DEV_USER"].to_s.strip
88
+ return dev_user.downcase unless dev_user.empty?
89
+ nil
90
+ end
91
+
92
+ # Returns [allowed, identity]. Both halves must be truthy to act.
93
+ def feedback_is_whitelisted(request)
94
+ wl = feedback_whitelist
95
+ return [false, nil] if wl.empty?
96
+ user = feedback_identify_user(request)
97
+ return [false, nil] unless user
98
+ [wl.include?(user), user]
99
+ end
100
+
101
+ # 5 turns/hour per user. Prunes old timestamps lazily.
102
+ def feedback_rate_limit_ok(user)
103
+ now = Time.now.to_f
104
+ @rate_mutex.synchronize do
105
+ hits = (@rate_limit[user] || []).select { |t| now - t < RATE_WINDOW }
106
+ if hits.size >= RATE_MAX
107
+ @rate_limit[user] = hits
108
+ return false
109
+ end
110
+ hits << now
111
+ @rate_limit[user] = hits
112
+ true
113
+ end
114
+ end
115
+
116
+ # Insert the widget <script> into HTML responses for whitelisted users.
117
+ #
118
+ # Called from the Rack middleware right before the body is sent.
119
+ # No-op if:
120
+ # - The request is for a /__dev or /__feedback path (developer
121
+ # dashboard / widget assets — never inject the customer widget
122
+ # on developer pages; the dev admin has its OWN chat trigger).
123
+ # - TINA4_ENABLE_FEEDBACK + TINA4_FEEDBACK_WHITELIST not both set
124
+ # - Requesting user isn't in the whitelist
125
+ # - Response doesn't have a closing </body> tag (fragment, JSON, etc.)
126
+ # Idempotent: a second call won't double-inject (looks for marker).
127
+ def inject_feedback_widget(request, html)
128
+ return html if html.nil? || html.empty?
129
+ # The customer feedback widget is for END USERS of the shipped
130
+ # app — injecting on developer-only paths creates a confusing
131
+ # "two bubbles" UX where the dev chat trigger + customer
132
+ # feedback bubble sit on top of each other. Hard exclusion at
133
+ # the framework layer.
134
+ path = request_path(request)
135
+ return html if path.start_with?("/__dev") || path.start_with?("/__feedback")
136
+ allowed, _user = feedback_is_whitelisted(request)
137
+ return html unless allowed
138
+ return html if html.include?("data-tina4-feedback")
139
+ snippet = '<script src="/__feedback/widget.js" data-tina4-feedback></script>'
140
+ idx = html.rindex("</body>")
141
+ return html unless idx
142
+ html[0...idx] + snippet + html[idx..]
143
+ end
144
+
145
+ # POST /__feedback/api/turn — whitelist check + rate-limit + stamp
146
+ # `sender` server-side, forward to Rust agent
147
+ # <supervisor>/feedback/intake. Returns a Rack response triple.
148
+ def handle_turn(env)
149
+ request = build_request_wrapper(env)
150
+ allowed, user = feedback_is_whitelisted(request)
151
+ return json_response({ error: "not authorised for feedback" }, 403) unless allowed
152
+ unless feedback_rate_limit_ok(user)
153
+ return json_response({
154
+ error: "rate limit exceeded",
155
+ hint: "max #{RATE_MAX} turns per hour"
156
+ }, 429)
157
+ end
158
+
159
+ body = read_json_body(env)
160
+ return json_response({ error: "expected JSON body" }, 400) unless body.is_a?(Hash)
161
+
162
+ forward_body = body.dup
163
+ forward_body["sender"] = user # server-stamped identity
164
+
165
+ base = supervisor_base
166
+ uri = URI.parse("#{base}/feedback/intake")
167
+ begin
168
+ req = Net::HTTP::Post.new(uri)
169
+ req["Content-Type"] = "application/json"
170
+ req.body = JSON.generate(forward_body)
171
+ resp = Net::HTTP.start(uri.host, uri.port, open_timeout: 5, read_timeout: 60) { |h| h.request(req) }
172
+ parsed = begin
173
+ JSON.parse(resp.body.to_s)
174
+ rescue JSON::ParserError
175
+ nil
176
+ end
177
+ status_code = resp.code.to_i
178
+ status_code = 200 if status_code.zero?
179
+ if parsed
180
+ json_response(parsed, status_code)
181
+ else
182
+ [status_code, { "content-type" => "text/plain; charset=utf-8" }, [resp.body.to_s]]
183
+ end
184
+ rescue StandardError => e
185
+ json_response({
186
+ error: "agent unreachable",
187
+ detail: e.message
188
+ }, 502)
189
+ end
190
+ end
191
+
192
+ # GET /__feedback/widget.js — serve the bundle at
193
+ # lib/tina4/public/__feedback/widget.js. Cache-Control: no-cache,
194
+ # must-revalidate so browsers re-check the bundle on every load.
195
+ # Without this an old cached bundle (e.g. one that pre-dates the
196
+ # path-block guard against rendering on /__dev/) can persist for
197
+ # days and keep painting the bubble on the dev admin even after
198
+ # the server-side script-tag injection is fixed.
199
+ def handle_widget_js(_env)
200
+ js_path = File.expand_path("public/__feedback/widget.js", __dir__)
201
+ body = if File.file?(js_path)
202
+ File.binread(js_path)
203
+ else
204
+ "console.warn('tina4-feedback-widget bundle not built yet');"
205
+ end
206
+ headers = {
207
+ "content-type" => "application/javascript",
208
+ "cache-control" => "no-cache, must-revalidate",
209
+ "pragma" => "no-cache"
210
+ }
211
+ [200, headers, [body]]
212
+ end
213
+
214
+ # Dispatcher used by RackApp — returns a Rack triple if the path
215
+ # matches a /__feedback route, else nil.
216
+ def handle_request(env)
217
+ path = env["PATH_INFO"] || "/"
218
+ method = env["REQUEST_METHOD"]
219
+ case [method, path]
220
+ when ["GET", "/__feedback/widget.js"]
221
+ handle_widget_js(env)
222
+ when ["POST", "/__feedback/api/turn"]
223
+ handle_turn(env)
224
+ end
225
+ end
226
+
227
+ private
228
+
229
+ # Locate the supervisor (Rust agent) base URL. Mirrors the
230
+ # Tier 3 helper in dev_admin.rb so both modules agree on the
231
+ # endpoint resolution rule.
232
+ def supervisor_base
233
+ if defined?(Tina4::DevAdmin) && Tina4::DevAdmin.respond_to?(:supervisor_base, true)
234
+ return Tina4::DevAdmin.send(:supervisor_base)
235
+ end
236
+ base = ENV["TINA4_SUPERVISOR_URL"].to_s.strip
237
+ return base unless base.empty?
238
+ port = (ENV["TINA4_PORT"] || ENV["PORT"] || "7147").to_i + 2000
239
+ "http://127.0.0.1:#{port}"
240
+ end
241
+
242
+ def request_path(request)
243
+ if request.is_a?(Hash)
244
+ (request["PATH_INFO"] || request[:path] || "").to_s
245
+ elsif request.respond_to?(:path)
246
+ request.path.to_s
247
+ else
248
+ ""
249
+ end
250
+ end
251
+
252
+ # Extract Rack-style headers ({"HTTP_AUTHORIZATION" => "..."}) so
253
+ # Tina4::Auth.authenticate_request can consume them. Accepts a
254
+ # Rack env hash, a Tina4::Request, or a plain hash of headers.
255
+ def request_headers(request)
256
+ if request.is_a?(Hash) && request.keys.any? { |k| k.to_s.start_with?("HTTP_") }
257
+ return request
258
+ end
259
+ if request.respond_to?(:env)
260
+ return request.env
261
+ end
262
+ if request.respond_to?(:headers)
263
+ h = request.headers || {}
264
+ # Auth.authenticate_request looks for HTTP_AUTHORIZATION or Authorization
265
+ rack_like = {}
266
+ h.each do |k, v|
267
+ key = k.to_s
268
+ if key.downcase == "authorization"
269
+ rack_like["HTTP_AUTHORIZATION"] = v
270
+ else
271
+ rack_like[key] = v
272
+ end
273
+ end
274
+ return rack_like
275
+ end
276
+ {}
277
+ end
278
+
279
+ # Lightweight request wrapper used by handle_turn so the same
280
+ # whitelist/identity helpers work whether the caller passes a Rack
281
+ # env or a Tina4::Request.
282
+ def build_request_wrapper(env)
283
+ Struct.new(:path, :env, :headers).new(
284
+ env["PATH_INFO"] || "/",
285
+ env,
286
+ env # Rack env IS the headers source for Tina4::Auth
287
+ )
288
+ end
289
+
290
+ def read_json_body(env)
291
+ input = env["rack.input"]
292
+ return nil unless input
293
+ input.rewind if input.respond_to?(:rewind)
294
+ raw = input.read
295
+ return nil if raw.nil? || raw.empty?
296
+ JSON.parse(raw)
297
+ rescue JSON::ParserError
298
+ nil
299
+ end
300
+
301
+ def json_response(data, status = 200)
302
+ [status, { "content-type" => "application/json; charset=utf-8" },
303
+ [JSON.generate(data)]]
304
+ end
305
+ end
306
+ end
307
+ end