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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -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 +57 -7
- data/lib/tina4/auth.rb +18 -12
- 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 +6 -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 +6 -0
- 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 +6 -0
- data/lib/tina4/cli.rb +22 -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 +215 -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 +8 -1
- data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
- data/lib/tina4/env.rb +6 -0
- 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 +196 -56
- 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 +28 -27
- 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 +6 -0
- 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 +15 -8
- data/lib/tina4/messenger.rb +86 -55
- data/lib/tina4/metrics.rb +6 -0
- data/lib/tina4/middleware.rb +24 -9
- 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 +9 -3
- data/lib/tina4/query_builder.rb +6 -0
- data/lib/tina4/queue.rb +6 -0
- data/lib/tina4/queue_backends/kafka_backend.rb +10 -2
- data/lib/tina4/queue_backends/lite_backend.rb +6 -0
- data/lib/tina4/queue_backends/mongo_backend.rb +6 -0
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +10 -2
- data/lib/tina4/rack_app.rb +11 -1
- 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 +6 -0
- 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 +85 -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 +6 -0
- 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 +25 -8
- 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 +10 -4
- data/lib/tina4/websocket_backplane.rb +19 -2
- data/lib/tina4/wsdl.rb +31 -16
- data/lib/tina4/xml_parser.rb +259 -0
- data/lib/tina4.rb +87 -31
- data/lib/tina4ruby.rb +6 -0
- metadata +43 -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,21 @@ 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
|
+
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
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 #{
|
|
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
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
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
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
#
|
|
2007
|
-
#
|
|
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
|
|
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
|
|
2059
|
-
# -
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
2272
|
+
Tina4.parse_json(raw)
|
|
2147
2273
|
end
|
|
2148
2274
|
|
|
2149
2275
|
# 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
|