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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -0
- data/COMMERCIAL-LICENSE.md +8 -0
- data/LICENSE +373 -0
- data/LICENSE.txt +373 -21
- data/NOTICE +11 -0
- data/README.md +11 -7
- data/exe/tina4ruby +6 -0
- data/lib/tina4/ai.rb +6 -0
- data/lib/tina4/ai_client.rb +10 -3
- data/lib/tina4/api.rb +74 -8
- data/lib/tina4/auth.rb +108 -26
- data/lib/tina4/auto_crud.rb +83 -27
- data/lib/tina4/background.rb +7 -1
- data/lib/tina4/base64.rb +59 -0
- data/lib/tina4/cache.rb +6 -0
- data/lib/tina4/cache_backends/base_backend.rb +14 -0
- data/lib/tina4/cache_backends/database_backend.rb +6 -0
- data/lib/tina4/cache_backends/file_backend.rb +6 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
- data/lib/tina4/cache_backends/memory_backend.rb +6 -0
- data/lib/tina4/cache_backends/mongo_backend.rb +22 -1
- data/lib/tina4/cache_backends/redis_backend.rb +6 -0
- data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
- data/lib/tina4/cache_backends.rb +9 -1
- data/lib/tina4/cli.rb +27 -7
- data/lib/tina4/constants.rb +12 -8
- data/lib/tina4/container.rb +6 -0
- data/lib/tina4/context/chunker.rb +6 -0
- data/lib/tina4/context.rb +13 -5
- data/lib/tina4/cors.rb +66 -10
- data/lib/tina4/crud.rb +78 -9
- data/lib/tina4/database/sqlite3_adapter.rb +9 -1
- data/lib/tina4/database.rb +260 -44
- data/lib/tina4/database_adapter.rb +6 -0
- data/lib/tina4/database_result.rb +6 -0
- data/lib/tina4/database_url.rb +6 -0
- data/lib/tina4/debug.rb +6 -0
- data/lib/tina4/dev.rb +6 -0
- data/lib/tina4/dev_admin.rb +222 -89
- data/lib/tina4/dev_mailbox.rb +6 -1
- data/lib/tina4/dispatch_pipeline.rb +86 -1
- data/lib/tina4/docs.rb +6 -0
- data/lib/tina4/docstore.rb +23 -4
- data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
- data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
- data/lib/tina4/drivers/firebird_driver.rb +95 -6
- data/lib/tina4/drivers/mongodb_driver.rb +6 -0
- data/lib/tina4/drivers/mssql_driver.rb +54 -39
- data/lib/tina4/drivers/mysql_driver.rb +6 -0
- data/lib/tina4/drivers/odbc_driver.rb +6 -0
- data/lib/tina4/drivers/postgres_driver.rb +35 -8
- data/lib/tina4/drivers/schema_split.rb +6 -0
- data/lib/tina4/drivers/sqlite_driver.rb +16 -1
- data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
- data/lib/tina4/env.rb +14 -6
- data/lib/tina4/error_overlay.rb +6 -0
- data/lib/tina4/events.rb +6 -0
- data/lib/tina4/feedback.rb +9 -2
- data/lib/tina4/field_types.rb +6 -0
- data/lib/tina4/form_parser.rb +241 -0
- data/lib/tina4/frond.rb +200 -58
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
- data/lib/tina4/graph.rb +6 -0
- data/lib/tina4/graphql.rb +134 -35
- data/lib/tina4/health.rb +6 -0
- data/lib/tina4/html_element.rb +6 -0
- data/lib/tina4/http_server.rb +690 -0
- data/lib/tina4/imap_client.rb +352 -0
- data/lib/tina4/import_helper.rb +6 -0
- data/lib/tina4/job.rb +21 -2
- data/lib/tina4/localization.rb +6 -0
- data/lib/tina4/log.rb +6 -0
- data/lib/tina4/mail_socket.rb +124 -0
- data/lib/tina4/mcp.rb +18 -9
- data/lib/tina4/messenger.rb +86 -55
- data/lib/tina4/metrics.rb +6 -0
- data/lib/tina4/middleware.rb +27 -10
- data/lib/tina4/migration.rb +6 -0
- data/lib/tina4/model_collection.rb +6 -0
- data/lib/tina4/mqtt.rb +9 -2
- data/lib/tina4/mqtt_message.rb +6 -0
- data/lib/tina4/orm.rb +72 -16
- data/lib/tina4/parse_json.rb +25 -0
- data/lib/tina4/plan.rb +9 -2
- data/lib/tina4/point.rb +6 -0
- data/lib/tina4/port_takeover.rb +6 -0
- data/lib/tina4/project_index.rb +6 -0
- data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
- data/lib/tina4/public/bundled-component-provenance.json +48 -0
- data/lib/tina4/public/js/tina4js.min.js +13 -3
- data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
- data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
- data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
- data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
- data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
- data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
- data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
- data/lib/tina4/push.rb +22 -4
- data/lib/tina4/query_builder.rb +6 -0
- data/lib/tina4/queue.rb +6 -0
- data/lib/tina4/queue_backends/kafka_backend.rb +18 -2
- data/lib/tina4/queue_backends/lite_backend.rb +18 -0
- data/lib/tina4/queue_backends/mongo_backend.rb +18 -0
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +18 -2
- data/lib/tina4/rack_app.rb +62 -50
- data/lib/tina4/rate_limiter.rb +6 -0
- data/lib/tina4/realtime/attachment.rb +6 -0
- data/lib/tina4/realtime/channel.rb +6 -0
- data/lib/tina4/realtime/channel_member.rb +6 -0
- data/lib/tina4/realtime/local_storage.rb +6 -0
- data/lib/tina4/realtime/message.rb +6 -0
- data/lib/tina4/realtime/s3_storage.rb +15 -1
- data/lib/tina4/realtime/storage.rb +6 -0
- data/lib/tina4/realtime/storage_backend.rb +6 -0
- data/lib/tina4/realtime/workspace.rb +6 -0
- data/lib/tina4/realtime.rb +10 -3
- data/lib/tina4/request.rb +90 -127
- data/lib/tina4/response.rb +71 -9
- data/lib/tina4/response_cache.rb +6 -0
- data/lib/tina4/router.rb +6 -0
- data/lib/tina4/secret_file.rb +34 -0
- data/lib/tina4/seeder.rb +9 -3
- data/lib/tina4/service.rb +8 -2
- data/lib/tina4/service_runner.rb +6 -0
- data/lib/tina4/session.rb +6 -0
- data/lib/tina4/session_handlers/database_handler.rb +42 -12
- data/lib/tina4/session_handlers/file_handler.rb +6 -0
- data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
- data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
- data/lib/tina4/session_handlers/redis_handler.rb +6 -0
- data/lib/tina4/session_handlers/resp_client.rb +6 -0
- data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
- data/lib/tina4/shutdown.rb +17 -14
- data/lib/tina4/smtp_client.rb +222 -0
- data/lib/tina4/sql_translator.rb +93 -0
- data/lib/tina4/sqlite3_gem.rb +27 -0
- data/lib/tina4/sso.rb +52 -12
- data/lib/tina4/ssrf.rb +148 -0
- data/lib/tina4/swagger.rb +6 -0
- data/lib/tina4/template.rb +6 -0
- data/lib/tina4/test.rb +8 -2
- data/lib/tina4/test_client.rb +8 -1
- data/lib/tina4/testing.rb +8 -1
- data/lib/tina4/validator.rb +6 -0
- data/lib/tina4/version.rb +7 -1
- data/lib/tina4/webserver.rb +25 -143
- data/lib/tina4/websocket.rb +170 -20
- data/lib/tina4/websocket_backplane.rb +25 -2
- data/lib/tina4/wsdl.rb +54 -18
- data/lib/tina4/xml_parser.rb +268 -0
- data/lib/tina4.rb +98 -33
- data/lib/tina4ruby.rb +6 -0
- metadata +44 -140
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
652
|
+
file_op_response(file_save(body))
|
|
638
653
|
when ["POST", "/__dev/api/file/rename"]
|
|
639
654
|
body = read_json_body(env) || {}
|
|
640
|
-
|
|
655
|
+
file_op_response(file_rename(body))
|
|
641
656
|
when ["POST", "/__dev/api/file/delete"]
|
|
642
657
|
body = read_json_body(env) || {}
|
|
643
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
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 #{
|
|
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
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
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
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
new_lines << "
|
|
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
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
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
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
#
|
|
2007
|
-
#
|
|
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
|
|
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
|
|
2059
|
-
# -
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
2279
|
+
Tina4.parse_json(raw)
|
|
2147
2280
|
end
|
|
2148
2281
|
|
|
2149
2282
|
# Native MCP JSON-RPC endpoint (POST /__dev/mcp[/message]). Real MCP
|
data/lib/tina4/dev_mailbox.rb
CHANGED
|
@@ -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
|