tina4ruby 3.13.137 → 3.13.138

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 (160) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -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 +57 -7
  12. data/lib/tina4/auth.rb +18 -12
  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 +6 -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 +6 -0
  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 +6 -0
  26. data/lib/tina4/cli.rb +22 -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 +215 -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 +8 -1
  55. data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
  56. data/lib/tina4/env.rb +6 -0
  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 +196 -56
  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 +28 -27
  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 +6 -0
  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 +15 -8
  82. data/lib/tina4/messenger.rb +86 -55
  83. data/lib/tina4/metrics.rb +6 -0
  84. data/lib/tina4/middleware.rb +24 -9
  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 +9 -3
  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 +10 -2
  109. data/lib/tina4/queue_backends/lite_backend.rb +6 -0
  110. data/lib/tina4/queue_backends/mongo_backend.rb +6 -0
  111. data/lib/tina4/queue_backends/rabbitmq_backend.rb +10 -2
  112. data/lib/tina4/rack_app.rb +11 -1
  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 +6 -0
  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 +85 -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 +6 -0
  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 +25 -8
  146. data/lib/tina4/swagger.rb +6 -0
  147. data/lib/tina4/template.rb +6 -0
  148. data/lib/tina4/test.rb +8 -2
  149. data/lib/tina4/test_client.rb +8 -1
  150. data/lib/tina4/testing.rb +8 -1
  151. data/lib/tina4/validator.rb +6 -0
  152. data/lib/tina4/version.rb +7 -1
  153. data/lib/tina4/webserver.rb +25 -143
  154. data/lib/tina4/websocket.rb +10 -4
  155. data/lib/tina4/websocket_backplane.rb +19 -2
  156. data/lib/tina4/wsdl.rb +31 -16
  157. data/lib/tina4/xml_parser.rb +259 -0
  158. data/lib/tina4.rb +87 -31
  159. data/lib/tina4ruby.rb +6 -0
  160. metadata +43 -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,21 @@ 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
+ begin
826
+ scan_root ? safe_project_path(File.join(scan_root, file_path), extra_roots: [scan_root]) : nil
827
+ rescue ArgumentError
828
+ nil
829
+ end
830
+ end
831
+ return json_response({ "error" => "Path outside project" }, 403) if !file_path.empty? && resolved.nil?
832
+
833
+ json_response(Tina4::Metrics.file_detail(file_path.empty? ? file_path : resolved))
805
834
  rescue Tina4::MetricsEngineError => e
806
835
  bad_path = e.message.include?("no such file") ||
807
836
  e.message.include?("not a file") ||
@@ -834,7 +863,7 @@ module Tina4
834
863
  input.rewind if input.respond_to?(:rewind)
835
864
  raw = input.read
836
865
  return nil if raw.nil? || raw.empty?
837
- JSON.parse(raw) rescue nil
866
+ Tina4.parse_json(raw) rescue nil
838
867
  end
839
868
 
840
869
  def json_response(data, status = 200)
@@ -869,7 +898,7 @@ module Tina4
869
898
  return { current: current, latest: nil,
870
899
  error: "RubyGems answered #{resp.code}" }
871
900
  end
872
- latest = JSON.parse(resp.body)["version"]
901
+ latest = Tina4.parse_json(resp.body)["version"]
873
902
  # Reaching RubyGems is not the same as learning the version: an
874
903
  # answer with none in it is the same lie by another route.
875
904
  if latest.nil? || latest.to_s.empty?
@@ -987,7 +1016,7 @@ module Tina4
987
1016
  limit: "N/A"
988
1017
  },
989
1018
  server: {
990
- software: "Ruby/WEBrick",
1019
+ software: "Ruby/#{Tina4::HttpServer::SOFTWARE}",
991
1020
  hostname: host_name,
992
1021
  document_root: Tina4.root_dir || Dir.pwd
993
1022
  },
@@ -1149,20 +1178,22 @@ module Tina4
1149
1178
  def grounding_token_save(body)
1150
1179
  token = (body && body["token"]).to_s.strip
1151
1180
  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
1181
+ SecretFile.update(env_path) do |content|
1182
+ lines = content.lines(chomp: true)
1183
+ found = false
1184
+ new_lines = lines.map do |line|
1185
+ stripped = line.strip
1186
+ if !stripped.empty? && !stripped.start_with?("#") && stripped.include?("=") &&
1187
+ stripped.split("=", 2).first.strip == "TINA4_MCP_TOKEN"
1188
+ found = true
1189
+ "TINA4_MCP_TOKEN=#{token}"
1190
+ else
1191
+ line
1192
+ end
1162
1193
  end
1194
+ new_lines << "TINA4_MCP_TOKEN=#{token}" unless found
1195
+ new_lines.join("\n") + "\n"
1163
1196
  end
1164
- new_lines << "TINA4_MCP_TOKEN=#{token}" unless found
1165
- File.write(env_path, new_lines.join("\n") + "\n")
1166
1197
 
1167
1198
  if token.empty?
1168
1199
  ENV.delete("TINA4_MCP_TOKEN")
@@ -1432,8 +1463,17 @@ module Tina4
1432
1463
  return { error: "No database configured" } unless db
1433
1464
 
1434
1465
  begin
1466
+ # ADR-0082: only a name the database itself reports is accepted, and
1467
+ # it is quoted as an identifier - never spliced raw into SQL.
1468
+ return { error: "unknown table", status: 404 } unless Array(db.tables).map(&:to_s).include?(table_name)
1469
+
1470
+ quoted = if db.respond_to?(:driver_name) && db.driver_name.to_s.include?("mysql")
1471
+ "`#{table_name.gsub('`', '``')}`"
1472
+ else
1473
+ "\"#{table_name.gsub('"', '""')}\""
1474
+ end
1435
1475
  columns = db.columns(table_name)
1436
- result = db.fetch("SELECT * FROM #{table_name} LIMIT 20")
1476
+ result = db.fetch("SELECT * FROM #{quoted}", [], limit: 20)
1437
1477
  rows = result.respond_to?(:to_a) ? result.to_a : (result.is_a?(Array) ? result : [])
1438
1478
  { table: table_name, columns: columns, rows: rows, count: rows.size }
1439
1479
  rescue => e
@@ -1558,45 +1598,47 @@ module Tina4
1558
1598
  return json_response({ success: false, error: "No connection URL provided" }) if url.empty?
1559
1599
  begin
1560
1600
  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
1601
+ SecretFile.update(env_path) do |content|
1602
+ lines = content.lines(chomp: true)
1603
+ # v3.12+ env vars are TINA4_-prefixed; saving bare DATABASE_URL
1604
+ # would trip the framework's legacy-env boot guard on next start.
1605
+ keys_found = {
1606
+ "TINA4_DATABASE_URL" => false,
1607
+ "TINA4_DATABASE_USERNAME" => false,
1608
+ "TINA4_DATABASE_PASSWORD" => false,
1609
+ }
1610
+ new_lines = []
1611
+ lines.each do |line|
1612
+ stripped = line.strip
1613
+ if stripped.empty? || stripped.start_with?("#") || !stripped.include?("=")
1614
+ new_lines << line
1615
+ next
1616
+ end
1617
+ key = stripped.split("=", 2).first.strip
1618
+ case key
1619
+ when "TINA4_DATABASE_URL"
1620
+ new_lines << "TINA4_DATABASE_URL=#{url}"
1621
+ keys_found["TINA4_DATABASE_URL"] = true
1622
+ when "TINA4_DATABASE_USERNAME"
1623
+ new_lines << "TINA4_DATABASE_USERNAME=#{username}"
1624
+ keys_found["TINA4_DATABASE_USERNAME"] = true
1625
+ when "TINA4_DATABASE_PASSWORD"
1626
+ new_lines << "TINA4_DATABASE_PASSWORD=#{password}"
1627
+ keys_found["TINA4_DATABASE_PASSWORD"] = true
1628
+ else
1629
+ new_lines << line
1630
+ end
1575
1631
  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
1632
+ values = {
1633
+ "TINA4_DATABASE_URL" => url,
1634
+ "TINA4_DATABASE_USERNAME" => username,
1635
+ "TINA4_DATABASE_PASSWORD" => password,
1636
+ }
1637
+ keys_found.each do |key, found|
1638
+ new_lines << "#{key}=#{values[key]}" unless found
1589
1639
  end
1640
+ new_lines.join("\n") + "\n"
1590
1641
  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
1642
  json_response({ success: true })
1601
1643
  rescue => e
1602
1644
  json_response({ success: false, error: e.message })
@@ -1656,13 +1698,39 @@ module Tina4
1656
1698
  { deployed: name, files: copied }
1657
1699
  end
1658
1700
 
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)
1701
+ # ADR-0082: resolve the path the way the OS will open it (".." collapsed,
1702
+ # symlinks followed on the deepest existing part) and require it inside an
1703
+ # allowed root, with a trailing separator so /app never contains /app-x.
1704
+ def safe_project_path(rel_path, extra_roots: [])
1705
+ root = real_path_of(File.expand_path(Dir.pwd))
1706
+ resolved = real_path_of(File.expand_path(rel_path.to_s, root))
1707
+ roots = [root] + extra_roots.compact.map { |r| real_path_of(File.expand_path(r.to_s)) }
1708
+ unless roots.any? { |r| resolved == r || resolved.start_with?(r.chomp("/") + "/") }
1709
+ raise ArgumentError, "path escapes project directory"
1710
+ end
1663
1711
  resolved
1664
1712
  end
1665
1713
 
1714
+ # Real path of +path+: the deepest existing ancestor is realpath'd and the
1715
+ # not-yet-existing tail (a new file being saved) is appended.
1716
+ def real_path_of(path)
1717
+ existing = path
1718
+ tail = []
1719
+ until File.exist?(existing) || File.symlink?(existing) || existing == File.dirname(existing)
1720
+ tail.unshift(File.basename(existing))
1721
+ existing = File.dirname(existing)
1722
+ end
1723
+ File.join(File.realpath(existing), *tail)
1724
+ rescue SystemCallError
1725
+ path
1726
+ end
1727
+
1728
+ # Project-relative form of a resolved path, for the secret denylist.
1729
+ def project_relative(resolved)
1730
+ root = real_path_of(File.expand_path(Dir.pwd))
1731
+ resolved == root ? "" : resolved.delete_prefix(root.chomp("/") + "/")
1732
+ end
1733
+
1666
1734
  # Noise dirs + hidden dot-entries are hidden from the file browser,
1667
1735
  # except the env files — verbatim parity with PHP/Python dev-admin.
1668
1736
  DEV_FILES_IGNORED = %w[__pycache__ node_modules vendor .git venv .venv dist target .tina4].freeze
@@ -1812,20 +1880,32 @@ module Tina4
1812
1880
  ext_map.fetch(File.extname(base).downcase, "text")
1813
1881
  end
1814
1882
 
1883
+ # Validate and read one descriptor; never reopen a checked pathname.
1884
+ def read_project_bytes(target)
1885
+ flags = File::RDONLY
1886
+ flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
1887
+ flags |= File::NONBLOCK if defined?(File::NONBLOCK)
1888
+ File.open(target, flags) do |file|
1889
+ file.binmode
1890
+ info = file.stat
1891
+ raise ArgumentError, "Not a regular file" unless info.file?
1892
+ file.read || "".b
1893
+ end
1894
+ end
1895
+
1815
1896
  def file_read_payload(rel)
1816
1897
  return { error: "path required" } if rel.nil? || rel.empty?
1817
1898
  # DEVADMIN-DEC-03: never serve secret material (.env, keys, secrets/).
1818
1899
  # The dispatch statuses this 403; the guard here keeps the helper safe
1819
1900
  # for any other caller too. The secret content never enters the payload.
1820
- if secret_path?(rel)
1901
+ if secret_path?(rel) || resolved_secret?(rel)
1821
1902
  return { error: "Refused: secret file", path: rel.to_s, content: "", language: "text", bytes: 0 }
1822
1903
  end
1823
1904
  begin
1824
1905
  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) }
1906
+ data = read_project_bytes(target)
1907
+ content = data.dup.force_encoding(Encoding::UTF_8).scrub
1908
+ { path: rel, content: content, bytes: data.bytesize, language: dev_admin_language(rel) }
1829
1909
  rescue => e
1830
1910
  { error: e.message }
1831
1911
  end
@@ -1834,11 +1914,10 @@ module Tina4
1834
1914
  def file_raw_response(rel)
1835
1915
  return json_response({ error: "path required" }) if rel.nil? || rel.empty?
1836
1916
  # DEVADMIN-DEC-03: never serve secret material (.env, keys, secrets/).
1837
- return json_response({ error: "Refused: secret file" }, 403) if secret_path?(rel)
1917
+ return json_response({ error: "Refused: secret file" }, 403) if secret_path?(rel) || resolved_secret?(rel)
1838
1918
  begin
1839
1919
  target = safe_project_path(rel)
1840
- return json_response({ error: "Not found" }) unless File.file?(target)
1841
- content = File.binread(target)
1920
+ content = read_project_bytes(target)
1842
1921
  ct = case File.extname(target).downcase
1843
1922
  when ".css" then "text/css"
1844
1923
  when ".js" then "application/javascript"
@@ -1851,6 +1930,8 @@ module Tina4
1851
1930
  else "text/plain; charset=utf-8"
1852
1931
  end
1853
1932
  [200, { "content-type" => ct }, [content]]
1933
+ rescue ArgumentError => e
1934
+ json_response({ error: e.message }, 403)
1854
1935
  rescue => e
1855
1936
  json_response({ error: e.message })
1856
1937
  end
@@ -1915,7 +1996,7 @@ module Tina4
1915
1996
  http.read_timeout = 8
1916
1997
  resp = http.request(Net::HTTP::Get.new(uri))
1917
1998
  if resp.is_a?(Net::HTTPSuccess)
1918
- gems = JSON.parse(resp.body)
1999
+ gems = Tina4.parse_json(resp.body)
1919
2000
  results = gems.first(20).map do |g|
1920
2001
  { name: g["name"], version: g["version"], info: g["info"].to_s[0, 200] }
1921
2002
  end
@@ -2002,20 +2083,21 @@ module Tina4
2002
2083
 
2003
2084
  # Whether the request carried a token matching TINA4_MCP_TOKEN.
2004
2085
  #
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
2086
+ # Token transports (in order): Authorization: Bearer, X-MCP-Token.
2087
+ # MCP transport retains its accepted API_KEY fallback; dedicated dev access does not.
2088
+ # With no configured token this returns false, so a
2008
2089
  # remote caller can never present a "valid" token by accident.
2009
- def mcp_token_ok?(env)
2090
+ def mcp_token_ok?(env, dedicated: false)
2091
+ # Dedicated non-MCP access never falls back to the application API key.
2010
2092
  expected = ENV["TINA4_MCP_TOKEN"]
2011
- expected = ENV["TINA4_API_KEY"] if expected.nil? || expected.empty?
2093
+ expected = ENV["TINA4_API_KEY"] if !dedicated && (expected.nil? || expected.empty?)
2012
2094
  return false if expected.nil? || expected.empty?
2013
2095
 
2014
2096
  provided = ""
2015
2097
  auth = (env["HTTP_AUTHORIZATION"] || "").to_s
2016
2098
  provided = auth[7..].to_s.strip if auth.downcase.start_with?("bearer ")
2017
2099
  provided = (env["HTTP_X_MCP_TOKEN"] || "").to_s if provided.empty?
2018
- provided = (env["HTTP_X_API_KEY"] || "").to_s if provided.empty?
2100
+ provided = (env["HTTP_X_API_KEY"] || "").to_s if !dedicated && provided.empty?
2019
2101
  return false if provided.empty?
2020
2102
 
2021
2103
  secure_equal?(expected.to_s, provided)
@@ -2037,7 +2119,7 @@ module Tina4
2037
2119
  # ── Dev-admin mutation security (feature 127, DEVADMIN-DEC-01/02/03) ──
2038
2120
  # The dashboard writes files, runs SQL and installs gems, so it must
2039
2121
  # 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.
2122
+ # every /__dev request, and a secret denylist guards the file-read surface.
2041
2123
  # Mirrors tina4_python/dev_admin _dev_same_origin_ok / _dev_mutation_denial
2042
2124
  # / _is_secret_path.
2043
2125
  DEV_SAFE_METHODS = %w[GET HEAD OPTIONS].freeze
@@ -2055,48 +2137,78 @@ module Tina4
2055
2137
  # always sends Sec-Fetch-Site (and any browser sends Origin on a
2056
2138
  # cross-origin POST), so:
2057
2139
  # - 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.
2140
+ # (cross-site and same-site refused; same-origin / none allowed).
2141
+ # - Origin present -> always require matching scheme, host and port.
2060
2142
  # - else neither -> not a browser cross-origin request at all (curl, a
2061
2143
  # test client, a server-side caller); it cannot be a drive-by, so it
2062
2144
  # is allowed here and the loopback gate still constrains the peer.
2063
2145
  def dev_same_origin_ok?(env)
2064
2146
  sfs = (env["HTTP_SEC_FETCH_SITE"] || "").strip.downcase
2065
- return %w[same-origin same-site none].include?(sfs) unless sfs.empty?
2147
+ # ADR-0082: same-site is a different origin (a sibling subdomain).
2148
+ return false unless sfs.empty? || %w[same-origin none].include?(sfs)
2066
2149
 
2067
2150
  origin = (env["HTTP_ORIGIN"] || "").strip
2068
2151
  unless origin.empty?
2069
- netloc = origin.include?("://") ? origin.split("://", 2)[1] : origin
2070
2152
  host = (env["HTTP_HOST"] || "").strip
2071
- return !host.empty? && netloc.downcase == host.downcase
2153
+ begin
2154
+ given = URI.parse(origin)
2155
+ scheme = Tina4::Request.secure_scheme?(env) ? "https" : "http"
2156
+ expected = URI.parse("#{scheme}://#{host}")
2157
+ return !host.empty? && given.scheme == expected.scheme &&
2158
+ given.host&.downcase == expected.host&.downcase && given.port == expected.port &&
2159
+ given.userinfo.nil? && given.query.nil? && given.fragment.nil? && [nil, "", "/"].include?(given.path)
2160
+ rescue URI::InvalidURIError
2161
+ return false
2162
+ end
2072
2163
  end
2073
2164
  true
2074
2165
  end
2075
2166
 
2076
2167
  # Return a 403 Rack triple to REFUSE a dev-admin write, or nil to allow.
2077
2168
  #
2078
- # Two independent fail-closed gates on every /__dev mutation:
2169
+ # Independent fail-closed boundaries on every /__dev request:
2079
2170
  # DEC-01 same-origin (all writes, incl. mcp/call) - drive-by CSRF.
2080
2171
  # DEC-02 loopback peer (all writes EXCEPT the MCP surface, which carries
2081
2172
  # its own gate) - a network-exposed debug box.
2082
2173
  # Uses the RAW socket peer (env["REMOTE_ADDR"]), never X-Forwarded-For.
2083
2174
  def dev_mutation_denial(env)
2084
- method = (env["REQUEST_METHOD"] || "").to_s.upcase
2085
- return nil if DEV_SAFE_METHODS.include?(method)
2086
2175
  path = (env["PATH_INFO"] || "").to_s
2087
2176
  return nil unless path.start_with?("/__dev")
2088
2177
 
2178
+ # Tokens never bypass the Host and Origin boundaries.
2179
+ return dev_refused("dev-admin: refused (host not allowed)") unless dev_host_allowed?(env)
2089
2180
  return dev_refused("dev-admin: refused (cross-origin request)") unless dev_same_origin_ok?(env)
2090
2181
 
2091
2182
  unless path.start_with?(*DEV_MCP_PREFIXES)
2092
2183
  remote_ip = (env["REMOTE_ADDR"] || "").to_s
2093
- unless Tina4.is_loopback?(remote_ip) || mcp_token_ok?(env)
2184
+ unless Tina4.is_loopback?(remote_ip) || mcp_token_ok?(env, dedicated: true)
2094
2185
  return dev_refused("dev-admin: refused (non-loopback peer)")
2095
2186
  end
2096
2187
  end
2097
2188
  nil
2098
2189
  end
2099
2190
 
2191
+ # ADR-0082: the Host header must name the loopback machine or the
2192
+ # configured TINA4_HOST, so a rebound DNS name cannot reach /__dev. A
2193
+ # missing Host is not a browser request; the peer gate governs it.
2194
+ def dev_host_allowed?(env)
2195
+ host = (env["HTTP_HOST"] || "").to_s.strip.downcase
2196
+ return true if host.empty?
2197
+
2198
+ name = if host.start_with?("[")
2199
+ host.include?("]") ? host[1...host.index("]")] : host
2200
+ elsif host.count(":") == 1
2201
+ host.split(":", 2).first
2202
+ else
2203
+ host
2204
+ end
2205
+ allowed = %w[localhost 127.0.0.1 ::1]
2206
+ configured = ENV["TINA4_HOST"].to_s.strip.downcase.delete("[]")
2207
+ allowed << configured unless configured.empty?
2208
+ allowed.include?(name)
2209
+ end
2210
+ public :dev_host_allowed?
2211
+
2100
2212
  def dev_refused(message)
2101
2213
  [403, { "content-type" => "application/json; charset=utf-8" },
2102
2214
  [JSON.generate({ "ok" => false, "error" => message })]]
@@ -2105,6 +2217,20 @@ module Tina4
2105
2217
  # DEVADMIN-DEC-03: true when +rel+ names secret material the file endpoints
2106
2218
  # must never serve - .env / .env.* (the .env.example template is allowed),
2107
2219
  # anything under .git/ or secrets/, and private-key material.
2220
+ # A file operation refused for leaving the project answers 403 (parity
2221
+ # with Python); other failures keep their 200 + error body.
2222
+ def file_op_response(payload)
2223
+ json_response(payload, payload[:error].to_s.include?("escapes project") ? 403 : 200)
2224
+ end
2225
+
2226
+ # ADR-0082: the secret denylist judged on the RESOLVED path, so ".env/.",
2227
+ # ".env/x/.." or a symlink to .env cannot slip past the raw-string check.
2228
+ def resolved_secret?(rel)
2229
+ secret_path?(project_relative(safe_project_path(rel)))
2230
+ rescue ArgumentError
2231
+ false
2232
+ end
2233
+
2108
2234
  def secret_path?(rel)
2109
2235
  norm = rel.to_s.tr("\\", "/").gsub(%r{\A/+|/+\z}, "").downcase
2110
2236
  return false if norm.empty?
@@ -2143,7 +2269,7 @@ module Tina4
2143
2269
  })
2144
2270
  raw = server.handle_message(payload)
2145
2271
  return {} if raw.nil? || raw.empty?
2146
- JSON.parse(raw)
2272
+ Tina4.parse_json(raw)
2147
2273
  end
2148
2274
 
2149
2275
  # 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