tina4ruby 3.13.137 → 3.13.139

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.
Files changed (161) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/COMMERCIAL-LICENSE.md +8 -0
  4. data/LICENSE +373 -0
  5. data/LICENSE.txt +373 -21
  6. data/NOTICE +11 -0
  7. data/README.md +11 -7
  8. data/exe/tina4ruby +6 -0
  9. data/lib/tina4/ai.rb +6 -0
  10. data/lib/tina4/ai_client.rb +10 -3
  11. data/lib/tina4/api.rb +74 -8
  12. data/lib/tina4/auth.rb +108 -26
  13. data/lib/tina4/auto_crud.rb +83 -27
  14. data/lib/tina4/background.rb +7 -1
  15. data/lib/tina4/base64.rb +59 -0
  16. data/lib/tina4/cache.rb +6 -0
  17. data/lib/tina4/cache_backends/base_backend.rb +14 -0
  18. data/lib/tina4/cache_backends/database_backend.rb +6 -0
  19. data/lib/tina4/cache_backends/file_backend.rb +6 -0
  20. data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
  21. data/lib/tina4/cache_backends/memory_backend.rb +6 -0
  22. data/lib/tina4/cache_backends/mongo_backend.rb +22 -1
  23. data/lib/tina4/cache_backends/redis_backend.rb +6 -0
  24. data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
  25. data/lib/tina4/cache_backends.rb +9 -1
  26. data/lib/tina4/cli.rb +27 -7
  27. data/lib/tina4/constants.rb +12 -8
  28. data/lib/tina4/container.rb +6 -0
  29. data/lib/tina4/context/chunker.rb +6 -0
  30. data/lib/tina4/context.rb +13 -5
  31. data/lib/tina4/cors.rb +66 -10
  32. data/lib/tina4/crud.rb +78 -9
  33. data/lib/tina4/database/sqlite3_adapter.rb +9 -1
  34. data/lib/tina4/database.rb +260 -44
  35. data/lib/tina4/database_adapter.rb +6 -0
  36. data/lib/tina4/database_result.rb +6 -0
  37. data/lib/tina4/database_url.rb +6 -0
  38. data/lib/tina4/debug.rb +6 -0
  39. data/lib/tina4/dev.rb +6 -0
  40. data/lib/tina4/dev_admin.rb +222 -89
  41. data/lib/tina4/dev_mailbox.rb +6 -1
  42. data/lib/tina4/dispatch_pipeline.rb +86 -1
  43. data/lib/tina4/docs.rb +6 -0
  44. data/lib/tina4/docstore.rb +23 -4
  45. data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
  46. data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
  47. data/lib/tina4/drivers/firebird_driver.rb +95 -6
  48. data/lib/tina4/drivers/mongodb_driver.rb +6 -0
  49. data/lib/tina4/drivers/mssql_driver.rb +54 -39
  50. data/lib/tina4/drivers/mysql_driver.rb +6 -0
  51. data/lib/tina4/drivers/odbc_driver.rb +6 -0
  52. data/lib/tina4/drivers/postgres_driver.rb +35 -8
  53. data/lib/tina4/drivers/schema_split.rb +6 -0
  54. data/lib/tina4/drivers/sqlite_driver.rb +16 -1
  55. data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
  56. data/lib/tina4/env.rb +14 -6
  57. data/lib/tina4/error_overlay.rb +6 -0
  58. data/lib/tina4/events.rb +6 -0
  59. data/lib/tina4/feedback.rb +9 -2
  60. data/lib/tina4/field_types.rb +6 -0
  61. data/lib/tina4/form_parser.rb +241 -0
  62. data/lib/tina4/frond.rb +200 -58
  63. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
  64. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
  65. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
  66. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
  67. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
  68. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
  69. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
  70. data/lib/tina4/graph.rb +6 -0
  71. data/lib/tina4/graphql.rb +134 -35
  72. data/lib/tina4/health.rb +6 -0
  73. data/lib/tina4/html_element.rb +6 -0
  74. data/lib/tina4/http_server.rb +690 -0
  75. data/lib/tina4/imap_client.rb +352 -0
  76. data/lib/tina4/import_helper.rb +6 -0
  77. data/lib/tina4/job.rb +21 -2
  78. data/lib/tina4/localization.rb +6 -0
  79. data/lib/tina4/log.rb +6 -0
  80. data/lib/tina4/mail_socket.rb +124 -0
  81. data/lib/tina4/mcp.rb +18 -9
  82. data/lib/tina4/messenger.rb +86 -55
  83. data/lib/tina4/metrics.rb +6 -0
  84. data/lib/tina4/middleware.rb +27 -10
  85. data/lib/tina4/migration.rb +6 -0
  86. data/lib/tina4/model_collection.rb +6 -0
  87. data/lib/tina4/mqtt.rb +9 -2
  88. data/lib/tina4/mqtt_message.rb +6 -0
  89. data/lib/tina4/orm.rb +72 -16
  90. data/lib/tina4/parse_json.rb +25 -0
  91. data/lib/tina4/plan.rb +9 -2
  92. data/lib/tina4/point.rb +6 -0
  93. data/lib/tina4/port_takeover.rb +6 -0
  94. data/lib/tina4/project_index.rb +6 -0
  95. data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
  96. data/lib/tina4/public/bundled-component-provenance.json +48 -0
  97. data/lib/tina4/public/js/tina4js.min.js +13 -3
  98. data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
  99. data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
  100. data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
  101. data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
  102. data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
  103. data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
  104. data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
  105. data/lib/tina4/push.rb +22 -4
  106. data/lib/tina4/query_builder.rb +6 -0
  107. data/lib/tina4/queue.rb +6 -0
  108. data/lib/tina4/queue_backends/kafka_backend.rb +18 -2
  109. data/lib/tina4/queue_backends/lite_backend.rb +18 -0
  110. data/lib/tina4/queue_backends/mongo_backend.rb +18 -0
  111. data/lib/tina4/queue_backends/rabbitmq_backend.rb +18 -2
  112. data/lib/tina4/rack_app.rb +62 -50
  113. data/lib/tina4/rate_limiter.rb +6 -0
  114. data/lib/tina4/realtime/attachment.rb +6 -0
  115. data/lib/tina4/realtime/channel.rb +6 -0
  116. data/lib/tina4/realtime/channel_member.rb +6 -0
  117. data/lib/tina4/realtime/local_storage.rb +6 -0
  118. data/lib/tina4/realtime/message.rb +6 -0
  119. data/lib/tina4/realtime/s3_storage.rb +15 -1
  120. data/lib/tina4/realtime/storage.rb +6 -0
  121. data/lib/tina4/realtime/storage_backend.rb +6 -0
  122. data/lib/tina4/realtime/workspace.rb +6 -0
  123. data/lib/tina4/realtime.rb +10 -3
  124. data/lib/tina4/request.rb +90 -127
  125. data/lib/tina4/response.rb +71 -9
  126. data/lib/tina4/response_cache.rb +6 -0
  127. data/lib/tina4/router.rb +6 -0
  128. data/lib/tina4/secret_file.rb +34 -0
  129. data/lib/tina4/seeder.rb +9 -3
  130. data/lib/tina4/service.rb +8 -2
  131. data/lib/tina4/service_runner.rb +6 -0
  132. data/lib/tina4/session.rb +6 -0
  133. data/lib/tina4/session_handlers/database_handler.rb +42 -12
  134. data/lib/tina4/session_handlers/file_handler.rb +6 -0
  135. data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
  136. data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
  137. data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
  138. data/lib/tina4/session_handlers/redis_handler.rb +6 -0
  139. data/lib/tina4/session_handlers/resp_client.rb +6 -0
  140. data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
  141. data/lib/tina4/shutdown.rb +17 -14
  142. data/lib/tina4/smtp_client.rb +222 -0
  143. data/lib/tina4/sql_translator.rb +93 -0
  144. data/lib/tina4/sqlite3_gem.rb +27 -0
  145. data/lib/tina4/sso.rb +52 -12
  146. data/lib/tina4/ssrf.rb +148 -0
  147. data/lib/tina4/swagger.rb +6 -0
  148. data/lib/tina4/template.rb +6 -0
  149. data/lib/tina4/test.rb +8 -2
  150. data/lib/tina4/test_client.rb +8 -1
  151. data/lib/tina4/testing.rb +8 -1
  152. data/lib/tina4/validator.rb +6 -0
  153. data/lib/tina4/version.rb +7 -1
  154. data/lib/tina4/webserver.rb +25 -143
  155. data/lib/tina4/websocket.rb +170 -20
  156. data/lib/tina4/websocket_backplane.rb +25 -2
  157. data/lib/tina4/wsdl.rb +54 -18
  158. data/lib/tina4/xml_parser.rb +268 -0
  159. data/lib/tina4.rb +98 -33
  160. data/lib/tina4ruby.rb +6 -0
  161. metadata +44 -140
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require "json"
4
10
  require "digest"
@@ -10,6 +16,8 @@ require "shellwords"
10
16
  require "rbconfig" # RbConfig.ruby for scaffold_run's Open3.capture3 call to exe/tina4ruby
11
17
  require "open3" # scaffold_run shells to exe/tina4ruby generate <kind> <name>
12
18
  require_relative "metrics"
19
+ require_relative "parse_json"
20
+ require_relative "secret_file"
13
21
 
14
22
  module Tina4
15
23
  # Thread-safe in-memory message log for dev dashboard
@@ -385,8 +393,10 @@ module Tina4
385
393
  path = env["PATH_INFO"] || "/"
386
394
  method = env["REQUEST_METHOD"]
387
395
 
396
+ # ADR-0082: ONE gate for every /__dev request, reads and writes alike -
397
+ # Host allow-list (DNS rebinding), same-origin, loopback peer.
388
398
  # DEVADMIN-DEC-01/02 (feature 127): fail-closed same-origin + loopback
389
- # gate on every /__dev write, before ANY handler runs. Closes drive-by
399
+ # gate on every /__dev request, before ANY handler runs. Closes drive-by
390
400
  # CSRF (a page the developer also has open POSTing to /file/save then
391
401
  # /reload) and a network-exposed debug box. GET/HEAD/OPTIONS skip it;
392
402
  # the MCP surface keeps its own richer 404 gate (with_mcp_gate), so the
@@ -562,7 +572,8 @@ module Tina4
562
572
  case [method, path]
563
573
  when ["GET", "/__dev/api/table"]
564
574
  table_name = query_param(env, "name")
565
- json_response(table_detail_payload(table_name))
575
+ payload = table_detail_payload(table_name)
576
+ json_response(payload.except(:status), payload[:status] || 200)
566
577
  when ["POST", "/__dev/api/seed"]
567
578
  handle_seed(env)
568
579
  when ["POST", "/__dev/api/tool"]
@@ -629,18 +640,22 @@ module Tina4
629
640
  rel = query_param(env, "path")
630
641
  # DEVADMIN-DEC-03: a secret path refuses with 403 (parity with Python);
631
642
  # file_read_payload also guards so the body never carries the content.
632
- json_response(file_read_payload(rel), secret_path?(rel) ? 403 : 200)
643
+ # ADR-0082: judged on the RESOLVED path too; a path outside the
644
+ # project is refused 403 like Python.
645
+ payload = file_read_payload(rel)
646
+ refused = secret_path?(rel) || resolved_secret?(rel) || payload[:error].to_s.include?("escapes project")
647
+ json_response(payload, refused ? 403 : 200)
633
648
  when ["GET", "/__dev/api/file/raw"]
634
649
  file_raw_response(query_param(env, "path"))
635
650
  when ["POST", "/__dev/api/file/save"]
636
651
  body = read_json_body(env) || {}
637
- json_response(file_save(body))
652
+ file_op_response(file_save(body))
638
653
  when ["POST", "/__dev/api/file/rename"]
639
654
  body = read_json_body(env) || {}
640
- json_response(file_rename(body))
655
+ file_op_response(file_rename(body))
641
656
  when ["POST", "/__dev/api/file/delete"]
642
657
  body = read_json_body(env) || {}
643
- json_response(file_delete(body))
658
+ file_op_response(file_delete(body))
644
659
  when ["GET", "/__dev/api/deps/search"]
645
660
  json_response(deps_search(query_param(env, "q") || query_param(env, "query") || ""))
646
661
  when ["POST", "/__dev/api/deps/install"]
@@ -801,7 +816,28 @@ module Tina4
801
816
  # (503).
802
817
  def metrics_file_response(env)
803
818
  file_path = (query_param(env, "path") || "").to_s
804
- json_response(Tina4::Metrics.file_detail(file_path))
819
+ # ADR-0082: the path must resolve inside the project or the last scan
820
+ # root; an absolute path elsewhere is refused before the engine runs.
821
+ scan_root = Tina4::Metrics.instance_variable_get(:@last_scan_root)
822
+ resolved = begin
823
+ safe_project_path(file_path, extra_roots: [scan_root])
824
+ rescue ArgumentError
825
+ # A RELATIVE path may be retried against the last scan root; an
826
+ # absolute path that resolved outside the project is simply outside -
827
+ # never re-based under the scan root (File.join would fold it into a
828
+ # bogus in-root path that 404s instead of refusing 403). Parity with
829
+ # Python/Node: outside -> 403.
830
+ if scan_root && !file_path.empty? && !File.absolute_path?(file_path)
831
+ begin
832
+ safe_project_path(File.join(scan_root, file_path), extra_roots: [scan_root])
833
+ rescue ArgumentError
834
+ nil
835
+ end
836
+ end
837
+ end
838
+ return json_response({ "error" => "Path outside project" }, 403) if !file_path.empty? && resolved.nil?
839
+
840
+ json_response(Tina4::Metrics.file_detail(file_path.empty? ? file_path : resolved))
805
841
  rescue Tina4::MetricsEngineError => e
806
842
  bad_path = e.message.include?("no such file") ||
807
843
  e.message.include?("not a file") ||
@@ -834,7 +870,7 @@ module Tina4
834
870
  input.rewind if input.respond_to?(:rewind)
835
871
  raw = input.read
836
872
  return nil if raw.nil? || raw.empty?
837
- JSON.parse(raw) rescue nil
873
+ Tina4.parse_json(raw) rescue nil
838
874
  end
839
875
 
840
876
  def json_response(data, status = 200)
@@ -869,7 +905,7 @@ module Tina4
869
905
  return { current: current, latest: nil,
870
906
  error: "RubyGems answered #{resp.code}" }
871
907
  end
872
- latest = JSON.parse(resp.body)["version"]
908
+ latest = Tina4.parse_json(resp.body)["version"]
873
909
  # Reaching RubyGems is not the same as learning the version: an
874
910
  # answer with none in it is the same lie by another route.
875
911
  if latest.nil? || latest.to_s.empty?
@@ -987,7 +1023,7 @@ module Tina4
987
1023
  limit: "N/A"
988
1024
  },
989
1025
  server: {
990
- software: "Ruby/WEBrick",
1026
+ software: "Ruby/#{Tina4::HttpServer::SOFTWARE}",
991
1027
  hostname: host_name,
992
1028
  document_root: Tina4.root_dir || Dir.pwd
993
1029
  },
@@ -1149,20 +1185,22 @@ module Tina4
1149
1185
  def grounding_token_save(body)
1150
1186
  token = (body && body["token"]).to_s.strip
1151
1187
  env_path = File.join(Dir.pwd, ".env")
1152
- lines = File.file?(env_path) ? File.readlines(env_path, chomp: true) : []
1153
- found = false
1154
- new_lines = lines.map do |line|
1155
- stripped = line.strip
1156
- if !stripped.empty? && !stripped.start_with?("#") && stripped.include?("=") &&
1157
- stripped.split("=", 2).first.strip == "TINA4_MCP_TOKEN"
1158
- found = true
1159
- "TINA4_MCP_TOKEN=#{token}"
1160
- else
1161
- line
1188
+ SecretFile.update(env_path) do |content|
1189
+ lines = content.lines(chomp: true)
1190
+ found = false
1191
+ new_lines = lines.map do |line|
1192
+ stripped = line.strip
1193
+ if !stripped.empty? && !stripped.start_with?("#") && stripped.include?("=") &&
1194
+ stripped.split("=", 2).first.strip == "TINA4_MCP_TOKEN"
1195
+ found = true
1196
+ "TINA4_MCP_TOKEN=#{token}"
1197
+ else
1198
+ line
1199
+ end
1162
1200
  end
1201
+ new_lines << "TINA4_MCP_TOKEN=#{token}" unless found
1202
+ new_lines.join("\n") + "\n"
1163
1203
  end
1164
- new_lines << "TINA4_MCP_TOKEN=#{token}" unless found
1165
- File.write(env_path, new_lines.join("\n") + "\n")
1166
1204
 
1167
1205
  if token.empty?
1168
1206
  ENV.delete("TINA4_MCP_TOKEN")
@@ -1432,8 +1470,17 @@ module Tina4
1432
1470
  return { error: "No database configured" } unless db
1433
1471
 
1434
1472
  begin
1473
+ # ADR-0082: only a name the database itself reports is accepted, and
1474
+ # it is quoted as an identifier - never spliced raw into SQL.
1475
+ return { error: "unknown table", status: 404 } unless Array(db.tables).map(&:to_s).include?(table_name)
1476
+
1477
+ quoted = if db.respond_to?(:driver_name) && db.driver_name.to_s.include?("mysql")
1478
+ "`#{table_name.gsub('`', '``')}`"
1479
+ else
1480
+ "\"#{table_name.gsub('"', '""')}\""
1481
+ end
1435
1482
  columns = db.columns(table_name)
1436
- result = db.fetch("SELECT * FROM #{table_name} LIMIT 20")
1483
+ result = db.fetch("SELECT * FROM #{quoted}", [], limit: 20)
1437
1484
  rows = result.respond_to?(:to_a) ? result.to_a : (result.is_a?(Array) ? result : [])
1438
1485
  { table: table_name, columns: columns, rows: rows, count: rows.size }
1439
1486
  rescue => e
@@ -1558,45 +1605,47 @@ module Tina4
1558
1605
  return json_response({ success: false, error: "No connection URL provided" }) if url.empty?
1559
1606
  begin
1560
1607
  env_path = File.join(Dir.pwd, ".env")
1561
- lines = File.file?(env_path) ? File.readlines(env_path, chomp: true) : []
1562
- # v3.12+ env vars are TINA4_-prefixed; saving bare DATABASE_URL
1563
- # would trip the framework's legacy-env boot guard on next start.
1564
- keys_found = {
1565
- "TINA4_DATABASE_URL" => false,
1566
- "TINA4_DATABASE_USERNAME" => false,
1567
- "TINA4_DATABASE_PASSWORD" => false,
1568
- }
1569
- new_lines = []
1570
- lines.each do |line|
1571
- stripped = line.strip
1572
- if stripped.empty? || stripped.start_with?("#") || !stripped.include?("=")
1573
- new_lines << line
1574
- next
1608
+ SecretFile.update(env_path) do |content|
1609
+ lines = content.lines(chomp: true)
1610
+ # v3.12+ env vars are TINA4_-prefixed; saving bare DATABASE_URL
1611
+ # would trip the framework's legacy-env boot guard on next start.
1612
+ keys_found = {
1613
+ "TINA4_DATABASE_URL" => false,
1614
+ "TINA4_DATABASE_USERNAME" => false,
1615
+ "TINA4_DATABASE_PASSWORD" => false,
1616
+ }
1617
+ new_lines = []
1618
+ lines.each do |line|
1619
+ stripped = line.strip
1620
+ if stripped.empty? || stripped.start_with?("#") || !stripped.include?("=")
1621
+ new_lines << line
1622
+ next
1623
+ end
1624
+ key = stripped.split("=", 2).first.strip
1625
+ case key
1626
+ when "TINA4_DATABASE_URL"
1627
+ new_lines << "TINA4_DATABASE_URL=#{url}"
1628
+ keys_found["TINA4_DATABASE_URL"] = true
1629
+ when "TINA4_DATABASE_USERNAME"
1630
+ new_lines << "TINA4_DATABASE_USERNAME=#{username}"
1631
+ keys_found["TINA4_DATABASE_USERNAME"] = true
1632
+ when "TINA4_DATABASE_PASSWORD"
1633
+ new_lines << "TINA4_DATABASE_PASSWORD=#{password}"
1634
+ keys_found["TINA4_DATABASE_PASSWORD"] = true
1635
+ else
1636
+ new_lines << line
1637
+ end
1575
1638
  end
1576
- key = stripped.split("=", 2).first.strip
1577
- case key
1578
- when "TINA4_DATABASE_URL"
1579
- new_lines << "TINA4_DATABASE_URL=#{url}"
1580
- keys_found["TINA4_DATABASE_URL"] = true
1581
- when "TINA4_DATABASE_USERNAME"
1582
- new_lines << "TINA4_DATABASE_USERNAME=#{username}"
1583
- keys_found["TINA4_DATABASE_USERNAME"] = true
1584
- when "TINA4_DATABASE_PASSWORD"
1585
- new_lines << "TINA4_DATABASE_PASSWORD=#{password}"
1586
- keys_found["TINA4_DATABASE_PASSWORD"] = true
1587
- else
1588
- new_lines << line
1639
+ values = {
1640
+ "TINA4_DATABASE_URL" => url,
1641
+ "TINA4_DATABASE_USERNAME" => username,
1642
+ "TINA4_DATABASE_PASSWORD" => password,
1643
+ }
1644
+ keys_found.each do |key, found|
1645
+ new_lines << "#{key}=#{values[key]}" unless found
1589
1646
  end
1647
+ new_lines.join("\n") + "\n"
1590
1648
  end
1591
- values = {
1592
- "TINA4_DATABASE_URL" => url,
1593
- "TINA4_DATABASE_USERNAME" => username,
1594
- "TINA4_DATABASE_PASSWORD" => password,
1595
- }
1596
- keys_found.each do |key, found|
1597
- new_lines << "#{key}=#{values[key]}" unless found
1598
- end
1599
- File.write(env_path, new_lines.join("\n") + "\n")
1600
1649
  json_response({ success: true })
1601
1650
  rescue => e
1602
1651
  json_response({ success: false, error: e.message })
@@ -1656,13 +1705,39 @@ module Tina4
1656
1705
  { deployed: name, files: copied }
1657
1706
  end
1658
1707
 
1659
- def safe_project_path(rel_path)
1660
- root = File.expand_path(Dir.pwd)
1661
- resolved = File.expand_path(rel_path.to_s, root)
1662
- raise ArgumentError, "path escapes project directory" unless resolved.start_with?(root)
1708
+ # ADR-0082: resolve the path the way the OS will open it (".." collapsed,
1709
+ # symlinks followed on the deepest existing part) and require it inside an
1710
+ # allowed root, with a trailing separator so /app never contains /app-x.
1711
+ def safe_project_path(rel_path, extra_roots: [])
1712
+ root = real_path_of(File.expand_path(Dir.pwd))
1713
+ resolved = real_path_of(File.expand_path(rel_path.to_s, root))
1714
+ roots = [root] + extra_roots.compact.map { |r| real_path_of(File.expand_path(r.to_s)) }
1715
+ unless roots.any? { |r| resolved == r || resolved.start_with?(r.chomp("/") + "/") }
1716
+ raise ArgumentError, "path escapes project directory"
1717
+ end
1663
1718
  resolved
1664
1719
  end
1665
1720
 
1721
+ # Real path of +path+: the deepest existing ancestor is realpath'd and the
1722
+ # not-yet-existing tail (a new file being saved) is appended.
1723
+ def real_path_of(path)
1724
+ existing = path
1725
+ tail = []
1726
+ until File.exist?(existing) || File.symlink?(existing) || existing == File.dirname(existing)
1727
+ tail.unshift(File.basename(existing))
1728
+ existing = File.dirname(existing)
1729
+ end
1730
+ File.join(File.realpath(existing), *tail)
1731
+ rescue SystemCallError
1732
+ path
1733
+ end
1734
+
1735
+ # Project-relative form of a resolved path, for the secret denylist.
1736
+ def project_relative(resolved)
1737
+ root = real_path_of(File.expand_path(Dir.pwd))
1738
+ resolved == root ? "" : resolved.delete_prefix(root.chomp("/") + "/")
1739
+ end
1740
+
1666
1741
  # Noise dirs + hidden dot-entries are hidden from the file browser,
1667
1742
  # except the env files — verbatim parity with PHP/Python dev-admin.
1668
1743
  DEV_FILES_IGNORED = %w[__pycache__ node_modules vendor .git venv .venv dist target .tina4].freeze
@@ -1812,20 +1887,32 @@ module Tina4
1812
1887
  ext_map.fetch(File.extname(base).downcase, "text")
1813
1888
  end
1814
1889
 
1890
+ # Validate and read one descriptor; never reopen a checked pathname.
1891
+ def read_project_bytes(target)
1892
+ flags = File::RDONLY
1893
+ flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
1894
+ flags |= File::NONBLOCK if defined?(File::NONBLOCK)
1895
+ File.open(target, flags) do |file|
1896
+ file.binmode
1897
+ info = file.stat
1898
+ raise ArgumentError, "Not a regular file" unless info.file?
1899
+ file.read || "".b
1900
+ end
1901
+ end
1902
+
1815
1903
  def file_read_payload(rel)
1816
1904
  return { error: "path required" } if rel.nil? || rel.empty?
1817
1905
  # DEVADMIN-DEC-03: never serve secret material (.env, keys, secrets/).
1818
1906
  # The dispatch statuses this 403; the guard here keeps the helper safe
1819
1907
  # for any other caller too. The secret content never enters the payload.
1820
- if secret_path?(rel)
1908
+ if secret_path?(rel) || resolved_secret?(rel)
1821
1909
  return { error: "Refused: secret file", path: rel.to_s, content: "", language: "text", bytes: 0 }
1822
1910
  end
1823
1911
  begin
1824
1912
  target = safe_project_path(rel)
1825
- return { error: "Not found" } unless File.exist?(target)
1826
- return { error: "Not a file" } unless File.file?(target)
1827
- content = File.read(target, encoding: "utf-8", invalid: :replace, undef: :replace)
1828
- { path: rel, content: content, bytes: File.size(target), language: dev_admin_language(rel) }
1913
+ data = read_project_bytes(target)
1914
+ content = data.dup.force_encoding(Encoding::UTF_8).scrub
1915
+ { path: rel, content: content, bytes: data.bytesize, language: dev_admin_language(rel) }
1829
1916
  rescue => e
1830
1917
  { error: e.message }
1831
1918
  end
@@ -1834,11 +1921,10 @@ module Tina4
1834
1921
  def file_raw_response(rel)
1835
1922
  return json_response({ error: "path required" }) if rel.nil? || rel.empty?
1836
1923
  # DEVADMIN-DEC-03: never serve secret material (.env, keys, secrets/).
1837
- return json_response({ error: "Refused: secret file" }, 403) if secret_path?(rel)
1924
+ return json_response({ error: "Refused: secret file" }, 403) if secret_path?(rel) || resolved_secret?(rel)
1838
1925
  begin
1839
1926
  target = safe_project_path(rel)
1840
- return json_response({ error: "Not found" }) unless File.file?(target)
1841
- content = File.binread(target)
1927
+ content = read_project_bytes(target)
1842
1928
  ct = case File.extname(target).downcase
1843
1929
  when ".css" then "text/css"
1844
1930
  when ".js" then "application/javascript"
@@ -1851,6 +1937,8 @@ module Tina4
1851
1937
  else "text/plain; charset=utf-8"
1852
1938
  end
1853
1939
  [200, { "content-type" => ct }, [content]]
1940
+ rescue ArgumentError => e
1941
+ json_response({ error: e.message }, 403)
1854
1942
  rescue => e
1855
1943
  json_response({ error: e.message })
1856
1944
  end
@@ -1915,7 +2003,7 @@ module Tina4
1915
2003
  http.read_timeout = 8
1916
2004
  resp = http.request(Net::HTTP::Get.new(uri))
1917
2005
  if resp.is_a?(Net::HTTPSuccess)
1918
- gems = JSON.parse(resp.body)
2006
+ gems = Tina4.parse_json(resp.body)
1919
2007
  results = gems.first(20).map do |g|
1920
2008
  { name: g["name"], version: g["version"], info: g["info"].to_s[0, 200] }
1921
2009
  end
@@ -2002,20 +2090,21 @@ module Tina4
2002
2090
 
2003
2091
  # Whether the request carried a token matching TINA4_MCP_TOKEN.
2004
2092
  #
2005
- # Token transports (in order): Authorization: Bearer, X-MCP-Token,
2006
- # X-Api-Key. Compared timing-safe against TINA4_MCP_TOKEN (fallback
2007
- # TINA4_API_KEY). With NO configured token this returns false, so a
2093
+ # Token transports (in order): Authorization: Bearer, X-MCP-Token.
2094
+ # MCP transport retains its accepted API_KEY fallback; dedicated dev access does not.
2095
+ # With no configured token this returns false, so a
2008
2096
  # remote caller can never present a "valid" token by accident.
2009
- def mcp_token_ok?(env)
2097
+ def mcp_token_ok?(env, dedicated: false)
2098
+ # Dedicated non-MCP access never falls back to the application API key.
2010
2099
  expected = ENV["TINA4_MCP_TOKEN"]
2011
- expected = ENV["TINA4_API_KEY"] if expected.nil? || expected.empty?
2100
+ expected = ENV["TINA4_API_KEY"] if !dedicated && (expected.nil? || expected.empty?)
2012
2101
  return false if expected.nil? || expected.empty?
2013
2102
 
2014
2103
  provided = ""
2015
2104
  auth = (env["HTTP_AUTHORIZATION"] || "").to_s
2016
2105
  provided = auth[7..].to_s.strip if auth.downcase.start_with?("bearer ")
2017
2106
  provided = (env["HTTP_X_MCP_TOKEN"] || "").to_s if provided.empty?
2018
- provided = (env["HTTP_X_API_KEY"] || "").to_s if provided.empty?
2107
+ provided = (env["HTTP_X_API_KEY"] || "").to_s if !dedicated && provided.empty?
2019
2108
  return false if provided.empty?
2020
2109
 
2021
2110
  secure_equal?(expected.to_s, provided)
@@ -2037,7 +2126,7 @@ module Tina4
2037
2126
  # ── Dev-admin mutation security (feature 127, DEVADMIN-DEC-01/02/03) ──
2038
2127
  # The dashboard writes files, runs SQL and installs gems, so it must
2039
2128
  # assume the developer ALSO browses the web. Two fail-closed gates guard
2040
- # every /__dev write, and a secret denylist guards the file-read surface.
2129
+ # every /__dev request, and a secret denylist guards the file-read surface.
2041
2130
  # Mirrors tina4_python/dev_admin _dev_same_origin_ok / _dev_mutation_denial
2042
2131
  # / _is_secret_path.
2043
2132
  DEV_SAFE_METHODS = %w[GET HEAD OPTIONS].freeze
@@ -2055,48 +2144,78 @@ module Tina4
2055
2144
  # always sends Sec-Fetch-Site (and any browser sends Origin on a
2056
2145
  # cross-origin POST), so:
2057
2146
  # - Sec-Fetch-Site present -> trust the browser's own classification
2058
- # (cross-site refused; same-origin / same-site / none allowed).
2059
- # - else Origin present -> require its host to match the request Host.
2147
+ # (cross-site and same-site refused; same-origin / none allowed).
2148
+ # - Origin present -> always require matching scheme, host and port.
2060
2149
  # - else neither -> not a browser cross-origin request at all (curl, a
2061
2150
  # test client, a server-side caller); it cannot be a drive-by, so it
2062
2151
  # is allowed here and the loopback gate still constrains the peer.
2063
2152
  def dev_same_origin_ok?(env)
2064
2153
  sfs = (env["HTTP_SEC_FETCH_SITE"] || "").strip.downcase
2065
- return %w[same-origin same-site none].include?(sfs) unless sfs.empty?
2154
+ # ADR-0082: same-site is a different origin (a sibling subdomain).
2155
+ return false unless sfs.empty? || %w[same-origin none].include?(sfs)
2066
2156
 
2067
2157
  origin = (env["HTTP_ORIGIN"] || "").strip
2068
2158
  unless origin.empty?
2069
- netloc = origin.include?("://") ? origin.split("://", 2)[1] : origin
2070
2159
  host = (env["HTTP_HOST"] || "").strip
2071
- return !host.empty? && netloc.downcase == host.downcase
2160
+ begin
2161
+ given = URI.parse(origin)
2162
+ scheme = Tina4::Request.secure_scheme?(env) ? "https" : "http"
2163
+ expected = URI.parse("#{scheme}://#{host}")
2164
+ return !host.empty? && given.scheme == expected.scheme &&
2165
+ given.host&.downcase == expected.host&.downcase && given.port == expected.port &&
2166
+ given.userinfo.nil? && given.query.nil? && given.fragment.nil? && [nil, "", "/"].include?(given.path)
2167
+ rescue URI::InvalidURIError
2168
+ return false
2169
+ end
2072
2170
  end
2073
2171
  true
2074
2172
  end
2075
2173
 
2076
2174
  # Return a 403 Rack triple to REFUSE a dev-admin write, or nil to allow.
2077
2175
  #
2078
- # Two independent fail-closed gates on every /__dev mutation:
2176
+ # Independent fail-closed boundaries on every /__dev request:
2079
2177
  # DEC-01 same-origin (all writes, incl. mcp/call) - drive-by CSRF.
2080
2178
  # DEC-02 loopback peer (all writes EXCEPT the MCP surface, which carries
2081
2179
  # its own gate) - a network-exposed debug box.
2082
2180
  # Uses the RAW socket peer (env["REMOTE_ADDR"]), never X-Forwarded-For.
2083
2181
  def dev_mutation_denial(env)
2084
- method = (env["REQUEST_METHOD"] || "").to_s.upcase
2085
- return nil if DEV_SAFE_METHODS.include?(method)
2086
2182
  path = (env["PATH_INFO"] || "").to_s
2087
2183
  return nil unless path.start_with?("/__dev")
2088
2184
 
2185
+ # Tokens never bypass the Host and Origin boundaries.
2186
+ return dev_refused("dev-admin: refused (host not allowed)") unless dev_host_allowed?(env)
2089
2187
  return dev_refused("dev-admin: refused (cross-origin request)") unless dev_same_origin_ok?(env)
2090
2188
 
2091
2189
  unless path.start_with?(*DEV_MCP_PREFIXES)
2092
2190
  remote_ip = (env["REMOTE_ADDR"] || "").to_s
2093
- unless Tina4.is_loopback?(remote_ip) || mcp_token_ok?(env)
2191
+ unless Tina4.is_loopback?(remote_ip) || mcp_token_ok?(env, dedicated: true)
2094
2192
  return dev_refused("dev-admin: refused (non-loopback peer)")
2095
2193
  end
2096
2194
  end
2097
2195
  nil
2098
2196
  end
2099
2197
 
2198
+ # ADR-0082: the Host header must name the loopback machine or the
2199
+ # configured TINA4_HOST, so a rebound DNS name cannot reach /__dev. A
2200
+ # missing Host is not a browser request; the peer gate governs it.
2201
+ def dev_host_allowed?(env)
2202
+ host = (env["HTTP_HOST"] || "").to_s.strip.downcase
2203
+ return true if host.empty?
2204
+
2205
+ name = if host.start_with?("[")
2206
+ host.include?("]") ? host[1...host.index("]")] : host
2207
+ elsif host.count(":") == 1
2208
+ host.split(":", 2).first
2209
+ else
2210
+ host
2211
+ end
2212
+ allowed = %w[localhost 127.0.0.1 ::1]
2213
+ configured = ENV["TINA4_HOST"].to_s.strip.downcase.delete("[]")
2214
+ allowed << configured unless configured.empty?
2215
+ allowed.include?(name)
2216
+ end
2217
+ public :dev_host_allowed?
2218
+
2100
2219
  def dev_refused(message)
2101
2220
  [403, { "content-type" => "application/json; charset=utf-8" },
2102
2221
  [JSON.generate({ "ok" => false, "error" => message })]]
@@ -2105,6 +2224,20 @@ module Tina4
2105
2224
  # DEVADMIN-DEC-03: true when +rel+ names secret material the file endpoints
2106
2225
  # must never serve - .env / .env.* (the .env.example template is allowed),
2107
2226
  # anything under .git/ or secrets/, and private-key material.
2227
+ # A file operation refused for leaving the project answers 403 (parity
2228
+ # with Python); other failures keep their 200 + error body.
2229
+ def file_op_response(payload)
2230
+ json_response(payload, payload[:error].to_s.include?("escapes project") ? 403 : 200)
2231
+ end
2232
+
2233
+ # ADR-0082: the secret denylist judged on the RESOLVED path, so ".env/.",
2234
+ # ".env/x/.." or a symlink to .env cannot slip past the raw-string check.
2235
+ def resolved_secret?(rel)
2236
+ secret_path?(project_relative(safe_project_path(rel)))
2237
+ rescue ArgumentError
2238
+ false
2239
+ end
2240
+
2108
2241
  def secret_path?(rel)
2109
2242
  norm = rel.to_s.tr("\\", "/").gsub(%r{\A/+|/+\z}, "").downcase
2110
2243
  return false if norm.empty?
@@ -2143,7 +2276,7 @@ module Tina4
2143
2276
  })
2144
2277
  raw = server.handle_message(payload)
2145
2278
  return {} if raw.nil? || raw.empty?
2146
- JSON.parse(raw)
2279
+ Tina4.parse_json(raw)
2147
2280
  end
2148
2281
 
2149
2282
  # Native MCP JSON-RPC endpoint (POST /__dev/mcp[/message]). Real MCP
@@ -1,10 +1,15 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require "json"
4
10
  require "securerandom"
5
11
  require "time"
6
12
  require "fileutils"
7
- require "base64"
8
13
 
9
14
  module Tina4
10
15
  class DevMailbox