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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6ed3d68931d97c88f7d7ea9128420ce4b531da04f47536c3f64abac71f3edbb3
4
- data.tar.gz: 4124c02d8ddefcb645eee1d5a52ddef31a4e2ebeecfd11fe81b7999a3784802a
3
+ metadata.gz: 0bb742a8cdfa909ee27c95cce703f14f678f03f93b764aea62501b4afde78e91
4
+ data.tar.gz: 3e0da501f8911d1df438ecebdab6b2f8ef67be7b66d39927672c23f089b351c4
5
5
  SHA512:
6
- metadata.gz: 6a5f5f009385d5e4ce97d0289db83d2d60d1bb2410019c88892fe5f6c22fc2c358654021965ebe4a8b2d159ac7958dd6b57ba41c8b879cf385cc6632428666c5
7
- data.tar.gz: 4a7d3a41ce8ca5e8283318d487745b90c726e03c648c4480807c8e2fd40910d7472e5371e0c910ef6e68e914d057d65faaa4955efaf585bc93d84919ac567de6
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 The Intelligent Native Application 4ramework</h3>
5
+ <h3 align="center">TINA4: The Intelligent Native Application 4ramework</h3>
6
6
  <p align="center"><em>Simple. Fast. Human. &nbsp;|&nbsp; 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 enables SCSS + live reload)
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 unified adapter, connection pooling, query cache, transactions, race-safe ID generation, SQL dialect translation |
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 service runner event system (on/emit/once/off) |
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` 5,000 requests, 50 concurrent, median of 3 runs:
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** with zero runtime dependencies.
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 same architecture, same conventions, same 55 features:
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
@@ -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
- # Enable autocommit if TINA4_AUTOCOMMIT env var is set
286
- if truthy?(ENV["TINA4_AUTOCOMMIT"]) && @driver.respond_to?(:autocommit=)
287
- @driver.autocommit = true
288
- end
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
- { success: true, last_id: drv.last_insert_id }
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
- result = current_driver.execute(sql, params)
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
@@ -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
- body = read_json_body(env) || {}
638
- json_response(mcp_tool_call(body))
639
- # JSON-RPC + SSE endpoints that real MCP clients (Claude Code/Desktop)
640
- # speak. Mounted on the same dispatch as the REST shim above and gated
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