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.
Files changed (161) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/COMMERCIAL-LICENSE.md +8 -0
  4. data/LICENSE +373 -0
  5. data/LICENSE.txt +373 -21
  6. data/NOTICE +11 -0
  7. data/README.md +11 -7
  8. data/exe/tina4ruby +6 -0
  9. data/lib/tina4/ai.rb +6 -0
  10. data/lib/tina4/ai_client.rb +10 -3
  11. data/lib/tina4/api.rb +74 -8
  12. data/lib/tina4/auth.rb +108 -26
  13. data/lib/tina4/auto_crud.rb +83 -27
  14. data/lib/tina4/background.rb +7 -1
  15. data/lib/tina4/base64.rb +59 -0
  16. data/lib/tina4/cache.rb +6 -0
  17. data/lib/tina4/cache_backends/base_backend.rb +14 -0
  18. data/lib/tina4/cache_backends/database_backend.rb +6 -0
  19. data/lib/tina4/cache_backends/file_backend.rb +6 -0
  20. data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
  21. data/lib/tina4/cache_backends/memory_backend.rb +6 -0
  22. data/lib/tina4/cache_backends/mongo_backend.rb +22 -1
  23. data/lib/tina4/cache_backends/redis_backend.rb +6 -0
  24. data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
  25. data/lib/tina4/cache_backends.rb +9 -1
  26. data/lib/tina4/cli.rb +27 -7
  27. data/lib/tina4/constants.rb +12 -8
  28. data/lib/tina4/container.rb +6 -0
  29. data/lib/tina4/context/chunker.rb +6 -0
  30. data/lib/tina4/context.rb +13 -5
  31. data/lib/tina4/cors.rb +66 -10
  32. data/lib/tina4/crud.rb +78 -9
  33. data/lib/tina4/database/sqlite3_adapter.rb +9 -1
  34. data/lib/tina4/database.rb +260 -44
  35. data/lib/tina4/database_adapter.rb +6 -0
  36. data/lib/tina4/database_result.rb +6 -0
  37. data/lib/tina4/database_url.rb +6 -0
  38. data/lib/tina4/debug.rb +6 -0
  39. data/lib/tina4/dev.rb +6 -0
  40. data/lib/tina4/dev_admin.rb +222 -89
  41. data/lib/tina4/dev_mailbox.rb +6 -1
  42. data/lib/tina4/dispatch_pipeline.rb +86 -1
  43. data/lib/tina4/docs.rb +6 -0
  44. data/lib/tina4/docstore.rb +23 -4
  45. data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
  46. data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
  47. data/lib/tina4/drivers/firebird_driver.rb +95 -6
  48. data/lib/tina4/drivers/mongodb_driver.rb +6 -0
  49. data/lib/tina4/drivers/mssql_driver.rb +54 -39
  50. data/lib/tina4/drivers/mysql_driver.rb +6 -0
  51. data/lib/tina4/drivers/odbc_driver.rb +6 -0
  52. data/lib/tina4/drivers/postgres_driver.rb +35 -8
  53. data/lib/tina4/drivers/schema_split.rb +6 -0
  54. data/lib/tina4/drivers/sqlite_driver.rb +16 -1
  55. data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
  56. data/lib/tina4/env.rb +14 -6
  57. data/lib/tina4/error_overlay.rb +6 -0
  58. data/lib/tina4/events.rb +6 -0
  59. data/lib/tina4/feedback.rb +9 -2
  60. data/lib/tina4/field_types.rb +6 -0
  61. data/lib/tina4/form_parser.rb +241 -0
  62. data/lib/tina4/frond.rb +200 -58
  63. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
  64. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
  65. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
  66. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
  67. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
  68. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
  69. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
  70. data/lib/tina4/graph.rb +6 -0
  71. data/lib/tina4/graphql.rb +134 -35
  72. data/lib/tina4/health.rb +6 -0
  73. data/lib/tina4/html_element.rb +6 -0
  74. data/lib/tina4/http_server.rb +690 -0
  75. data/lib/tina4/imap_client.rb +352 -0
  76. data/lib/tina4/import_helper.rb +6 -0
  77. data/lib/tina4/job.rb +21 -2
  78. data/lib/tina4/localization.rb +6 -0
  79. data/lib/tina4/log.rb +6 -0
  80. data/lib/tina4/mail_socket.rb +124 -0
  81. data/lib/tina4/mcp.rb +18 -9
  82. data/lib/tina4/messenger.rb +86 -55
  83. data/lib/tina4/metrics.rb +6 -0
  84. data/lib/tina4/middleware.rb +27 -10
  85. data/lib/tina4/migration.rb +6 -0
  86. data/lib/tina4/model_collection.rb +6 -0
  87. data/lib/tina4/mqtt.rb +9 -2
  88. data/lib/tina4/mqtt_message.rb +6 -0
  89. data/lib/tina4/orm.rb +72 -16
  90. data/lib/tina4/parse_json.rb +25 -0
  91. data/lib/tina4/plan.rb +9 -2
  92. data/lib/tina4/point.rb +6 -0
  93. data/lib/tina4/port_takeover.rb +6 -0
  94. data/lib/tina4/project_index.rb +6 -0
  95. data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
  96. data/lib/tina4/public/bundled-component-provenance.json +48 -0
  97. data/lib/tina4/public/js/tina4js.min.js +13 -3
  98. data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
  99. data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
  100. data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
  101. data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
  102. data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
  103. data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
  104. data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
  105. data/lib/tina4/push.rb +22 -4
  106. data/lib/tina4/query_builder.rb +6 -0
  107. data/lib/tina4/queue.rb +6 -0
  108. data/lib/tina4/queue_backends/kafka_backend.rb +18 -2
  109. data/lib/tina4/queue_backends/lite_backend.rb +18 -0
  110. data/lib/tina4/queue_backends/mongo_backend.rb +18 -0
  111. data/lib/tina4/queue_backends/rabbitmq_backend.rb +18 -2
  112. data/lib/tina4/rack_app.rb +62 -50
  113. data/lib/tina4/rate_limiter.rb +6 -0
  114. data/lib/tina4/realtime/attachment.rb +6 -0
  115. data/lib/tina4/realtime/channel.rb +6 -0
  116. data/lib/tina4/realtime/channel_member.rb +6 -0
  117. data/lib/tina4/realtime/local_storage.rb +6 -0
  118. data/lib/tina4/realtime/message.rb +6 -0
  119. data/lib/tina4/realtime/s3_storage.rb +15 -1
  120. data/lib/tina4/realtime/storage.rb +6 -0
  121. data/lib/tina4/realtime/storage_backend.rb +6 -0
  122. data/lib/tina4/realtime/workspace.rb +6 -0
  123. data/lib/tina4/realtime.rb +10 -3
  124. data/lib/tina4/request.rb +90 -127
  125. data/lib/tina4/response.rb +71 -9
  126. data/lib/tina4/response_cache.rb +6 -0
  127. data/lib/tina4/router.rb +6 -0
  128. data/lib/tina4/secret_file.rb +34 -0
  129. data/lib/tina4/seeder.rb +9 -3
  130. data/lib/tina4/service.rb +8 -2
  131. data/lib/tina4/service_runner.rb +6 -0
  132. data/lib/tina4/session.rb +6 -0
  133. data/lib/tina4/session_handlers/database_handler.rb +42 -12
  134. data/lib/tina4/session_handlers/file_handler.rb +6 -0
  135. data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
  136. data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
  137. data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
  138. data/lib/tina4/session_handlers/redis_handler.rb +6 -0
  139. data/lib/tina4/session_handlers/resp_client.rb +6 -0
  140. data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
  141. data/lib/tina4/shutdown.rb +17 -14
  142. data/lib/tina4/smtp_client.rb +222 -0
  143. data/lib/tina4/sql_translator.rb +93 -0
  144. data/lib/tina4/sqlite3_gem.rb +27 -0
  145. data/lib/tina4/sso.rb +52 -12
  146. data/lib/tina4/ssrf.rb +148 -0
  147. data/lib/tina4/swagger.rb +6 -0
  148. data/lib/tina4/template.rb +6 -0
  149. data/lib/tina4/test.rb +8 -2
  150. data/lib/tina4/test_client.rb +8 -1
  151. data/lib/tina4/testing.rb +8 -1
  152. data/lib/tina4/validator.rb +6 -0
  153. data/lib/tina4/version.rb +7 -1
  154. data/lib/tina4/webserver.rb +25 -143
  155. data/lib/tina4/websocket.rb +170 -20
  156. data/lib/tina4/websocket_backplane.rb +25 -2
  157. data/lib/tina4/wsdl.rb +54 -18
  158. data/lib/tina4/xml_parser.rb +268 -0
  159. data/lib/tina4.rb +98 -33
  160. data/lib/tina4ruby.rb +6 -0
  161. metadata +44 -140
data/lib/tina4/api.rb CHANGED
@@ -1,11 +1,19 @@
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 "net/http"
4
10
  require "openssl"
5
11
  require "uri"
6
12
  require "json"
7
- require "base64"
13
+ require_relative "base64"
8
14
  require "securerandom"
15
+ require_relative "parse_json"
16
+ require_relative "ssrf"
9
17
 
10
18
  module Tina4
11
19
  # Statuses that warrant an automatic retry when max_retries > 0: rate-limit
@@ -101,7 +109,7 @@ module Tina4
101
109
  def initialize(base_url, headers: {}, timeout: 30,
102
110
  bearer_token: nil, username: nil, password: nil,
103
111
  verify_ssl: nil, max_retries: 0, retry_backoff: 0.5,
104
- transport: nil, cookies: false)
112
+ transport: nil, cookies: false, allow_hosts: nil)
105
113
  if !transport.nil? && !transport.respond_to?(:call)
106
114
  raise ArgumentError, "transport must respond to #call(method, url, headers, body, timeout)"
107
115
  end
@@ -123,6 +131,8 @@ module Tina4
123
131
  @transport = transport
124
132
  @cookies_enabled = cookies ? true : false
125
133
  @cookies = {}
134
+ # SSRF guard (ADR-0084): explicit allow-list of hosts / host:port / CIDRs.
135
+ @allow_hosts = allow_hosts || []
126
136
 
127
137
  # Bearer wins over basic-auth when both passed
128
138
  if bearer_token
@@ -258,6 +268,7 @@ module Tina4
258
268
  headers = @headers.dup
259
269
  cookie = cookie_header
260
270
  headers["Cookie"] = cookie if @cookies_enabled && cookie
271
+ headers = origin_headers(uri, headers)
261
272
 
262
273
  return download_via_transport(uri, headers, dest_path) if @transport
263
274
 
@@ -277,7 +288,7 @@ module Tina4
277
288
  end
278
289
 
279
290
  def set_basic_auth(username, password)
280
- @headers["Authorization"] = "Basic #{Base64.strict_encode64("#{username}:#{password}")}"
291
+ @headers["Authorization"] = "Basic #{Tina4::Base64.strict_encode64("#{username}:#{password}")}"
281
292
  self
282
293
  end
283
294
 
@@ -447,10 +458,21 @@ module Tina4
447
458
  when "HEAD" then Net::HTTP::Head
448
459
  else raise ArgumentError, "unsupported stream method: #{method}"
449
460
  end
461
+ # SSRF guard (ADR-0084): refuse a private/internal target before connect.
462
+ begin
463
+ Tina4::Ssrf.guard_url!(uri, @allow_hosts)
464
+ rescue Tina4::SsrfError => e
465
+ raise APIStreamError.new(e.message)
466
+ end
467
+
450
468
  request = request_class.new(uri)
451
469
  apply_headers(request, headers || {})
452
470
  cookie = cookie_header
453
471
  request["Cookie"] = cookie if @cookies_enabled && cookie
472
+ unless same_origin?(uri, URI.parse(@base_url))
473
+ request.delete("Authorization")
474
+ request.delete("Cookie")
475
+ end
454
476
  if body
455
477
  request.body = body.is_a?(String) ? body : JSON.generate(body)
456
478
  request["Content-Type"] = content_type if content_type
@@ -458,12 +480,22 @@ module Tina4
458
480
  request["Content-Type"] = content_type
459
481
  end
460
482
 
483
+ # ADR-0060: connect_timeout (else TINA4_API_CONNECT_TIMEOUT, else 10s)
484
+ # bounds establishment and raises Net::OpenTimeout; timeout (else
485
+ # TINA4_API_TIMEOUT, else the client timeout) bounds the WHOLE stream and
486
+ # raises APIStreamTimeoutError. Both are Timeout::Error. `timeout` used to
487
+ # be only Net::HTTP's per-read idle timeout, so a server that kept
488
+ # dripping data streamed forever.
489
+ connect_bound = connect_timeout || stream_env_seconds("TINA4_API_CONNECT_TIMEOUT", 10.0)
490
+ total_bound = timeout || stream_env_seconds("TINA4_API_TIMEOUT", @timeout)
491
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + total_bound
492
+
461
493
  http = Net::HTTP.new(uri.host, uri.port)
462
494
  http.use_ssl = uri.scheme == "https"
463
495
  http.verify_mode = OpenSSL::SSL::VERIFY_NONE if @verify_ssl == false
464
- http.open_timeout = connect_timeout || @timeout
465
- http.read_timeout = timeout || @timeout
466
- http.write_timeout = (timeout || @timeout) if http.respond_to?(:write_timeout=)
496
+ http.open_timeout = [connect_bound, total_bound].min
497
+ http.read_timeout = total_bound
498
+ http.write_timeout = total_bound if http.respond_to?(:write_timeout=)
467
499
 
468
500
  http.start do |conn|
469
501
  conn.request(request) do |response|
@@ -473,9 +505,26 @@ module Tina4
473
505
  raise APIStreamError.new("stream returned HTTP #{status}", status)
474
506
  end
475
507
  store_cookies(response.get_fields("Set-Cookie"))
476
- response.read_body { |chunk| yield chunk }
508
+ response.read_body do |chunk|
509
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
510
+ raise APIStreamTimeoutError, "stream exceeded its total timeout of #{total_bound}s" unless remaining.positive?
511
+
512
+ # The next read may wait only for what is left of the deadline.
513
+ # tina4: bounded per read, so a server dripping single bytes inside
514
+ # one chunk header can overrun by one read at most.
515
+ conn.read_timeout = remaining
516
+ yield chunk
517
+ end
477
518
  end
478
519
  end
520
+ rescue Net::ReadTimeout, Net::WriteTimeout
521
+ raise APIStreamTimeoutError, "stream exceeded its total timeout of #{total_bound}s"
522
+ end
523
+
524
+ # A positive number of seconds from an env var; anything else falls back.
525
+ def stream_env_seconds(name, fallback)
526
+ seconds = Float(ENV[name].to_s.strip, exception: false)
527
+ seconds&.positive? ? seconds : fallback
479
528
  end
480
529
 
481
530
  def build_uri(path, params = {})
@@ -488,6 +537,10 @@ module Tina4
488
537
  uri
489
538
  end
490
539
 
540
+ def origin_headers(uri, headers)
541
+ same_origin?(uri, URI.parse(@base_url)) ? headers : strip_cross_origin(headers)
542
+ end
543
+
491
544
  def apply_headers(request, extra_headers)
492
545
  @headers.merge(extra_headers).each do |key, value|
493
546
  request[key] = value
@@ -523,6 +576,7 @@ module Tina4
523
576
  request.each_header { |key, value| headers[key] = value }
524
577
  cookie = cookie_header
525
578
  headers["cookie"] = cookie if @cookies_enabled && cookie
579
+ headers = origin_headers(uri, headers)
526
580
  body = request.body
527
581
 
528
582
  if @transport
@@ -552,6 +606,12 @@ module Tina4
552
606
  current_body = body
553
607
 
554
608
  loop do
609
+ # SSRF guard (ADR-0084): re-validated per hop, so a redirect to a
610
+ # private/internal address is refused even when the initial URL was
611
+ # public. Raises SsrfError; attempt_request/download turn it into a
612
+ # status-0 error response.
613
+ Tina4::Ssrf.guard_url!(current_uri, @allow_hosts)
614
+
555
615
  http = Net::HTTP.new(current_uri.host, current_uri.port)
556
616
  http.use_ssl = current_uri.scheme == "https"
557
617
  # Only disable verification when EXPLICITLY false — nil/true keep the
@@ -803,6 +863,12 @@ module Tina4
803
863
  end
804
864
  end
805
865
 
866
+ # A stream_* call ran past its total timeout (timeout:, else
867
+ # TINA4_API_TIMEOUT). A Timeout::Error, as Python's ApiTimeoutError is a
868
+ # TimeoutError, and deliberately NOT an APIStreamError: callers such as
869
+ # AiClient treat APIStreamError as an HTTP status failure.
870
+ class APIStreamTimeoutError < Timeout::Error; end
871
+
806
872
  class APIResponse
807
873
  attr_reader :status, :body, :headers, :error, :path
808
874
 
@@ -821,7 +887,7 @@ module Tina4
821
887
  end
822
888
 
823
889
  def json
824
- @json ||= JSON.parse(@body)
890
+ @json ||= Tina4.parse_json(@body)
825
891
  rescue JSON::ParserError, TypeError
826
892
  {}
827
893
  end
data/lib/tina4/auth.rb CHANGED
@@ -1,9 +1,17 @@
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
  require "openssl"
3
- require "base64"
9
+ require_relative "base64"
4
10
  require "json"
5
11
  require "fileutils"
6
12
  require "securerandom"
13
+ require_relative "parse_json"
14
+ require_relative "secret_file"
7
15
 
8
16
  module Tina4
9
17
  module Auth
@@ -60,13 +68,72 @@ module Tina4
60
68
  # rejected for no real reason; RFC 7519 explicitly allows "a small leeway".
61
69
  JWT_LEEWAY_SECONDS = 60
62
70
 
71
+ # Token purposes that are never an identity (ADR-0079 s1). Frond's
72
+ # form_token signs a JWT with the same TINA4_SECRET as an auth token, marked
73
+ # "type" => "form". A form token proves where a write came from, never who
74
+ # the caller is, so every identity gate refuses these purposes. valid_token
75
+ # itself stays purpose-neutral: CsrfMiddleware needs it for form tokens.
76
+ NON_IDENTITY_TOKEN_TYPES = %w[form].freeze
77
+
78
+ # Minimum HMAC key length in bytes (ADR-0079 s2): the HS256 output size,
79
+ # RFC 7518 s3.2. A blank key is the shortest case - anyone can reproduce an
80
+ # HMAC made with it.
81
+ MIN_SECRET_BYTES = 32
82
+
83
+ # The HMAC signing key is blank or shorter than MIN_SECRET_BYTES. An
84
+ # ArgumentError, Ruby's equivalent of Python's ValueError.
85
+ class InsecureSecretError < ArgumentError; end
86
+
63
87
  class << self
88
+ # Explicitly provision an RS256 key pair in <root_dir>/.keys (opt-in). The
89
+ # framework never calls this on its own: a blank TINA4_SECRET used to mint
90
+ # a key pair implicitly at boot (ADR-0079 s2).
64
91
  def setup(root_dir = Dir.pwd)
65
92
  @keys_dir = File.join(root_dir, KEYS_DIR)
66
93
  FileUtils.mkdir_p(@keys_dir)
67
94
  ensure_keys
68
95
  end
69
96
 
97
+ # Point RS256 at <root_dir>/.keys WITHOUT creating anything. Boot uses
98
+ # this, so an operator-provisioned key pair there keeps working.
99
+ def keys_root=(root_dir)
100
+ @keys_dir = File.join(root_dir, KEYS_DIR)
101
+ @private_key = nil
102
+ @public_key = nil
103
+ end
104
+
105
+ # True when a VERIFIED payload may stand for a caller's identity. A payload
106
+ # whose "type" is a reserved non-identity purpose (a Frond form token) is
107
+ # refused; application payloads are otherwise untouched.
108
+ def identity_payload?(payload)
109
+ payload.is_a?(Hash) && !NON_IDENTITY_TOKEN_TYPES.include?(payload["type"])
110
+ end
111
+
112
+ # The actionable error for a weak HMAC key, or nil when the key is usable.
113
+ def insecure_secret_message(secret)
114
+ length = secret.to_s.bytesize
115
+ return nil if length >= MIN_SECRET_BYTES
116
+
117
+ what = length.zero? ? "is not set" : "is #{length} bytes"
118
+ "Auth: TINA4_SECRET #{what}; an HMAC JWT secret must be at least " \
119
+ "#{MIN_SECRET_BYTES} bytes. Generate one with `openssl rand -hex 32` and set " \
120
+ "TINA4_SECRET in your environment or .env."
121
+ end
122
+
123
+ # Refuse to boot with a secret that makes tokens forgeable (ADR-0079 s2).
124
+ # Outside dev a blank TINA4_SECRET is refused; in any mode a set-but-short
125
+ # one is refused. An operator-provisioned RS256 key pair in .keys/ stands
126
+ # in for the secret. Raises InsecureSecretError with the actionable message.
127
+ def require_boot_secret!
128
+ return if rsa_keys_present?
129
+
130
+ secret = ENV["TINA4_SECRET"].to_s
131
+ return if secret.empty? && dev?
132
+
133
+ message = insecure_secret_message(secret)
134
+ raise InsecureSecretError, message if message
135
+ end
136
+
70
137
  # Boot-time bootstrap (run once after env load, before auth is used).
71
138
  #
72
139
  # Mirrors the Python master's tina4_python.auth.ensure_dev_secret:
@@ -100,12 +167,10 @@ module Tina4
100
167
  local_path = File.join(root_dir, ".env.local")
101
168
  # If the file exists and does not end in a newline, prepend one so the
102
169
  # new key lands on its own line rather than gluing onto the last value.
103
- prefix = ""
104
- if File.exist?(local_path)
105
- content = File.read(local_path)
106
- prefix = "\n" if !content.empty? && !content.end_with?("\n")
170
+ SecretFile.update(local_path) do |content|
171
+ prefix = !content.empty? && !content.end_with?("\n") ? "\n" : ""
172
+ content + "#{prefix}TINA4_SECRET=#{new_secret}\n"
107
173
  end
108
- File.open(local_path, "a") { |f| f.write("#{prefix}TINA4_SECRET=#{new_secret}\n") }
109
174
  log_info("Auth: generated a development secret, saved to .env.local (gitignored)")
110
175
  rescue StandardError => e
111
176
  # Keep the in-memory secret for this run; just warn. Never crash boot.
@@ -117,15 +182,17 @@ module Tina4
117
182
 
118
183
  # ── HS256 helpers (stdlib only, no gem) ──────────────────────
119
184
 
120
- # Returns true when SECRET env var is set and no RSA keys exist in .keys/
185
+ # HMAC with TINA4_SECRET unless an RS256 key pair has been provisioned in
186
+ # .keys/ (kept for existing deployments). A blank secret no longer falls
187
+ # back to an implicitly generated key pair: it is refused (ADR-0079 s2).
121
188
  def use_hmac?
122
- secret = ENV["TINA4_SECRET"]
123
- return false if secret.nil? || secret.empty?
189
+ !rsa_keys_present?
190
+ end
124
191
 
125
- # If RSA keys already exist on disk, prefer RS256 for backward compat
192
+ def rsa_keys_present?
126
193
  @keys_dir ||= File.join(Dir.pwd, KEYS_DIR)
127
- !(File.exist?(File.join(@keys_dir, "private.pem")) &&
128
- File.exist?(File.join(@keys_dir, "public.pem")))
194
+ File.exist?(File.join(@keys_dir, "private.pem")) &&
195
+ File.exist?(File.join(@keys_dir, "public.pem"))
129
196
  end
130
197
 
131
198
  # Lazy per-call secret resolver. When the secret is blank, emit the
@@ -139,7 +206,7 @@ module Tina4
139
206
 
140
207
  # Base64url-encode without padding (JWT spec)
141
208
  def base64url_encode(data)
142
- Base64.urlsafe_encode64(data, padding: false)
209
+ Tina4::Base64.urlsafe_encode64(data, padding: false)
143
210
  end
144
211
 
145
212
  # Base64url-decode (handles missing padding)
@@ -147,7 +214,7 @@ module Tina4
147
214
  # Add back padding
148
215
  remainder = str.length % 4
149
216
  str += "=" * ((4 - remainder) % 4) if remainder != 0
150
- Base64.urlsafe_decode64(str)
217
+ Tina4::Base64.urlsafe_decode64(str)
151
218
  end
152
219
 
153
220
  # Pick the JWT algorithm: explicit argument, else TINA4_JWT_ALGORITHM, else
@@ -174,6 +241,9 @@ module Tina4
174
241
  # HMAC the signing input with the digest the algorithm actually names, so the
175
242
  # header's "alg" can never disagree with the bytes we produced.
176
243
  def hmac_signature(algorithm, secret, signing_input)
244
+ weak = insecure_secret_message(secret)
245
+ raise InsecureSecretError, weak if weak
246
+
177
247
  OpenSSL::HMAC.digest(HMAC_ALGORITHMS.fetch(algorithm).new, secret.to_s, signing_input)
178
248
  end
179
249
 
@@ -200,10 +270,10 @@ module Tina4
200
270
  def decode_envelope(token, algorithm)
201
271
  parts = token.to_s.split(".")
202
272
  return nil unless parts.length == 3
203
- return nil unless JSON.parse(base64url_decode(parts[0]))["alg"] == algorithm
273
+ return nil unless Tina4.parse_json(base64url_decode(parts[0]))["alg"] == algorithm
204
274
  return nil unless yield("#{parts[0]}.#{parts[1]}", base64url_decode(parts[2]))
205
275
 
206
- payload = JSON.parse(base64url_decode(parts[1]))
276
+ payload = Tina4.parse_json(base64url_decode(parts[1]))
207
277
  now = Time.now.to_i
208
278
 
209
279
  # RFC 7519 s4.1.4: "The processing of the 'exp' claim requires that the
@@ -247,6 +317,15 @@ module Tina4
247
317
  # configuration error that must surface, not become a nil "invalid token".
248
318
  alg = resolve_algorithm(algorithm)
249
319
 
320
+ # A blank or short key verifies tokens anyone can mint, so the token is
321
+ # REJECTED (fail closed with a 401, never a 500) and the operator is
322
+ # told why. ADR-0079 s2.
323
+ weak = insecure_secret_message(secret)
324
+ if weak
325
+ log_warning(weak)
326
+ return nil
327
+ end
328
+
250
329
  decode_envelope(token, alg) do |input, signature|
251
330
  expected = hmac_signature(alg, secret, input)
252
331
  # Constant-time comparison to prevent timing attacks. Lengths must match
@@ -304,7 +383,6 @@ module Tina4
304
383
  elsif use_hmac?
305
384
  hmac_encode(claims, hmac_secret, algorithm: algorithm)
306
385
  else
307
- ensure_keys
308
386
  rs256_encode(claims)
309
387
  end
310
388
  end
@@ -321,7 +399,6 @@ module Tina4
321
399
  if use_hmac?
322
400
  hmac_decode(token, hmac_secret) # returns Hash payload or nil
323
401
  else
324
- ensure_keys
325
402
  rs256_decode(token)
326
403
  end
327
404
  end
@@ -361,7 +438,7 @@ module Tina4
361
438
  return nil unless parts.length == 3
362
439
 
363
440
  payload_json = base64url_decode(parts[1])
364
- JSON.parse(payload_json)
441
+ Tina4.parse_json(payload_json)
365
442
  rescue ArgumentError, JSON::ParserError
366
443
  nil
367
444
  end
@@ -401,10 +478,11 @@ module Tina4
401
478
  # than this process's env-resolved defaults.
402
479
  payload = if secret || algorithm
403
480
  hmac_decode(token, secret || hmac_secret, algorithm: algorithm)
404
- elsif valid_token(token)
405
- get_payload(token)
481
+ else
482
+ valid_token(token)
406
483
  end
407
- return payload if payload
484
+ # A form token is not an identity (ADR-0079 s1).
485
+ return identity_payload?(payload) ? payload : nil if payload
408
486
 
409
487
  # API_KEY bypass — timing-safe comparison via validate_api_key
410
488
  # (OpenSSL.fixed_length_secure_compare). Parity with Python's
@@ -445,9 +523,13 @@ module Tina4
445
523
  token = Regexp.last_match(1)
446
524
 
447
525
  # JWT first, then the API key — same order and same payload shape as
448
- # authenticate_request above (and as Python/PHP/Node).
449
- if valid_token(token)
450
- env["tina4.auth"] = get_payload(token)
526
+ # authenticate_request above (and as Python/PHP/Node). A form token is
527
+ # not an identity (ADR-0079 s1).
528
+ payload = valid_token(token)
529
+ if payload
530
+ return false unless identity_payload?(payload)
531
+
532
+ env["tina4.auth"] = payload
451
533
  return true
452
534
  end
453
535
 
@@ -564,7 +646,7 @@ module Tina4
564
646
  def generate_keys
565
647
  Tina4::Log.info("Generating RSA key pair for JWT authentication")
566
648
  key = OpenSSL::PKey::RSA.generate(2048)
567
- File.write(private_key_path, key.to_pem)
649
+ SecretFile.update(private_key_path) { key.to_pem }
568
650
  File.write(public_key_path, key.public_key.to_pem)
569
651
  @private_key = nil
570
652
  @public_key = nil
@@ -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
  require "json"
3
9
 
4
10
  module Tina4
@@ -105,17 +111,46 @@ module Tina4
105
111
  page = [page, 1].max
106
112
  limit = per_page
107
113
  offset = req.query["offset"] ? req.query["offset"].to_i : (page - 1) * per_page
108
- order_by = parse_sort(req.query["sort"])
109
114
 
110
- # Filter support: ?filter[field]=value
115
+ # Filter support: ?filter[field]=value. ADR-0069: ANY bracket key is
116
+ # captured and must resolve to a declared field (attribute or its
117
+ # mapped column); an unknown key is a 400 before any SQL runs.
118
+ #
119
+ # The query parser keeps bracketed keys flat, so a list or map shows
120
+ # up as "sort[...]" or "filter[name][...]": a 400, never ignored.
121
+ if req.query.keys.any? { |key| key.start_with?("sort[") }
122
+ next res.error("INVALID_QUERY_PARAMETER",
123
+ "Query parameter 'sort' must be a single comma-separated string", 400)
124
+ end
125
+
111
126
  filter_conditions = []
112
127
  filter_values = []
128
+ unknown_filter_field = nil
129
+ nested_filter_field = nil
113
130
  req.query.each do |key, value|
114
- if key =~ /\Afilter\[(\w+)\]\z/
115
- filter_conditions << "#{$1} = ?"
116
- filter_values << value
131
+ next unless key =~ /\Afilter\[(.*)\]\z/m
132
+
133
+ filter_key = Regexp.last_match(1)
134
+ if filter_key.include?("][")
135
+ nested_filter_field = filter_key.split("][", 2).first
136
+ break
117
137
  end
138
+ column = model_class.resolve_field_column(filter_key)
139
+ unless column
140
+ unknown_filter_field = filter_key
141
+ break
142
+ end
143
+ filter_conditions << "#{column} = ?"
144
+ filter_values << value
145
+ end
146
+ if nested_filter_field
147
+ next res.error("INVALID_QUERY_PARAMETER",
148
+ "Filter value for '#{nested_filter_field}' must be a single value", 400)
118
149
  end
150
+ next res.error("UNKNOWN_FIELD", "Unknown filter field '#{unknown_filter_field}'", 400) if unknown_filter_field
151
+
152
+ order_by, unknown_sort_field = parse_sort(model_class, req.query["sort"])
153
+ next res.error("UNKNOWN_FIELD", "Unknown sort field '#{unknown_sort_field}'", 400) if unknown_sort_field
119
154
 
120
155
  if filter_conditions.empty?
121
156
  records = model_class.all(limit: limit, offset: offset, order_by: order_by)
@@ -150,8 +185,7 @@ module Tina4
150
185
  # GET /api/{table}/{id} -- get single record
151
186
  Tina4::Router.add("GET", "#{prefix}/#{table}/{id}", proc { |req, res|
152
187
  begin
153
- id = req.params["id"]
154
- record = model_class.find_by_id(id.to_i)
188
+ record = find_addressed_record(model_class, req.params["id"])
155
189
  if record
156
190
  res.json({ data: record.to_h })
157
191
  else
@@ -200,8 +234,7 @@ module Tina4
200
234
  # PUT /api/{table}/{id} -- update record
201
235
  put_route = Tina4::Router.add("PUT", "#{prefix}/#{table}/{id}", proc { |req, res|
202
236
  begin
203
- id = req.params["id"]
204
- record = model_class.find_by_id(id.to_i)
237
+ record = find_addressed_record(model_class, req.params["id"])
205
238
  unless record
206
239
  next res.json({ error: "Not found" }, status: 404)
207
240
  end
@@ -236,8 +269,7 @@ module Tina4
236
269
  # DELETE /api/{table}/{id} -- delete record
237
270
  delete_route = Tina4::Router.add("DELETE", "#{prefix}/#{table}/{id}", proc { |req, res|
238
271
  begin
239
- id = req.params["id"]
240
- record = model_class.find_by_id(id.to_i)
272
+ record = find_addressed_record(model_class, req.params["id"])
241
273
  unless record
242
274
  next res.json({ error: "Not found" }, status: 404)
243
275
  end
@@ -314,27 +346,51 @@ module Tina4
314
346
  auto_increment = single_pk && defs[single_pk.to_sym] && defs[single_pk.to_sym][:auto_increment]
315
347
  strip_pk = is_create ? !(single_pk && !auto_increment) : true
316
348
 
349
+ # ADR-0069: a body key is written only when it resolves to a DECLARED
350
+ # field, by attribute name or its mapped column - the SAME resolver the
351
+ # list route's filter/sort use. Anything else is dropped (writes drop,
352
+ # reads 400).
317
353
  data.each_with_object({}) do |(key, value), allowed|
318
- key_s = key.to_s
319
- next unless defs.key?(key_s.to_sym)
320
- next if key_s == "is_deleted"
321
- next if strip_pk && pk_fields.include?(key_s)
354
+ attribute = model_class.resolve_field(key)
355
+ next unless attribute
322
356
 
323
- allowed[key] = value
357
+ attribute_name = attribute.to_s
358
+ next if attribute_name == "is_deleted"
359
+ next if strip_pk && pk_fields.include?(attribute_name)
360
+
361
+ allowed[attribute_name] = value
324
362
  end
325
363
  end
326
364
 
327
- # Parse sort parameter: "-name,created_at" => "name DESC, created_at ASC"
328
- def parse_sort(sort_str)
329
- return nil if sort_str.nil? || sort_str.empty?
330
- sort_str.split(",").map do |field|
331
- field = field.strip
332
- if field.start_with?("-")
333
- "#{field[1..-1]} DESC"
334
- else
335
- "#{field} ASC"
336
- end
337
- end.join(", ")
365
+ # The row a URL {id} addresses, or nil. The id is bound, never
366
+ # interpolated; ORM.coerce_primary_key keeps a string natural key as-is
367
+ # and answers nil for a non-integer id on an integer key (-> 404).
368
+ def find_addressed_record(model_class, raw_id)
369
+ id = model_class.coerce_primary_key(raw_id)
370
+ id.nil? ? nil : model_class.find_by_id(id)
371
+ end
372
+
373
+ # Parse the sort parameter: "-name,created_at" => "name DESC, created_at ASC".
374
+ #
375
+ # ADR-0069: each part (empty parts skipped, a leading "-" means DESC) must
376
+ # resolve to a declared field; the ORDER BY is built ONLY from resolved
377
+ # columns plus ASC/DESC. Returns [order_by_or_nil, unknown_field_or_nil].
378
+ def parse_sort(model_class, sort_str)
379
+ return [nil, nil] if sort_str.nil? || sort_str.empty?
380
+
381
+ parts = []
382
+ sort_str.split(",").each do |part|
383
+ part = part.strip
384
+ next if part.empty?
385
+
386
+ descending = part.start_with?("-")
387
+ field = descending ? part[1..].strip : part
388
+ column = model_class.resolve_field_column(field)
389
+ return [nil, field] unless column
390
+
391
+ parts << "#{column} #{descending ? 'DESC' : 'ASC'}"
392
+ end
393
+ [parts.empty? ? nil : parts.join(", "), nil]
338
394
  end
339
395
  end
340
396
  end
@@ -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
  module Tina4
4
10
  # Periodic background task registry.
@@ -9,7 +15,7 @@ module Tina4
9
15
  #
10
16
  # Ruby has no asyncio event loop, so each task runs in its own dedicated OS
11
17
  # thread, started at registration time. Because the thread runs regardless of
12
- # which web server (Puma/WEBrick) is in front, a Ruby background task is never
18
+ # which web server (the built-in server or Puma) is in front, a Ruby background task is never
13
19
  # a silent no-op under production — the thread IS the runtime (contrast the
14
20
  # Python ASGI / PHP-FPM silent-no-op the other frameworks had to fix). The GIL
15
21
  # keeps it cooperative-enough for the periodic work this is meant for (queue
@@ -0,0 +1,59 @@
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
+
8
+
9
+ module Tina4
10
+ # Base64 on core Array#pack / String#unpack1, so Tina4 needs no base64 gem.
11
+ #
12
+ # The base64 gem stopped being a default gem in Ruby 3.4, and a bundled gem an
13
+ # app does not declare is not on the load path under Bundler. The gem is itself
14
+ # a thin wrapper over pack("m"), so this module keeps its method names and its
15
+ # exact semantics (same bytes out, same ArgumentError on bad strict input) and
16
+ # the call sites read the same with a Tina4:: prefix.
17
+ module Base64
18
+ module_function
19
+
20
+ # RFC 2045 (MIME): a newline after every 60 encoded characters and at the end.
21
+ def encode64(binary)
22
+ [binary].pack("m")
23
+ end
24
+
25
+ # Lenient decode: characters outside the alphabet are skipped, never raised on.
26
+ def decode64(string)
27
+ string.unpack1("m")
28
+ end
29
+
30
+ # RFC 4648: no line feeds.
31
+ def strict_encode64(binary)
32
+ [binary].pack("m0")
33
+ end
34
+
35
+ # Raises ArgumentError on anything that is not canonical, padded base64.
36
+ def strict_decode64(string)
37
+ string.unpack1("m0")
38
+ end
39
+
40
+ # RFC 4648 URL-safe alphabet ("-" and "_"), padding optional.
41
+ def urlsafe_encode64(binary, padding: true)
42
+ encoded = strict_encode64(binary)
43
+ encoded.chomp!("==") or encoded.chomp!("=") unless padding
44
+ encoded.tr!("+/", "-_")
45
+ encoded
46
+ end
47
+
48
+ # Accepts padded or unpadded input; raises ArgumentError on invalid input.
49
+ def urlsafe_decode64(string)
50
+ if !string.end_with?("=") && string.length % 4 != 0
51
+ string = string.ljust((string.length + 3) & ~3, "=")
52
+ string.tr!("-_", "+/")
53
+ else
54
+ string = string.tr("-_", "+/")
55
+ end
56
+ strict_decode64(string)
57
+ end
58
+ end
59
+ end