tina4ruby 3.13.38 → 3.13.40
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/README.md +7 -7
- data/lib/tina4/api.rb +43 -1
- data/lib/tina4/auth.rb +5 -1
- data/lib/tina4/cli.rb +4 -0
- data/lib/tina4/database.rb +51 -6
- data/lib/tina4/dev_admin.rb +92 -9
- data/lib/tina4/docstore.rb +753 -0
- data/lib/tina4/env.rb +7 -2
- data/lib/tina4/field_types.rb +5 -2
- data/lib/tina4/log.rb +86 -10
- data/lib/tina4/mcp.rb +101 -9
- data/lib/tina4/metrics.rb +115 -28
- data/lib/tina4/migration.rb +107 -20
- data/lib/tina4/orm.rb +182 -21
- data/lib/tina4/query_builder.rb +22 -3
- data/lib/tina4/queue_backends/kafka_backend.rb +39 -2
- data/lib/tina4/rack_app.rb +22 -5
- data/lib/tina4/router.rb +34 -4
- data/lib/tina4/swagger.rb +166 -32
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +105 -4
- data/lib/tina4.rb +81 -3
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0bb742a8cdfa909ee27c95cce703f14f678f03f93b764aea62501b4afde78e91
|
|
4
|
+
data.tar.gz: 3e0da501f8911d1df438ecebdab6b2f8ef67be7b66d39927672c23f089b351c4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1f6998cc599c7ebae85a3a0020b8146721ca653a84f7f65635226072781aac2ad41ccec3cf3a749063960e3f1bae47c3e3eca97fe58375d6c01c0a5df7359487
|
|
7
|
+
data.tar.gz: ce0653713c6f9e0659b5f26a6068690db7915ccbc8cdeb87ca54c8cfe609c2b700f6e90a5f6ca90b8f6c198d33edbcea70599bb050855259504fd831f16b90f6
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<img src="https://tina4.com/logo.svg" alt="Tina4" width="200">
|
|
3
3
|
</p>
|
|
4
4
|
<h1 align="center">Tina4 Ruby</h1>
|
|
5
|
-
<h3 align="center">TINA4
|
|
5
|
+
<h3 align="center">TINA4: The Intelligent Native Application 4ramework</h3>
|
|
6
6
|
<p align="center"><em>Simple. Fast. Human. | Built for AI. Built for you.</em></p>
|
|
7
7
|
<p align="center">55 built-in features. Zero runtime dependencies. One require, everything works.</p>
|
|
8
8
|
<p align="center">
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
## Quick Start
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
|
-
# With the Tina4 CLI (recommended
|
|
21
|
+
# With the Tina4 CLI (recommended, enables SCSS + live reload)
|
|
22
22
|
cargo install tina4 # or grab a binary from https://github.com/tina4stack/tina4/releases
|
|
23
23
|
tina4 init ruby ./my-app
|
|
24
24
|
cd my-app && tina4 serve
|
|
@@ -56,12 +56,12 @@ db = Tina4::Database.new("sqlite://app.db")
|
|
|
56
56
|
| Category | Features |
|
|
57
57
|
|----------|----------|
|
|
58
58
|
| **Core HTTP** (7) | Router with path params (`{id:int}`, `{p:path}`), Server, Request/Response, Middleware pipeline, Static file serving, CORS |
|
|
59
|
-
| **Database** (6) | SQLite, PostgreSQL, MySQL, MSSQL, Firebird
|
|
59
|
+
| **Database** (6) | SQLite, PostgreSQL, MySQL, MSSQL, Firebird: unified adapter, connection pooling, query cache, transactions, race-safe ID generation, SQL dialect translation |
|
|
60
60
|
| **ORM** (7) | Active Record with typed fields, relationships (`has_one`/`has_many`/`belongs_to`), soft delete, QueryBuilder + MongoDB support, Auto-CRUD generator, migrations with rollback |
|
|
61
61
|
| **Auth & Security** (5) | JWT (HS256/RS256), password hashing (PBKDF2-SHA256), API key validation, rate limiting, CSRF form tokens |
|
|
62
62
|
| **Templating** (3) | Frond engine (Twig/Jinja2-compatible, pre-compiled 2.8× faster), SCSS auto-compilation, built-in CSS (~24 KB) |
|
|
63
63
|
| **API & Integration** (5) | HTTP client (zero-dep), GraphQL with ORM auto-schema + GraphiQL IDE, WSDL/SOAP with auto WSDL, WebSocket (RFC 6455) + Redis backplane, MCP server (24 dev tools) |
|
|
64
|
-
| **Background** (3) | Job queue (File/RabbitMQ/Kafka/MongoDB) with priority, delay, retry, dead letters
|
|
64
|
+
| **Background** (3) | Job queue (File/RabbitMQ/Kafka/MongoDB) with priority, delay, retry, dead letters; service runner; event system (on/emit/once/off) |
|
|
65
65
|
| **Data & Storage** (4) | Session (File/Redis/Valkey/MongoDB/DB), response cache (LRU, TTL), seeder + 50+ fake data generators, messenger (SMTP/IMAP) |
|
|
66
66
|
| **Developer Tools** (7) | Dev dashboard (11 tabs), dev toolbar, error overlay (Catppuccin Mocha), dev mailbox, hot reload + CSS hot-reload, code metrics (complexity, coupling, maintainability), AI context installer (7 tools) |
|
|
67
67
|
| **Utilities** (7) | DI container (transient + singleton), HtmlElement builder, inline testing (`@tests` decorator), i18n (6 languages), Swagger/OpenAPI auto-generation, CLI scaffolding (`generate model/route/migration/middleware`), structured logging |
|
|
@@ -84,14 +84,14 @@ tina4ruby generate model <name>
|
|
|
84
84
|
|
|
85
85
|
## Performance
|
|
86
86
|
|
|
87
|
-
Benchmarked with `wrk
|
|
87
|
+
Benchmarked with `wrk`: 5,000 requests, 50 concurrent, median of 3 runs:
|
|
88
88
|
|
|
89
89
|
| Framework | JSON req/s | Deps | Features |
|
|
90
90
|
|-----------|-----------|------|----------|
|
|
91
91
|
| **Tina4 Ruby** | **10,243** | 0 | 55 |
|
|
92
92
|
| Sinatra | 9,548 | 5+ | ~4 |
|
|
93
93
|
|
|
94
|
-
Tina4 Ruby outperforms Sinatra while delivering **55 features vs ~4
|
|
94
|
+
Tina4 Ruby outperforms Sinatra while delivering **55 features vs ~4**, with zero runtime dependencies.
|
|
95
95
|
|
|
96
96
|
**Across all 4 Tina4 implementations:**
|
|
97
97
|
|
|
@@ -105,7 +105,7 @@ Tina4 Ruby outperforms Sinatra while delivering **55 features vs ~4** — with z
|
|
|
105
105
|
|
|
106
106
|
## Cross-Framework Parity
|
|
107
107
|
|
|
108
|
-
Tina4 ships identical features across four languages
|
|
108
|
+
Tina4 ships identical features across four languages: same architecture, same conventions, same 55 features:
|
|
109
109
|
|
|
110
110
|
| | Python | PHP | Ruby | Node.js |
|
|
111
111
|
|---|--------|-----|------|---------|
|
data/lib/tina4/api.rb
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require "net/http"
|
|
3
|
+
require "openssl"
|
|
3
4
|
require "uri"
|
|
4
5
|
require "json"
|
|
5
6
|
require "base64"
|
|
6
7
|
|
|
7
8
|
module Tina4
|
|
9
|
+
# Statuses that warrant an automatic retry when max_retries > 0: rate-limit
|
|
10
|
+
# (429) plus the transient server-side 5xx family. 4xx client errors (401,
|
|
11
|
+
# 404, …) are NOT retried — a repeat won't succeed. Parity with the Python
|
|
12
|
+
# master's _RETRY_STATUSES.
|
|
13
|
+
API_RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
14
|
+
|
|
8
15
|
class API
|
|
9
16
|
attr_reader :base_url, :headers
|
|
10
17
|
|
|
@@ -18,9 +25,16 @@ module Tina4
|
|
|
18
25
|
# api = Tina4::API.new("https://self-signed.local", verify_ssl: false)
|
|
19
26
|
#
|
|
20
27
|
# Bearer wins over basic-auth when both are passed.
|
|
28
|
+
#
|
|
29
|
+
# 3.13.39: +max_retries / +retry_backoff enable opt-in automatic retry with
|
|
30
|
+
# exponential backoff (default max_retries: 0 = off, non-breaking) on a
|
|
31
|
+
# transport error (APIResponse#status == 0) or a retryable status
|
|
32
|
+
# (429/5xx). A retried non-idempotent request (POST/PUT/PATCH/DELETE) may be
|
|
33
|
+
# re-sent — retries are opt-in for exactly that reason. Parity with the
|
|
34
|
+
# Python master.
|
|
21
35
|
def initialize(base_url, headers: {}, timeout: 30,
|
|
22
36
|
bearer_token: nil, username: nil, password: nil,
|
|
23
|
-
verify_ssl: nil)
|
|
37
|
+
verify_ssl: nil, max_retries: 0, retry_backoff: 0.5)
|
|
24
38
|
@base_url = base_url.chomp("/")
|
|
25
39
|
@headers = {
|
|
26
40
|
"Content-Type" => "application/json",
|
|
@@ -28,6 +42,8 @@ module Tina4
|
|
|
28
42
|
}.merge(headers)
|
|
29
43
|
@timeout = timeout
|
|
30
44
|
@verify_ssl = verify_ssl
|
|
45
|
+
@max_retries = [0, max_retries.to_i].max
|
|
46
|
+
@retry_backoff = retry_backoff.to_f
|
|
31
47
|
|
|
32
48
|
# Bearer wins over basic-auth when both passed
|
|
33
49
|
if bearer_token
|
|
@@ -142,9 +158,35 @@ module Tina4
|
|
|
142
158
|
end
|
|
143
159
|
end
|
|
144
160
|
|
|
161
|
+
# Execute the request with opt-in retry/backoff. Returns an APIResponse.
|
|
162
|
+
#
|
|
163
|
+
# With @max_retries > 0, a transport error (APIResponse#status == 0, the
|
|
164
|
+
# existing error sentinel) or a retryable status (429/5xx) is retried up to
|
|
165
|
+
# @max_retries times with exponential backoff (@retry_backoff seconds base,
|
|
166
|
+
# doubling each attempt); any other outcome (2xx, 3xx, other 4xx) returns at
|
|
167
|
+
# once. Parity with the Python master's _request.
|
|
145
168
|
def execute(uri, request)
|
|
169
|
+
attempts = @max_retries + 1
|
|
170
|
+
response = nil
|
|
171
|
+
(0...attempts).each do |attempt|
|
|
172
|
+
response = attempt_request(uri, request)
|
|
173
|
+
code = response.status
|
|
174
|
+
retryable = code.zero? || API_RETRY_STATUSES.include?(code)
|
|
175
|
+
return response if !retryable || attempt == attempts - 1
|
|
176
|
+
|
|
177
|
+
sleep(@retry_backoff * (2**attempt))
|
|
178
|
+
end
|
|
179
|
+
response
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# A single HTTP attempt. Returns the standardized APIResponse.
|
|
183
|
+
def attempt_request(uri, request)
|
|
146
184
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
147
185
|
http.use_ssl = uri.scheme == "https"
|
|
186
|
+
# 3.13.39: honour verify_ssl: false (the dead-since-3.13.1 kwarg). Only
|
|
187
|
+
# disable verification when EXPLICITLY false — nil/true keep the secure
|
|
188
|
+
# default (OpenSSL::SSL::VERIFY_PEER).
|
|
189
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE if @verify_ssl == false
|
|
148
190
|
http.open_timeout = @timeout
|
|
149
191
|
http.read_timeout = @timeout
|
|
150
192
|
|
data/lib/tina4/auth.rb
CHANGED
|
@@ -15,7 +15,11 @@ module Tina4
|
|
|
15
15
|
BLANK_SECRET_WARNING =
|
|
16
16
|
"Auth: TINA4_SECRET is not set — JWT signing is insecure. Set TINA4_SECRET " \
|
|
17
17
|
"to a random value (e.g. `openssl rand -hex 32`) in your environment or " \
|
|
18
|
-
".env before serving traffic."
|
|
18
|
+
".env before serving traffic. " \
|
|
19
|
+
"For LOCAL DEV, set TINA4_DEBUG=true and a per-machine secret is generated " \
|
|
20
|
+
"automatically into .env.local (gitignored). Seeing this warning means the " \
|
|
21
|
+
"run was NOT detected as dev - typically a container or CI without " \
|
|
22
|
+
"TINA4_DEBUG set, or TINA4_ENV=production."
|
|
19
23
|
|
|
20
24
|
class << self
|
|
21
25
|
def setup(root_dir = Dir.pwd)
|
data/lib/tina4/cli.rb
CHANGED
|
@@ -287,6 +287,10 @@ module Tina4
|
|
|
287
287
|
status_icon = r[:status] == "success" ? "OK" : "FAIL"
|
|
288
288
|
puts " [#{status_icon}] #{r[:name]}"
|
|
289
289
|
end
|
|
290
|
+
# FAIL-FAST: a failed migration must give CI a non-zero exit (parity
|
|
291
|
+
# with the Python master). Only the startup auto-migration hook
|
|
292
|
+
# swallows failures; the explicit CLI does not.
|
|
293
|
+
exit 1 if results.any? { |r| r[:status] == "failed" }
|
|
290
294
|
end
|
|
291
295
|
end
|
|
292
296
|
end
|
data/lib/tina4/database.rb
CHANGED
|
@@ -255,6 +255,17 @@ module Tina4
|
|
|
255
255
|
end
|
|
256
256
|
end
|
|
257
257
|
|
|
258
|
+
# Autocommit is ON by default — parity with Python/PHP/Node. A standalone
|
|
259
|
+
# write (execute/insert/update/delete made OUTSIDE an explicit
|
|
260
|
+
# start_transaction()/commit() block) commits on its own connection before
|
|
261
|
+
# returning, so a write actually persists. An UNSET TINA4_AUTOCOMMIT is
|
|
262
|
+
# treated as TRUE; set TINA4_AUTOCOMMIT=false for strict manual mode (every
|
|
263
|
+
# write needs an explicit commit). Inside an explicit transaction the
|
|
264
|
+
# framework-issued commit is suppressed (gated on the thread tx-pin), so
|
|
265
|
+
# explicit transactions stay atomic. Mirrors Python's
|
|
266
|
+
# DatabaseAdapter._autocommit ("true"/"1"/"yes", default "true").
|
|
267
|
+
@autocommit = truthy?(ENV.fetch("TINA4_AUTOCOMMIT", "true"))
|
|
268
|
+
|
|
258
269
|
# Register this connection so Tina4::Database.reset_request_caches can
|
|
259
270
|
# clear its request-scoped entries at the start of every HTTP request.
|
|
260
271
|
Tina4::Database.register_instance(self)
|
|
@@ -282,10 +293,11 @@ module Tina4
|
|
|
282
293
|
@driver.connect(@connection_string, username: @username, password: @password)
|
|
283
294
|
@connected = true
|
|
284
295
|
|
|
285
|
-
#
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
296
|
+
# Push the resolved autocommit setting down to the driver when it exposes a
|
|
297
|
+
# native toggle (default ON — see @autocommit in #initialize). The
|
|
298
|
+
# framework-level commit in #autocommit_standalone_write covers drivers
|
|
299
|
+
# that have no native setter.
|
|
300
|
+
@driver.autocommit = @autocommit if @driver.respond_to?(:autocommit=)
|
|
289
301
|
|
|
290
302
|
Tina4::Log.info("Database connected: #{@driver_name}")
|
|
291
303
|
rescue => e
|
|
@@ -488,7 +500,9 @@ module Tina4
|
|
|
488
500
|
placeholders = drv.placeholders(columns.length)
|
|
489
501
|
sql = "INSERT INTO #{table} (#{columns.join(', ')}) VALUES (#{placeholders})"
|
|
490
502
|
drv.execute(sql, data.values)
|
|
491
|
-
|
|
503
|
+
last_id = drv.last_insert_id
|
|
504
|
+
autocommit_standalone_write(drv)
|
|
505
|
+
{ success: true, last_id: last_id }
|
|
492
506
|
end
|
|
493
507
|
|
|
494
508
|
def update(table, data, filter = {}, params = nil)
|
|
@@ -501,6 +515,7 @@ module Tina4
|
|
|
501
515
|
sql = "UPDATE #{table} SET #{set_parts.join(', ')}"
|
|
502
516
|
sql += " WHERE #{filter}" unless filter.empty?
|
|
503
517
|
drv.execute(sql, data.values + Array(params))
|
|
518
|
+
autocommit_standalone_write(drv)
|
|
504
519
|
return { success: true }
|
|
505
520
|
end
|
|
506
521
|
|
|
@@ -510,6 +525,7 @@ module Tina4
|
|
|
510
525
|
sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
|
|
511
526
|
values = data.values + filter.values
|
|
512
527
|
drv.execute(sql, values)
|
|
528
|
+
autocommit_standalone_write(drv)
|
|
513
529
|
{ success: true }
|
|
514
530
|
end
|
|
515
531
|
|
|
@@ -528,6 +544,7 @@ module Tina4
|
|
|
528
544
|
sql = "DELETE FROM #{table}"
|
|
529
545
|
sql += " WHERE #{filter}" unless filter.empty?
|
|
530
546
|
drv.execute(sql, Array(params))
|
|
547
|
+
autocommit_standalone_write(drv)
|
|
531
548
|
return { success: true }
|
|
532
549
|
end
|
|
533
550
|
|
|
@@ -536,6 +553,7 @@ module Tina4
|
|
|
536
553
|
sql = "DELETE FROM #{table}"
|
|
537
554
|
sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
|
|
538
555
|
drv.execute(sql, filter.values)
|
|
556
|
+
autocommit_standalone_write(drv)
|
|
539
557
|
{ success: true }
|
|
540
558
|
end
|
|
541
559
|
|
|
@@ -582,8 +600,10 @@ module Tina4
|
|
|
582
600
|
# or a clean { error: } payload respectively.
|
|
583
601
|
def execute(sql, params = [])
|
|
584
602
|
cache_invalidate if @cache_enabled
|
|
585
|
-
|
|
603
|
+
drv = current_driver
|
|
604
|
+
result = drv.execute(sql, params)
|
|
586
605
|
@last_error = nil
|
|
606
|
+
autocommit_standalone_write(drv)
|
|
587
607
|
sql_upper = sql.strip.upcase
|
|
588
608
|
if sql_upper.include?("RETURNING") || sql_upper.start_with?("CALL ") ||
|
|
589
609
|
sql_upper.start_with?("EXEC ") || sql_upper.start_with?("SELECT ")
|
|
@@ -1104,6 +1124,31 @@ module Tina4
|
|
|
1104
1124
|
%w[true 1 yes on].include?((val || "").to_s.strip.downcase)
|
|
1105
1125
|
end
|
|
1106
1126
|
|
|
1127
|
+
# Durability: commit a standalone write so it actually persists.
|
|
1128
|
+
#
|
|
1129
|
+
# Called after a write (execute/insert/update/delete) issued OUTSIDE an
|
|
1130
|
+
# explicit transaction. The commit is suppressed when autocommit is off
|
|
1131
|
+
# (TINA4_AUTOCOMMIT=false, strict manual mode) OR when a transaction is open
|
|
1132
|
+
# on this thread (the thread tx-pin is set) — so an explicit
|
|
1133
|
+
# start_transaction()/commit() block stays atomic and is never broken up by
|
|
1134
|
+
# a per-statement commit. A commit with no transaction in progress is a
|
|
1135
|
+
# harmless no-op on every engine (SQLite swallows the specific
|
|
1136
|
+
# "no transaction is active" error in its driver; PostgreSQL/MySQL/MSSQL emit
|
|
1137
|
+
# at most a benign warning), so this never raises in the common case. Mirrors
|
|
1138
|
+
# the `not self._in_transaction and self.autocommit` gate in the Python
|
|
1139
|
+
# master and PHP's `autoCommit && transaction === null`.
|
|
1140
|
+
def autocommit_standalone_write(drv)
|
|
1141
|
+
return unless @autocommit
|
|
1142
|
+
return unless Thread.current[@tx_pin_key].nil?
|
|
1143
|
+
|
|
1144
|
+
drv.commit
|
|
1145
|
+
rescue StandardError => e
|
|
1146
|
+
# A standalone write already succeeded; a follow-up commit failure here
|
|
1147
|
+
# must not mask that. Capture for #get_error and log, but don't raise.
|
|
1148
|
+
@last_error = e.message
|
|
1149
|
+
Tina4::Log.warning("autocommit commit after standalone write failed: #{e.message}")
|
|
1150
|
+
end
|
|
1151
|
+
|
|
1107
1152
|
# "persistent" / "request" / "off" — mirrors Python connection.py.
|
|
1108
1153
|
def cache_mode
|
|
1109
1154
|
if @cache_persistent
|
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -312,6 +312,19 @@ module Tina4
|
|
|
312
312
|
@error_tracker ||= ErrorTracker.new
|
|
313
313
|
end
|
|
314
314
|
|
|
315
|
+
# Drop the lazily-memoized dev singletons so the next access rebuilds
|
|
316
|
+
# them from the CURRENT environment. The mailbox in particular resolves
|
|
317
|
+
# its directory from `TINA4_MAILBOX_DIR`/`data/mailbox` at construction
|
|
318
|
+
# time, so a singleton built under one env must not leak into a later
|
|
319
|
+
# caller (or test) running under a different env. Safe to call anytime —
|
|
320
|
+
# it only nils the caches.
|
|
321
|
+
def reset_singletons!
|
|
322
|
+
@message_log = nil
|
|
323
|
+
@request_inspector = nil
|
|
324
|
+
@mailbox = nil
|
|
325
|
+
@error_tracker = nil
|
|
326
|
+
end
|
|
327
|
+
|
|
315
328
|
def enabled?
|
|
316
329
|
Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
|
|
317
330
|
end
|
|
@@ -631,19 +644,25 @@ module Tina4
|
|
|
631
644
|
json_response(deps_install(body))
|
|
632
645
|
when ["GET", "/__dev/api/git/status"]
|
|
633
646
|
json_response(git_status_payload)
|
|
647
|
+
# All four MCP surfaces (REST shim + JSON-RPC + SSE) go through
|
|
648
|
+
# with_mcp_gate: capability (Tina4.mcp_enabled?) decides whether MCP
|
|
649
|
+
# runs at all (off → route behaves as unmounted, nil → RackApp 404,
|
|
650
|
+
# no info leak); per-request authorisation on the RAW socket peer +
|
|
651
|
+
# token decides whether THIS caller may use it (loopback always,
|
|
652
|
+
# remote only with TINA4_MCP_REMOTE + a valid TINA4_MCP_TOKEN — denied
|
|
653
|
+
# → 404 mcp_forbidden). The dev tools expose powerful ops (DB query,
|
|
654
|
+
# file read/WRITE, route listing). Mirrors the Python master gate.
|
|
634
655
|
when ["GET", "/__dev/api/mcp/tools"]
|
|
635
|
-
json_response(mcp_tools_list)
|
|
656
|
+
with_mcp_gate(env) { json_response(mcp_tools_list) }
|
|
636
657
|
when ["POST", "/__dev/api/mcp/call"]
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
# on the same enabled? (TINA4_DEBUG) check, so disabled → 404. They
|
|
642
|
-
# share the default MCP server's tool registry with the REST shim.
|
|
658
|
+
with_mcp_gate(env) do
|
|
659
|
+
body = read_json_body(env) || {}
|
|
660
|
+
json_response(mcp_tool_call(body))
|
|
661
|
+
end
|
|
643
662
|
when ["POST", "/__dev/mcp"], ["POST", "/__dev/mcp/message"]
|
|
644
|
-
mcp_jsonrpc(env)
|
|
663
|
+
with_mcp_gate(env) { mcp_jsonrpc(env) }
|
|
645
664
|
when ["GET", "/__dev/mcp/sse"]
|
|
646
|
-
mcp_sse_handshake
|
|
665
|
+
with_mcp_gate(env) { mcp_sse_handshake }
|
|
647
666
|
when ["GET", "/__dev/api/scaffold"]
|
|
648
667
|
json_response(scaffold_templates)
|
|
649
668
|
when ["POST", "/__dev/api/scaffold/run"]
|
|
@@ -1570,6 +1589,70 @@ module Tina4
|
|
|
1570
1589
|
end
|
|
1571
1590
|
end
|
|
1572
1591
|
|
|
1592
|
+
# Gate an MCP surface. Yields to the handler block only for an authorised
|
|
1593
|
+
# caller. Returns:
|
|
1594
|
+
# nil — capability off (route behaves as unmounted → 404, no
|
|
1595
|
+
# "MCP exists" info leak; matches PHP not mounting it)
|
|
1596
|
+
# mcp_forbidden — capability on but this caller is denied (explicit 404)
|
|
1597
|
+
# block result — authorised caller proceeds
|
|
1598
|
+
def with_mcp_gate(env)
|
|
1599
|
+
return nil unless Tina4.mcp_enabled?
|
|
1600
|
+
return mcp_forbidden unless mcp_request_allowed?(env)
|
|
1601
|
+
|
|
1602
|
+
yield
|
|
1603
|
+
end
|
|
1604
|
+
|
|
1605
|
+
# 404 payload for a disallowed MCP request (parity with Python/PHP).
|
|
1606
|
+
def mcp_forbidden
|
|
1607
|
+
[404, { "content-type" => "application/json; charset=utf-8" },
|
|
1608
|
+
[JSON.generate({ "error" => "MCP forbidden" })]]
|
|
1609
|
+
end
|
|
1610
|
+
|
|
1611
|
+
# Per-request MCP authorisation using the RAW socket peer.
|
|
1612
|
+
#
|
|
1613
|
+
# Reads env["REMOTE_ADDR"] (never X-Forwarded-For — spoofable) and the
|
|
1614
|
+
# token, then delegates to Tina4.request_allowed?. Loopback is always
|
|
1615
|
+
# allowed; a remote caller needs TINA4_MCP_REMOTE=true plus a valid
|
|
1616
|
+
# token. Mirrors the Python dev_admin _mcp_request_allowed.
|
|
1617
|
+
def mcp_request_allowed?(env)
|
|
1618
|
+
remote_ip = (env["REMOTE_ADDR"] || "").to_s
|
|
1619
|
+
Tina4.request_allowed?(remote_ip, has_valid_token: mcp_token_ok?(env))
|
|
1620
|
+
end
|
|
1621
|
+
|
|
1622
|
+
# Whether the request carried a token matching TINA4_MCP_TOKEN.
|
|
1623
|
+
#
|
|
1624
|
+
# Token transports (in order): Authorization: Bearer, X-MCP-Token,
|
|
1625
|
+
# X-Api-Key. Compared timing-safe against TINA4_MCP_TOKEN (fallback
|
|
1626
|
+
# TINA4_API_KEY). With NO configured token this returns false, so a
|
|
1627
|
+
# remote caller can never present a "valid" token by accident.
|
|
1628
|
+
def mcp_token_ok?(env)
|
|
1629
|
+
expected = ENV["TINA4_MCP_TOKEN"]
|
|
1630
|
+
expected = ENV["TINA4_API_KEY"] if expected.nil? || expected.empty?
|
|
1631
|
+
return false if expected.nil? || expected.empty?
|
|
1632
|
+
|
|
1633
|
+
provided = ""
|
|
1634
|
+
auth = (env["HTTP_AUTHORIZATION"] || "").to_s
|
|
1635
|
+
provided = auth[7..].to_s.strip if auth.downcase.start_with?("bearer ")
|
|
1636
|
+
provided = (env["HTTP_X_MCP_TOKEN"] || "").to_s if provided.empty?
|
|
1637
|
+
provided = (env["HTTP_X_API_KEY"] || "").to_s if provided.empty?
|
|
1638
|
+
return false if provided.empty?
|
|
1639
|
+
|
|
1640
|
+
secure_equal?(expected.to_s, provided)
|
|
1641
|
+
end
|
|
1642
|
+
|
|
1643
|
+
# Constant-time string comparison (length check first so the
|
|
1644
|
+
# fixed-length compare never raises on a mismatch).
|
|
1645
|
+
def secure_equal?(expected, provided)
|
|
1646
|
+
return false unless expected.bytesize == provided.bytesize
|
|
1647
|
+
|
|
1648
|
+
OpenSSL.fixed_length_secure_compare(expected, provided)
|
|
1649
|
+
rescue StandardError
|
|
1650
|
+
# Fallback for very old OpenSSL without fixed_length_secure_compare.
|
|
1651
|
+
res = 0
|
|
1652
|
+
expected.bytes.zip(provided.bytes) { |a, b| res |= (a ^ b.to_i) }
|
|
1653
|
+
res.zero?
|
|
1654
|
+
end
|
|
1655
|
+
|
|
1573
1656
|
def mcp_tools_list
|
|
1574
1657
|
return { tools: [], count: 0 } unless defined?(Tina4::McpServer)
|
|
1575
1658
|
server = Tina4._default_mcp_server
|