tina4ruby 3.13.136 → 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.
Files changed (160) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -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 +24 -9
  11. data/lib/tina4/api.rb +57 -7
  12. data/lib/tina4/auth.rb +18 -12
  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 +6 -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 +6 -0
  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 +6 -0
  26. data/lib/tina4/cli.rb +22 -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 +215 -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 +8 -1
  55. data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
  56. data/lib/tina4/env.rb +6 -0
  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 +196 -56
  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 +28 -27
  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 +6 -0
  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 +15 -8
  82. data/lib/tina4/messenger.rb +86 -55
  83. data/lib/tina4/metrics.rb +6 -0
  84. data/lib/tina4/middleware.rb +24 -9
  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 +9 -3
  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 +10 -2
  109. data/lib/tina4/queue_backends/lite_backend.rb +6 -0
  110. data/lib/tina4/queue_backends/mongo_backend.rb +6 -0
  111. data/lib/tina4/queue_backends/rabbitmq_backend.rb +10 -2
  112. data/lib/tina4/rack_app.rb +11 -1
  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 +6 -0
  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 +85 -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 +6 -0
  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 +25 -8
  146. data/lib/tina4/swagger.rb +6 -0
  147. data/lib/tina4/template.rb +6 -0
  148. data/lib/tina4/test.rb +8 -2
  149. data/lib/tina4/test_client.rb +8 -1
  150. data/lib/tina4/testing.rb +8 -1
  151. data/lib/tina4/validator.rb +6 -0
  152. data/lib/tina4/version.rb +7 -1
  153. data/lib/tina4/webserver.rb +25 -143
  154. data/lib/tina4/websocket.rb +10 -4
  155. data/lib/tina4/websocket_backplane.rb +19 -2
  156. data/lib/tina4/wsdl.rb +31 -16
  157. data/lib/tina4/xml_parser.rb +259 -0
  158. data/lib/tina4.rb +87 -31
  159. data/lib/tina4ruby.rb +6 -0
  160. metadata +43 -140
data/lib/tina4/api.rb CHANGED
@@ -1,11 +1,18 @@
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"
9
16
 
10
17
  module Tina4
11
18
  # Statuses that warrant an automatic retry when max_retries > 0: rate-limit
@@ -258,6 +265,7 @@ module Tina4
258
265
  headers = @headers.dup
259
266
  cookie = cookie_header
260
267
  headers["Cookie"] = cookie if @cookies_enabled && cookie
268
+ headers = origin_headers(uri, headers)
261
269
 
262
270
  return download_via_transport(uri, headers, dest_path) if @transport
263
271
 
@@ -277,7 +285,7 @@ module Tina4
277
285
  end
278
286
 
279
287
  def set_basic_auth(username, password)
280
- @headers["Authorization"] = "Basic #{Base64.strict_encode64("#{username}:#{password}")}"
288
+ @headers["Authorization"] = "Basic #{Tina4::Base64.strict_encode64("#{username}:#{password}")}"
281
289
  self
282
290
  end
283
291
 
@@ -451,6 +459,10 @@ module Tina4
451
459
  apply_headers(request, headers || {})
452
460
  cookie = cookie_header
453
461
  request["Cookie"] = cookie if @cookies_enabled && cookie
462
+ unless same_origin?(uri, URI.parse(@base_url))
463
+ request.delete("Authorization")
464
+ request.delete("Cookie")
465
+ end
454
466
  if body
455
467
  request.body = body.is_a?(String) ? body : JSON.generate(body)
456
468
  request["Content-Type"] = content_type if content_type
@@ -458,12 +470,22 @@ module Tina4
458
470
  request["Content-Type"] = content_type
459
471
  end
460
472
 
473
+ # ADR-0060: connect_timeout (else TINA4_API_CONNECT_TIMEOUT, else 10s)
474
+ # bounds establishment and raises Net::OpenTimeout; timeout (else
475
+ # TINA4_API_TIMEOUT, else the client timeout) bounds the WHOLE stream and
476
+ # raises APIStreamTimeoutError. Both are Timeout::Error. `timeout` used to
477
+ # be only Net::HTTP's per-read idle timeout, so a server that kept
478
+ # dripping data streamed forever.
479
+ connect_bound = connect_timeout || stream_env_seconds("TINA4_API_CONNECT_TIMEOUT", 10.0)
480
+ total_bound = timeout || stream_env_seconds("TINA4_API_TIMEOUT", @timeout)
481
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + total_bound
482
+
461
483
  http = Net::HTTP.new(uri.host, uri.port)
462
484
  http.use_ssl = uri.scheme == "https"
463
485
  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=)
486
+ http.open_timeout = [connect_bound, total_bound].min
487
+ http.read_timeout = total_bound
488
+ http.write_timeout = total_bound if http.respond_to?(:write_timeout=)
467
489
 
468
490
  http.start do |conn|
469
491
  conn.request(request) do |response|
@@ -473,9 +495,26 @@ module Tina4
473
495
  raise APIStreamError.new("stream returned HTTP #{status}", status)
474
496
  end
475
497
  store_cookies(response.get_fields("Set-Cookie"))
476
- response.read_body { |chunk| yield chunk }
498
+ response.read_body do |chunk|
499
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
500
+ raise APIStreamTimeoutError, "stream exceeded its total timeout of #{total_bound}s" unless remaining.positive?
501
+
502
+ # The next read may wait only for what is left of the deadline.
503
+ # tina4: bounded per read, so a server dripping single bytes inside
504
+ # one chunk header can overrun by one read at most.
505
+ conn.read_timeout = remaining
506
+ yield chunk
507
+ end
477
508
  end
478
509
  end
510
+ rescue Net::ReadTimeout, Net::WriteTimeout
511
+ raise APIStreamTimeoutError, "stream exceeded its total timeout of #{total_bound}s"
512
+ end
513
+
514
+ # A positive number of seconds from an env var; anything else falls back.
515
+ def stream_env_seconds(name, fallback)
516
+ seconds = Float(ENV[name].to_s.strip, exception: false)
517
+ seconds&.positive? ? seconds : fallback
479
518
  end
480
519
 
481
520
  def build_uri(path, params = {})
@@ -488,6 +527,10 @@ module Tina4
488
527
  uri
489
528
  end
490
529
 
530
+ def origin_headers(uri, headers)
531
+ same_origin?(uri, URI.parse(@base_url)) ? headers : strip_cross_origin(headers)
532
+ end
533
+
491
534
  def apply_headers(request, extra_headers)
492
535
  @headers.merge(extra_headers).each do |key, value|
493
536
  request[key] = value
@@ -523,6 +566,7 @@ module Tina4
523
566
  request.each_header { |key, value| headers[key] = value }
524
567
  cookie = cookie_header
525
568
  headers["cookie"] = cookie if @cookies_enabled && cookie
569
+ headers = origin_headers(uri, headers)
526
570
  body = request.body
527
571
 
528
572
  if @transport
@@ -803,6 +847,12 @@ module Tina4
803
847
  end
804
848
  end
805
849
 
850
+ # A stream_* call ran past its total timeout (timeout:, else
851
+ # TINA4_API_TIMEOUT). A Timeout::Error, as Python's ApiTimeoutError is a
852
+ # TimeoutError, and deliberately NOT an APIStreamError: callers such as
853
+ # AiClient treat APIStreamError as an HTTP status failure.
854
+ class APIStreamTimeoutError < Timeout::Error; end
855
+
806
856
  class APIResponse
807
857
  attr_reader :status, :body, :headers, :error, :path
808
858
 
@@ -821,7 +871,7 @@ module Tina4
821
871
  end
822
872
 
823
873
  def json
824
- @json ||= JSON.parse(@body)
874
+ @json ||= Tina4.parse_json(@body)
825
875
  rescue JSON::ParserError, TypeError
826
876
  {}
827
877
  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
@@ -100,12 +108,10 @@ module Tina4
100
108
  local_path = File.join(root_dir, ".env.local")
101
109
  # If the file exists and does not end in a newline, prepend one so the
102
110
  # 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")
111
+ SecretFile.update(local_path) do |content|
112
+ prefix = !content.empty? && !content.end_with?("\n") ? "\n" : ""
113
+ content + "#{prefix}TINA4_SECRET=#{new_secret}\n"
107
114
  end
108
- File.open(local_path, "a") { |f| f.write("#{prefix}TINA4_SECRET=#{new_secret}\n") }
109
115
  log_info("Auth: generated a development secret, saved to .env.local (gitignored)")
110
116
  rescue StandardError => e
111
117
  # Keep the in-memory secret for this run; just warn. Never crash boot.
@@ -139,7 +145,7 @@ module Tina4
139
145
 
140
146
  # Base64url-encode without padding (JWT spec)
141
147
  def base64url_encode(data)
142
- Base64.urlsafe_encode64(data, padding: false)
148
+ Tina4::Base64.urlsafe_encode64(data, padding: false)
143
149
  end
144
150
 
145
151
  # Base64url-decode (handles missing padding)
@@ -147,7 +153,7 @@ module Tina4
147
153
  # Add back padding
148
154
  remainder = str.length % 4
149
155
  str += "=" * ((4 - remainder) % 4) if remainder != 0
150
- Base64.urlsafe_decode64(str)
156
+ Tina4::Base64.urlsafe_decode64(str)
151
157
  end
152
158
 
153
159
  # Pick the JWT algorithm: explicit argument, else TINA4_JWT_ALGORITHM, else
@@ -200,10 +206,10 @@ module Tina4
200
206
  def decode_envelope(token, algorithm)
201
207
  parts = token.to_s.split(".")
202
208
  return nil unless parts.length == 3
203
- return nil unless JSON.parse(base64url_decode(parts[0]))["alg"] == algorithm
209
+ return nil unless Tina4.parse_json(base64url_decode(parts[0]))["alg"] == algorithm
204
210
  return nil unless yield("#{parts[0]}.#{parts[1]}", base64url_decode(parts[2]))
205
211
 
206
- payload = JSON.parse(base64url_decode(parts[1]))
212
+ payload = Tina4.parse_json(base64url_decode(parts[1]))
207
213
  now = Time.now.to_i
208
214
 
209
215
  # RFC 7519 s4.1.4: "The processing of the 'exp' claim requires that the
@@ -361,7 +367,7 @@ module Tina4
361
367
  return nil unless parts.length == 3
362
368
 
363
369
  payload_json = base64url_decode(parts[1])
364
- JSON.parse(payload_json)
370
+ Tina4.parse_json(payload_json)
365
371
  rescue ArgumentError, JSON::ParserError
366
372
  nil
367
373
  end
@@ -564,7 +570,7 @@ module Tina4
564
570
  def generate_keys
565
571
  Tina4::Log.info("Generating RSA key pair for JWT authentication")
566
572
  key = OpenSSL::PKey::RSA.generate(2048)
567
- File.write(private_key_path, key.to_pem)
573
+ SecretFile.update(private_key_path) { key.to_pem }
568
574
  File.write(public_key_path, key.public_key.to_pem)
569
575
  @private_key = nil
570
576
  @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
data/lib/tina4/cache.rb CHANGED
@@ -1,3 +1,9 @@
1
+ # Copyright (c) 2026 Code Infinity
2
+ # SPDX-License-Identifier: MPL-2.0
3
+ # This Source Code Form is subject to the terms of the Mozilla Public
4
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
5
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
6
+
1
7
  module Tina4
2
8
  # In-memory TTL cache with tag-based invalidation.
3
9
  #
@@ -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
  module CacheBackends
@@ -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_relative "base_backend"
@@ -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 "fileutils"
@@ -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 "socket"
@@ -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_relative "base_backend"
4
10
 
@@ -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 "uri"
@@ -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 "socket"
@@ -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_relative "redis_backend"
4
10
 
@@ -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_relative "cache_backends/base_backend"
4
10
  require_relative "cache_backends/memory_backend"
data/lib/tina4/cli.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 "optparse"
4
10
  require "fileutils"
@@ -8,6 +14,7 @@ require "tmpdir" # Dir.mktmpdir — used by the ADR-0063 sandbox edit-hint sc
8
14
  require "stringio" # StringIO — captures generator stdout during the sandbox run
9
15
  require "open3" # Open3.capture2e — captures `ruby -c` output+status for `lint`
10
16
  require_relative "port_takeover"
17
+ require_relative "env" # Tina4::Env.is_truthy -- resolve_config runs before lib/tina4 loads
11
18
 
12
19
  module Tina4
13
20
  class CLI
@@ -676,7 +683,7 @@ module Tina4
676
683
  # invokes PHP. That mismatch is why Ruby could not be launched through
677
684
  # the shared launcher the other frameworks use.
678
685
  opts.on("--managed", "Running under the tina4 CLI supervisor") { options[:managed] = true }
679
- opts.on("--production", "Use production server (Puma)") { options[:production] = true }
686
+ opts.on("--production", "Production mode: Puma if the app bundles it, else the built-in server") { options[:production] = true }
680
687
  opts.on("--no-browser", "Do not open browser on start") { options[:no_browser] = true }
681
688
  opts.on("--no-reload", "Disable file watcher / live-reload") { options[:no_reload] = true }
682
689
  opts.on("--no-kill", "Never take over the port from a stale dev server") { options[:no_kill] = true }
@@ -727,9 +734,9 @@ module Tina4
727
734
 
728
735
  is_debug = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
729
736
 
730
- # Use Puma only when explicitly requested via --production flag
731
- # WEBrick is used for development (supports dev toolbar/reload)
732
- # Same production server, same shutdown contract as Tina4.run! - one
737
+ # Puma only with --production AND only when the app bundles it
738
+ # (ADR-0067); otherwise Tina4's built-in server, in development and
739
+ # production alike. Same production server, same shutdown contract as Tina4.run! - one
733
740
  # implementation, so the two entry points cannot drift again (the old
734
741
  # copy here claimed "we handle DB cleanup" and did no cleanup at all).
735
742
  # TINA4_DEFAULT_WEBSERVER=TRUE pins the built-in server even with
@@ -739,7 +746,6 @@ module Tina4
739
746
  return
740
747
  end
741
748
 
742
- Tina4::Log.info("Development server: WEBrick")
743
749
  server = Tina4::WebServer.new(app, host: options[:host], port: options[:port])
744
750
  server.start
745
751
  end
@@ -3806,7 +3812,7 @@ module Tina4
3806
3812
  # loopback, not 0.0.0.0. Only the DEFAULT changes: production
3807
3813
  # (TINA4_DEBUG off) keeps 0.0.0.0, and a developer who WANTS network
3808
3814
  # exposure sets TINA4_HOST=0.0.0.0 to override deliberately.
3809
- Tina4.truthy?(ENV["TINA4_DEBUG"]) ? "127.0.0.1" : DEFAULT_HOST
3815
+ Tina4::Env.is_truthy(ENV["TINA4_DEBUG"]) ? "127.0.0.1" : DEFAULT_HOST
3810
3816
  end
3811
3817
  end
3812
3818
 
@@ -3875,9 +3881,18 @@ module Tina4
3875
3881
 
3876
3882
  # Gemfile
3877
3883
  unless File.exist?(File.join(dir, "Gemfile"))
3884
+ # Same Gemfile `tina4 init ruby` writes. The gem is published as
3885
+ # "tina4ruby" ("tina4-ruby" does not exist, so bundle install failed),
3886
+ # and sqlite3 is an app dependency (ADR-0067): tina4ruby loads it lazily
3887
+ # for the default SQLite database but never declares it.
3878
3888
  File.write(File.join(dir, "Gemfile"), <<~RUBY)
3879
3889
  source "https://rubygems.org"
3880
- gem "tina4-ruby", "~> 3.0"
3890
+
3891
+ gem "tina4ruby", "~> 3.0"
3892
+
3893
+ # SQLite driver for the default database (TINA4_DATABASE_URL=sqlite:...).
3894
+ # tina4ruby loads it lazily; it is an app dependency, like pg or mysql2.
3895
+ gem "sqlite3"
3881
3896
  RUBY
3882
3897
  end
3883
3898