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.
data/lib/tina4/mcp.rb CHANGED
@@ -21,6 +21,7 @@
21
21
  require "json"
22
22
  require "socket"
23
23
  require "fileutils"
24
+ require "open3"
24
25
 
25
26
  module Tina4
26
27
  # ── JSON-RPC 2.0 codec ────────────────────────────────────────────
@@ -457,7 +458,111 @@ module Tina4
457
458
  project_root = File.expand_path(Dir.pwd)
458
459
 
459
460
  # ── Helpers ────────────────────────────────────────
461
+ # Append a structured line to `.tina4/agent.log` AND echo to STDERR.
462
+ # Mirrors the Python `_agent_log` helper so a single log file
463
+ # captures every agent action regardless of which side of the
464
+ # stack performed it. Cheap — never blocks the caller on I/O
465
+ # failure.
466
+ agent_log = lambda do |category, message|
467
+ begin
468
+ log_dir = File.join(project_root, ".tina4")
469
+ FileUtils.mkdir_p(log_dir)
470
+ log_path = File.join(log_dir, "agent.log")
471
+ ts = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
472
+ File.open(log_path, "a") { |f| f.write("#{ts} [#{category}] #{message}\n") }
473
+ rescue
474
+ # logging must never fail the actual call
475
+ end
476
+ warn " [agent #{category}] #{message}"
477
+ end
478
+
479
+ # Paths that look like prose rather than filesystem paths. AI
480
+ # agents occasionally pass natural language ("The plan requires
481
+ # implementing...") as `path` to file_write and produce folders
482
+ # with prose for names. Returns an error string on rejection,
483
+ # nil on acceptance.
484
+ sane_path_segment = /\A[A-Za-z0-9._\-]+\z/
485
+ looks_like_prose = lambda do |rel_path|
486
+ if rel_path.nil? || rel_path.strip.empty?
487
+ break "path is empty"
488
+ end
489
+ if rel_path.length > 300
490
+ break "path too long (#{rel_path.length} chars); use a real filename"
491
+ end
492
+ bad_sequences = ["`", "\n", "\t", " ", " — ", " (", " [", "?", "*", "<", ">", "|"]
493
+ bad_hit = bad_sequences.find { |bad| rel_path.include?(bad) }
494
+ if bad_hit
495
+ break "path contains illegal character sequence #{bad_hit.inspect} — looks like prose, not a filename"
496
+ end
497
+ segment_error = nil
498
+ rel_path.split("/").each do |seg|
499
+ next if seg.empty? || seg == "." || seg == ".."
500
+ if seg.length > 80
501
+ segment_error = "path segment too long: #{seg[0, 60].inspect}… — use a short filename"
502
+ break
503
+ end
504
+ unless sane_path_segment.match?(seg)
505
+ segment_error = "path segment #{seg.inspect} contains disallowed characters — stick to [A-Za-z0-9._-]"
506
+ break
507
+ end
508
+ end
509
+ segment_error
510
+ end
511
+
512
+ # Rewrite bare top-level Tina4-conventional directories into
513
+ # their `src/<dir>/` canonical form. The framework's
514
+ # auto-discovery only scans `src/`, so a file at
515
+ # `templates/foo.twig` is dead weight — the framework never
516
+ # loads it. Mirrors normalize_coder_path() in the Python agent.
517
+ normalize_coder_path = lambda do |rel_path|
518
+ passthrough_prefixes = ["src/", "migrations/", "plan/", "tests/",
519
+ "test/", ".tina4/"]
520
+ passthrough_files = %w[app.py app.ts app.rb index.php composer.json
521
+ package.json Gemfile pyproject.toml
522
+ requirements.txt .env .env.example]
523
+ if passthrough_prefixes.any? { |p| rel_path.start_with?(p) }
524
+ next rel_path
525
+ end
526
+ if passthrough_files.include?(rel_path)
527
+ next rel_path
528
+ end
529
+ %w[routes orm templates seeds controllers models middleware].each do |d|
530
+ if rel_path.start_with?("#{d}/")
531
+ rewritten = "src/#{rel_path}"
532
+ agent_log.call("write.path_normalized", "#{rel_path} → #{rewritten}")
533
+ return rewritten
534
+ end
535
+ end
536
+ rel_path
537
+ end
538
+
539
+ # Copy `target` into `.tina4/backups/` with a timestamped name.
540
+ # Returns the relative backup path on success, nil on failure.
541
+ agent_backup = lambda do |target|
542
+ begin
543
+ next nil unless File.file?(target)
544
+ backup_dir = File.join(project_root, ".tina4", "backups")
545
+ FileUtils.mkdir_p(backup_dir)
546
+ rel = if target.start_with?("#{project_root}/")
547
+ target.sub("#{project_root}/", "")
548
+ else
549
+ File.basename(target)
550
+ end
551
+ safe = rel.gsub("/", "__").gsub("\\", "__")
552
+ ts = Time.now.utc.strftime("%Y-%m-%dT%H-%M-%SZ")
553
+ backup_name = "#{safe}.#{ts}.bak"
554
+ backup_path = File.join(backup_dir, backup_name)
555
+ File.binwrite(backup_path, File.binread(target))
556
+ ".tina4/backups/#{backup_name}"
557
+ rescue => e
558
+ agent_log.call("write.backup_failed", "#{target}: #{e.message}")
559
+ nil
560
+ end
561
+ end
562
+
460
563
  safe_path = lambda do |rel_path|
564
+ err = looks_like_prose.call(rel_path)
565
+ raise ArgumentError, "Invalid path #{rel_path.inspect}: #{err}" if err
461
566
  resolved = File.expand_path(rel_path, project_root)
462
567
  unless resolved.start_with?(project_root)
463
568
  raise ArgumentError, "Path escapes project directory: #{rel_path}"
@@ -465,6 +570,52 @@ module Tina4
465
570
  resolved
466
571
  end
467
572
 
573
+ # Try `ruby -c` on a freshly-written Ruby file to catch syntax
574
+ # errors BEFORE the next request hits the broken handler.
575
+ # Mirrors _verify_python_import() in tina4_python/mcp/tools.py.
576
+ #
577
+ # Returns nil on success (or when verification is skipped), or
578
+ # the captured error string on failure. Only checks files under
579
+ # src/ that end in .rb — skips spec_helper.rb, *_spec.rb, and
580
+ # test_*.rb because those have their own loading patterns
581
+ # (mirrors Python's skip of __init__.py / conftest.py / test_*.py).
582
+ #
583
+ # Why this matters: the AI coder repeatedly produces Ruby with
584
+ # unclosed blocks, missing `end`, dangling parens. Running
585
+ # `ruby -c` right after write catches it inline and surfaces
586
+ # the real Ruby error in the file_write response — the LLM
587
+ # sees the error on its next turn and can retry with context.
588
+ verify_ruby_syntax = lambda do |rel_path|
589
+ next nil unless rel_path.end_with?(".rb")
590
+ next nil unless rel_path.start_with?("src/")
591
+ base = File.basename(rel_path)
592
+ next nil if base == "spec_helper.rb"
593
+ next nil if base.end_with?("_spec.rb")
594
+ next nil if base.start_with?("test_")
595
+
596
+ abs_path = File.expand_path(rel_path, project_root)
597
+ begin
598
+ stdout, stderr, status = Open3.capture3("ruby", "-c", abs_path)
599
+ rescue StandardError => e
600
+ next "verification subprocess failed: #{e.message}"
601
+ end
602
+
603
+ next nil if status.success?
604
+
605
+ # Pull the first meaningful stderr line — ruby -c emits
606
+ # "<file>:<line>: syntax error, ...". Strip the absolute
607
+ # path prefix for readability.
608
+ err_lines = (stderr || "").strip.split("\n")
609
+ if err_lines.empty?
610
+ next "syntax check failed (exit #{status.exitstatus}, no stderr)"
611
+ end
612
+ line = err_lines.first.strip
613
+ # Strip the absolute project_root prefix so the error reads
614
+ # as "src/routes/foo.rb:3: syntax error, ..." instead of the
615
+ # full /Users/... path.
616
+ line.sub("#{project_root}/", "")
617
+ end
618
+
468
619
  redact_env = lambda do |key, value|
469
620
  sensitive = %w[secret password token key credential api_key]
470
621
  if sensitive.any? { |s| key.downcase.include?(s) }
@@ -549,12 +700,55 @@ module Tina4
549
700
  }, "Read a project file")
550
701
 
551
702
  server.register_tool("file_write", lambda { |path:, content:|
703
+ # 1. Coder-path normalization — rewrite bare top-level Tina4
704
+ # directories (templates/, routes/, orm/, ...) into their
705
+ # src/ canonical form before resolving.
706
+ path = normalize_coder_path.call(path)
707
+ # 2. safe_path runs looks_like_prose then sandbox check.
552
708
  p = safe_path.call(path)
709
+
710
+ old_bytes = File.file?(p) ? File.binread(p) : ""
711
+ old_size = old_bytes.bytesize
712
+ old_lines = old_bytes.count("\n")
713
+ new_bytes = content.to_s
714
+ new_size = new_bytes.bytesize
715
+ new_lines = new_bytes.count("\n")
716
+ rel = p.start_with?("#{project_root}/") ? p.sub("#{project_root}/", "") : path
717
+
718
+ # 3. Truncation guard — refuse suspicious shrinkage on
719
+ # non-trivial files (>200B → <30% of size).
720
+ if old_size > 200 && (new_size * 100) < (old_size * 30)
721
+ msg = "REFUSED #{rel} (would shrink #{old_size} → #{new_size} bytes / " \
722
+ "#{old_lines} → #{new_lines} lines, looks truncated)"
723
+ agent_log.call("write.refused", msg)
724
+ next { "error" => msg, "refused" => true, "old_bytes" => old_size, "new_bytes" => new_size }
725
+ end
726
+
727
+ # 4. Backup before overwrite.
728
+ backup_rel = old_size > 0 ? agent_backup.call(p) : nil
729
+
553
730
  FileUtils.mkdir_p(File.dirname(p))
554
- File.write(p, content, encoding: "utf-8")
555
- rel = p.sub("#{project_root}/", "")
556
- { "written" => rel, "bytes" => content.bytesize }
557
- }, "Write or update a project file")
731
+ File.write(p, new_bytes, encoding: "utf-8")
732
+
733
+ # 5. Audit log.
734
+ agent_log.call("write.ok",
735
+ "#{rel} (#{old_size}B/#{old_lines}L → #{new_size}B/#{new_lines}L, " \
736
+ "backup: #{backup_rel || '(no prior file)'})")
737
+
738
+ result = { "written" => rel, "bytes" => new_size }
739
+ result["backup"] = backup_rel if backup_rel
740
+
741
+ # 6. Post-write syntax check — catch hallucinated Ruby
742
+ # (missing `end`, unclosed parens, etc.) before the next
743
+ # request hits the broken handler.
744
+ err = verify_ruby_syntax.call(rel)
745
+ if err
746
+ result["import_error"] = err
747
+ agent_log.call("write.import_failed", "#{rel}: #{err}")
748
+ end
749
+
750
+ result
751
+ }, "Write or update a project file (with backup, truncation guard, audit log)")
558
752
 
559
753
  server.register_tool("file_list", lambda { |path: "."|
560
754
  p = safe_path.call(path)
@@ -706,25 +900,58 @@ module Tina4
706
900
 
707
901
  # ── File patch ────────────────────────────────────
708
902
  server.register_tool("file_patch", lambda { |path:, old_string:, new_string:, count: 1|
903
+ # 1. Normalize, 2. safe_path (which runs the prose check).
904
+ path = normalize_coder_path.call(path)
709
905
  p = safe_path.call(path)
710
- return { "error" => "File not found: #{path}" } unless File.file?(p)
906
+
907
+ # 3. Existence check.
908
+ next { "error" => "File not found: #{path}" } unless File.file?(p)
909
+
711
910
  original = File.read(p, encoding: "utf-8")
712
911
  occurrences = original.scan(old_string).size
713
- return { "error" => "old_string not found in #{path}" } if occurrences.zero?
912
+
913
+ # 4. Match-count guard (already-existing behaviour).
914
+ next { "error" => "old_string not found in #{path}" } if occurrences.zero?
714
915
  if occurrences != count.to_i
715
- return { "error" => "old_string appears #{occurrences} times, expected #{count}. Expand old_string to make it unique, or set count explicitly." }
916
+ next({ "error" => "old_string appears #{occurrences} times, expected #{count}. Expand old_string to make it unique, or set count explicitly." })
716
917
  end
918
+
717
919
  updated = original.sub(old_string, new_string)
718
920
  # Ruby String#sub replaces first; if count > 1, do N replacements
719
921
  if count.to_i > 1
720
922
  updated = original.dup
721
923
  count.to_i.times { updated.sub!(old_string, new_string) }
722
924
  end
925
+
926
+ rel = p.start_with?("#{project_root}/") ? p.sub("#{project_root}/", "") : path
927
+
928
+ # 5. Backup before overwrite — same path layout as file_write
929
+ # so recovery is uniform regardless of which tool touched
930
+ # the file.
931
+ backup_rel = agent_backup.call(p)
932
+
723
933
  File.write(p, updated, encoding: "utf-8")
724
- rel = p.sub("#{project_root}/", "")
934
+
725
935
  Tina4::Plan.record_action("patched", rel) if defined?(Tina4::Plan)
726
- { "patched" => rel, "replacements" => count.to_i, "bytes" => updated.bytesize }
727
- }, "Targeted edit: replace old_string with new_string in a file")
936
+
937
+ old_size = original.bytesize
938
+ new_size = updated.bytesize
939
+ agent_log.call("patch.ok",
940
+ "#{rel} (replaced #{count.to_i}× old_string, " \
941
+ "#{old_size}B → #{new_size}B, backup: #{backup_rel || '(none)'})")
942
+
943
+ result = { "patched" => rel, "replacements" => count.to_i, "bytes" => new_size }
944
+ result["backup"] = backup_rel if backup_rel
945
+
946
+ # Post-patch syntax check — same rationale as file_write.
947
+ err = verify_ruby_syntax.call(rel)
948
+ if err
949
+ result["import_error"] = err
950
+ agent_log.call("patch.import_failed", "#{rel}: #{err}")
951
+ end
952
+
953
+ result
954
+ }, "Targeted edit: replace old_string with new_string in a file (with backup + audit log)")
728
955
 
729
956
  # ── Docs tools ────────────────────────────────────
730
957
  framework_doc_paths = lambda do
data/lib/tina4/plan.rb CHANGED
@@ -125,23 +125,51 @@ module Tina4
125
125
 
126
126
  # ── Public API ─────────────────────────────────────────────
127
127
 
128
+ # All plan files — merged from `plan/` (user-curated) and
129
+ # `.tina4/plans/` (where the Rust supervisor's planner writes).
130
+ #
131
+ # Two directories exist because of a historic split: the framework
132
+ # treats `plan/` as the canonical project location, but the Rust
133
+ # agent's planner writes to `.tina4/plans/` (alongside other
134
+ # AI-state artefacts like chat history). Until those are unified we
135
+ # read both so plans created either way are discoverable. Dedup by
136
+ # filename — plan/ wins on collision. Sorted newest-first by
137
+ # filename (filenames start with unix timestamps).
128
138
  def list_plans
129
- d = plan_dir
130
139
  cur = current_name || ""
140
+ # Order matters for dedup: user-curated plan/ first, then .tina4/plans/.
141
+ dirs = []
142
+ primary = File.join(project_root, PLAN_DIR)
143
+ dirs << primary if Dir.exist?(primary)
144
+ rust_plans = File.join(project_root, ".tina4", "plans")
145
+ dirs << rust_plans if Dir.exist?(rust_plans)
146
+
147
+ seen = {}
131
148
  out = []
132
- Dir.glob(File.join(d, "*.md")).sort.each do |path|
133
- name = File.basename(path)
134
- parsed = parse(File.read(path, encoding: "utf-8"))
135
- total = parsed["steps"].size
136
- done = parsed["steps"].count { |s| s["done"] }
137
- out << {
138
- "name" => name,
139
- "title" => parsed["title"].to_s.empty? ? File.basename(name, ".md") : parsed["title"],
140
- "steps_total" => total,
141
- "steps_done" => done,
142
- "is_current" => name == cur
143
- }
149
+ dirs.each do |dir|
150
+ Dir.glob(File.join(dir, "*.md")).sort.each do |path|
151
+ name = File.basename(path)
152
+ next if seen.key?(name) # plan/ wins over .tina4/plans/ on name clash
153
+ seen[name] = true
154
+ parsed = parse(File.read(path, encoding: "utf-8"))
155
+ total = parsed["steps"].size
156
+ done = parsed["steps"].count { |s| s["done"] }
157
+ out << {
158
+ "name" => name,
159
+ "title" => parsed["title"].to_s.empty? ? File.basename(name, ".md") : parsed["title"],
160
+ "steps_total" => total,
161
+ "steps_done" => done,
162
+ "is_current" => name == cur,
163
+ # Relative path from project root — lets the SPA open the
164
+ # right file in the editor regardless of which dir the
165
+ # plan came from.
166
+ "path" => path.sub("#{project_root}/", "")
167
+ }
168
+ end
144
169
  end
170
+ # Newest first by name (filenames start with unix timestamps).
171
+ out.sort_by! { |x| x["name"] }
172
+ out.reverse!
145
173
  out
146
174
  end
147
175
 
@@ -0,0 +1,96 @@
1
+ (function(){"use strict";(()=>{try{const t=window.location.pathname||"";return t.startsWith("/__dev")||t.startsWith("/__feedback")}catch{return!1}})()?console.info("tina4-feedback-widget: skipping on developer path"):window.__tina4FeedbackLoaded?console.warn("tina4-feedback-widget already loaded; skipping"):(window.__tina4FeedbackLoaded=!0,b());function b(){const c=(getComputedStyle(document.documentElement).getPropertyValue("--primary")||"").trim()||"#3b82f6";h(c);const l=m();document.body.appendChild(l);let e=null,u;const r=[];l.addEventListener("click",()=>{if(e){e.remove(),e=null,l.style.display="";return}e=g(),document.body.appendChild(e),l.style.display="none",setTimeout(()=>e?.querySelector("textarea")?.focus(),0)});function g(){const o=document.createElement("div");o.className="tina4-fb-modal",o.innerHTML=`
2
+ <div class="tina4-fb-head">
3
+ <span class="tina4-fb-title">Tell us what's not working</span>
4
+ <button type="button" class="tina4-fb-close" aria-label="Close">×</button>
5
+ </div>
6
+ <div class="tina4-fb-context">
7
+ <span>📍 ${p(location.pathname+location.search)}</span>
8
+ <span>📐 ${window.innerWidth}×${window.innerHeight}</span>
9
+ </div>
10
+ <div class="tina4-fb-chat" role="log"></div>
11
+ <form class="tina4-fb-form">
12
+ <textarea
13
+ rows="3"
14
+ placeholder="What's hard to use here? Be specific — which field, which button, what you expected."
15
+ aria-label="Feedback message"
16
+ ></textarea>
17
+ <button type="submit" class="tina4-fb-send">Send</button>
18
+ </form>
19
+ `,o.querySelector(".tina4-fb-close")?.addEventListener("click",()=>{o.remove(),e=null,l.style.display=""});const a=o.querySelector("form");return a.addEventListener("submit",n=>{n.preventDefault();const i=a.querySelector("textarea"),f=i.value.trim();f&&(i.value="",x(f))}),s(o),o}function s(o){const a=o.querySelector(".tina4-fb-chat");if(a){if(!r.length){a.innerHTML=`<div class="tina4-fb-hint">Your feedback lands directly with the team — no email loop. We'll ask a quick follow-up if we need to.</div>`;return}a.innerHTML=r.map(n=>`<div class="tina4-fb-msg ${n.role==="user"?"tina4-fb-user":"tina4-fb-ai"}">${p(n.text)}</div>`).join(""),a.scrollTop=a.scrollHeight}}async function x(o){if(!e)return;r.push({role:"user",text:o}),s(e),d(e,!0);const a={message:o,context:{url:location.pathname+location.search,viewport:`${window.innerWidth}x${window.innerHeight}`,ua:navigator.userAgent},conversation_id:u};let n;try{const i=await fetch("/__feedback/api/turn",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)});if(n=await i.json(),!i.ok){const f=n?.error||`HTTP ${i.status}`;r.push({role:"ai",text:`Couldn't send: ${f}`}),s(e),d(e,!1);return}}catch(i){r.push({role:"ai",text:`Network issue: ${i?.message||i}`}),s(e),d(e,!1);return}if("ask"in n)u=n.conversation_id,r.push({role:"ai",text:n.ask}),s(e),d(e,!1),e?.querySelector("textarea")?.focus();else if("final"in n)r.push({role:"ai",text:`Thanks — filed as: "${n.final.title}". The team will take it from here.`}),s(e),d(e,!1),u=void 0,r.length=0,setTimeout(()=>{e?.remove(),e=null,l.style.display=""},4500);else{const i=n?.error||"unexpected response";r.push({role:"ai",text:`Issue: ${i}`}),s(e),d(e,!1)}}function d(o,a){const n=o.querySelector(".tina4-fb-send"),i=o.querySelector("textarea");n&&(n.disabled=a,n.textContent=a?"Sending…":"Send"),i&&(i.disabled=a)}}function m(){const t=document.createElement("button");return t.type="button",t.className="tina4-fb-btn",t.setAttribute("aria-label","Send feedback"),t.innerHTML="💬",t.title="Tell us what's not working",t}function h(t){const c=document.createElement("style");c.id="tina4-fb-styles",c.textContent=`
20
+ .tina4-fb-btn {
21
+ position: fixed; bottom: 1.25rem; right: 1.25rem;
22
+ width: 48px; height: 48px; border-radius: 50%; border: none;
23
+ background: ${t}; color: white; font-size: 1.4rem;
24
+ box-shadow: 0 4px 12px rgba(0,0,0,0.18); cursor: pointer;
25
+ z-index: 2147483646; transition: transform 0.15s, box-shadow 0.15s;
26
+ display: flex; align-items: center; justify-content: center;
27
+ line-height: 1; padding: 0;
28
+ }
29
+ .tina4-fb-btn:hover { transform: scale(1.06); box-shadow: 0 6px 16px rgba(0,0,0,0.22); }
30
+ .tina4-fb-btn:active { transform: scale(0.96); }
31
+ .tina4-fb-modal {
32
+ position: fixed; bottom: 5rem; right: 1.25rem;
33
+ width: 340px; max-height: 480px; display: flex; flex-direction: column;
34
+ background: #1e1e2e; color: #cdd6f4;
35
+ border: 1px solid #313244; border-radius: 8px;
36
+ box-shadow: 0 8px 32px rgba(0,0,0,0.35);
37
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
38
+ font-size: 0.85rem; z-index: 2147483647;
39
+ animation: tina4-fb-in 0.18s ease-out;
40
+ }
41
+ @keyframes tina4-fb-in {
42
+ from { opacity: 0; transform: translateY(8px); }
43
+ to { opacity: 1; transform: translateY(0); }
44
+ }
45
+ .tina4-fb-head {
46
+ display: flex; align-items: center; justify-content: space-between;
47
+ padding: 0.6rem 0.8rem; border-bottom: 1px solid #313244;
48
+ }
49
+ .tina4-fb-title { font-weight: 600; font-size: 0.9rem; }
50
+ .tina4-fb-close {
51
+ background: transparent; border: none; color: #9399b2;
52
+ font-size: 1.4rem; line-height: 1; cursor: pointer; padding: 0 0.2rem;
53
+ }
54
+ .tina4-fb-close:hover { color: #cdd6f4; }
55
+ .tina4-fb-context {
56
+ display: flex; gap: 0.6rem; padding: 0.4rem 0.8rem;
57
+ font-size: 0.7rem; color: #9399b2;
58
+ border-bottom: 1px solid #313244;
59
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
60
+ }
61
+ .tina4-fb-chat {
62
+ flex: 1; overflow-y: auto; padding: 0.5rem 0.8rem;
63
+ display: flex; flex-direction: column; gap: 0.4rem;
64
+ min-height: 80px; max-height: 280px;
65
+ }
66
+ .tina4-fb-hint {
67
+ font-size: 0.75rem; color: #9399b2; line-height: 1.4; padding: 0.3rem 0;
68
+ }
69
+ .tina4-fb-msg {
70
+ padding: 0.4rem 0.6rem; border-radius: 6px;
71
+ max-width: 85%; word-wrap: break-word; line-height: 1.35;
72
+ }
73
+ .tina4-fb-user { align-self: flex-end; background: ${t}; color: white; }
74
+ .tina4-fb-ai { align-self: flex-start; background: #313244; }
75
+ .tina4-fb-form {
76
+ display: flex; flex-direction: column; gap: 0.4rem;
77
+ padding: 0.5rem 0.8rem 0.8rem; border-top: 1px solid #313244;
78
+ }
79
+ .tina4-fb-form textarea {
80
+ width: 100%; box-sizing: border-box; resize: vertical;
81
+ min-height: 60px; font-family: inherit; font-size: 0.82rem;
82
+ padding: 0.4rem 0.5rem; border: 1px solid #313244;
83
+ background: #11111b; color: #cdd6f4; border-radius: 4px;
84
+ line-height: 1.3;
85
+ }
86
+ .tina4-fb-form textarea:focus {
87
+ outline: none; border-color: ${t};
88
+ }
89
+ .tina4-fb-send {
90
+ align-self: flex-end; padding: 0.35rem 0.9rem;
91
+ background: ${t}; color: white; border: none; border-radius: 4px;
92
+ font-size: 0.8rem; font-weight: 500; cursor: pointer;
93
+ }
94
+ .tina4-fb-send:disabled { opacity: 0.55; cursor: wait; }
95
+ .tina4-fb-send:hover:not(:disabled) { filter: brightness(1.1); }
96
+ `,document.head.appendChild(c)}function p(t){return t.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}})();
@@ -43,8 +43,18 @@ module Tina4
43
43
  path = env["PATH_INFO"] || "/"
44
44
  request_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
45
45
 
46
- # Fast-path: OPTIONS preflight
47
- return Tina4::CorsMiddleware.preflight_response(env) if method == "OPTIONS"
46
+ # Fast-path: CORS preflight. Real CORS preflight requests carry an
47
+ # Origin header AND an Access-Control-Request-Method header — the
48
+ # browser is asking "may I send this method?" before the actual
49
+ # request. If neither is present, the OPTIONS is a plain protocol-
50
+ # introspection request (link checker, monitoring probe, RFC 9110
51
+ # §9.3.7 OPTIONS) and must fall through to the router's generic
52
+ # Allow-header response. Otherwise we'd shadow the framework's own
53
+ # OPTIONS support and force every operator to hand-register CORS
54
+ # exceptions for every introspection client.
55
+ if method == "OPTIONS" && (env["HTTP_ORIGIN"] || env["HTTP_ACCESS_CONTROL_REQUEST_METHOD"])
56
+ return Tina4::CorsMiddleware.preflight_response(env)
57
+ end
48
58
 
49
59
  # WebSocket upgrade — match against registered ws_routes
50
60
  if websocket_upgrade?(env)
@@ -65,6 +75,15 @@ module Tina4
65
75
  return dev_response if dev_response
66
76
  end
67
77
 
78
+ # Customer feedback widget routes (parity with Python's /__feedback/*
79
+ # surface — see tina4/feedback.rb). Always available — the master
80
+ # switch (TINA4_ENABLE_FEEDBACK) is enforced INSIDE handle_request
81
+ # so route shape stays stable across environments.
82
+ if path.start_with?("/__feedback")
83
+ fb_response = Tina4::Feedback.handle_request(env)
84
+ return fb_response if fb_response
85
+ end
86
+
68
87
  # Fast-path: API routes skip static file + swagger checks entirely
69
88
  unless path.start_with?("/api/")
70
89
  # Swagger
@@ -87,8 +106,43 @@ module Tina4
87
106
  rack_response = handle_route(env, route, path_params)
88
107
  matched_pattern = route.path
89
108
  else
90
- rack_response = handle_404(path)
91
- matched_pattern = nil
109
+ # RFC 9110 conformance — before falling through to 404, check whether
110
+ # the PATH is known to the router under any OTHER method.
111
+ # - OPTIONS request → 204 with Allow header (§9.3.7)
112
+ # - Any other method (PUT on GET-only, TRACE, CONNECT, etc.)
113
+ # → 405 with Allow header (§15.5.6 + §10.2.1)
114
+ allowed = Tina4::Router.methods_allowed_for_path(path)
115
+ if !allowed.empty?
116
+ allow_header = allowed.join(", ")
117
+ if method.to_s.upcase == "OPTIONS"
118
+ rack_response = [204, { "allow" => allow_header, "content-length" => "0" }, [""]]
119
+ else
120
+ body = %({"error":"Method Not Allowed","path":"#{path}","method":"#{method}","allow":[#{allowed.map { |m| %("#{m}") }.join(",")}],"status":405})
121
+ rack_response = [405, {
122
+ "allow" => allow_header,
123
+ "content-type" => "application/json",
124
+ "content-length" => body.bytesize.to_s
125
+ }, [body]]
126
+ end
127
+ matched_pattern = nil
128
+ else
129
+ rack_response = handle_404(path)
130
+ matched_pattern = nil
131
+ end
132
+ end
133
+
134
+ # RFC 9110 §9.3.2: a HEAD response MUST NOT include content. Strip
135
+ # the body unconditionally and record what Content-Length the GET
136
+ # would have sent. Cache validators / link checkers / monitoring
137
+ # probes use that header to estimate sizes.
138
+ if method.to_s.upcase == "HEAD"
139
+ status, headers, body_parts = rack_response
140
+ joined = body_parts.respond_to?(:join) ? body_parts.join : body_parts.to_s
141
+ unless joined.empty?
142
+ new_headers = headers.dup
143
+ new_headers["content-length"] = joined.bytesize.to_s
144
+ rack_response = [status, new_headers, [""]]
145
+ end
92
146
  end
93
147
 
94
148
  # Capture request for dev inspector
@@ -118,6 +172,33 @@ module Tina4
118
172
  end
119
173
  end
120
174
 
175
+ # Customer feedback widget injection — runs LAST so its <script>
176
+ # tag survives any earlier post-processing. No-op if disabled
177
+ # (TINA4_ENABLE_FEEDBACK off), the user isn't whitelisted, the
178
+ # path is /__dev or /__feedback, or the body isn't text/html with
179
+ # a closing </body> tag. Mirrors Python's server.py call site —
180
+ # see tina4_python/core/server.py around line 1543.
181
+ begin
182
+ status, headers, body_parts = rack_response
183
+ content_type = headers["content-type"] || ""
184
+ if content_type.include?("text/html") && body_parts.respond_to?(:join)
185
+ joined = body_parts.join
186
+ if joined.include?("</body>")
187
+ injected = Tina4::Feedback.inject_feedback_widget(
188
+ Struct.new(:path, :env).new(path, env),
189
+ joined
190
+ )
191
+ if injected != joined
192
+ new_headers = headers.dup
193
+ new_headers["content-length"] = injected.bytesize.to_s if new_headers["content-length"]
194
+ rack_response = [status, new_headers, [injected]]
195
+ end
196
+ end
197
+ end
198
+ rescue StandardError
199
+ # Injection is best-effort — never break the response.
200
+ end
201
+
121
202
  # Save session and set cookie if session was used
122
203
  if result && defined?(rack_response)
123
204
  status, headers, body_parts = rack_response
data/lib/tina4/request.rb CHANGED
@@ -75,13 +75,17 @@ module Tina4
75
75
  @body_parsed = nil
76
76
  end
77
77
 
78
- # Full URL reconstruction
78
+ # Full absolute URL — scheme://host[:port]/path[?query].
79
+ # Honours X-Forwarded-Proto / X-Forwarded-Host so apps behind a proxy
80
+ # still see the URL the client used. Matches Python/PHP/Node parity.
79
81
  def url
80
- scheme = env["rack.url_scheme"] || "http"
81
- host = env["HTTP_HOST"] || env["SERVER_NAME"] || "localhost"
82
+ scheme = env["HTTP_X_FORWARDED_PROTO"] || env["rack.url_scheme"] || "http"
83
+ host = env["HTTP_X_FORWARDED_HOST"] || env["HTTP_HOST"] || env["SERVER_NAME"] || "localhost"
82
84
  port = env["SERVER_PORT"]
83
85
  url_str = "#{scheme}://#{host}"
84
- url_str += ":#{port}" if port && port != "80" && port != "443"
86
+ # Only append :port when the host doesn't already include one
87
+ # (HTTP_HOST often does) and it's not the default for the scheme.
88
+ url_str += ":#{port}" if port && !host.include?(":") && port.to_s != "80" && port.to_s != "443"
85
89
  url_str += @path
86
90
  url_str += "?#{@query_string}" unless @query_string.empty?
87
91
  url_str