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
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ of this software and associated documentation files (the "Software"), to deal
6
+ in the Software without restriction, including without limitation the rights
7
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the Software is
9
+ furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all
12
+ copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ SOFTWARE.
data/lib/tina4/push.rb CHANGED
@@ -1,10 +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
 
3
9
  require "openssl"
4
- require "base64"
10
+ require_relative "base64"
5
11
  require "json"
6
12
  require "net/http"
7
13
  require "uri"
14
+ require_relative "ssrf"
8
15
 
9
16
  module Tina4
10
17
  class PushError < StandardError; end
@@ -34,7 +41,11 @@ module Tina4
34
41
  bytes.b.rjust(32, "\x00".b)
35
42
  end
36
43
 
37
- def initialize(subject: nil, public_key: nil, private_key: nil, ttl: 60, urgency: nil)
44
+ def initialize(subject: nil, public_key: nil, private_key: nil, ttl: 60, urgency: nil, allow_hosts: nil)
45
+ # SSRF guard (ADR-0084): the push endpoint is refused when it resolves to a
46
+ # private/internal address unless TINA4_ALLOW_PRIVATE_REQUESTS is truthy or
47
+ # the host/CIDR is on this allow-list.
48
+ @allow_hosts = allow_hosts || []
38
49
  @subject = (subject || ENV.fetch("TINA4_VAPID_SUBJECT", "")).strip
39
50
  @public_key = (public_key || ENV.fetch("TINA4_VAPID_PUBLIC", "")).strip
40
51
  @private_key = (private_key || ENV.fetch("TINA4_VAPID_PRIVATE", "")).strip
@@ -82,6 +93,13 @@ module Tina4
82
93
  end
83
94
 
84
95
  def deliver(endpoint, uri, subject, public_key, private, public, body)
96
+ # SSRF guard (ADR-0084): refuse a private/internal push endpoint. Net::HTTP
97
+ # does not follow redirects, so guarding the endpoint is sufficient.
98
+ begin
99
+ Tina4::Ssrf.guard_url!(uri, @allow_hosts)
100
+ rescue Tina4::SsrfError => e
101
+ raise PushError, e.message
102
+ end
85
103
  request = Net::HTTP::Post.new(uri)
86
104
  request["Authorization"] = "vapid t=#{vapid_token(uri, subject, private, public)}, k=#{public_key}"
87
105
  request["Content-Encoding"] = "aes128gcm"
@@ -113,13 +131,13 @@ module Tina4
113
131
  end
114
132
 
115
133
  def self.b64(value)
116
- Base64.urlsafe_encode64(value, padding: false)
134
+ Tina4::Base64.urlsafe_encode64(value, padding: false)
117
135
  end
118
136
 
119
137
  def decode(value, name)
120
138
  raise PushError, "#{name} must be a non-empty base64url string" unless value.is_a?(String) && value.match?(/\A[A-Za-z0-9_-]+\z/)
121
139
 
122
- Base64.urlsafe_decode64(value)
140
+ Tina4::Base64.urlsafe_decode64(value)
123
141
  rescue ArgumentError => e
124
142
  raise PushError, "#{name} must be base64url encoded: #{e.message}"
125
143
  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
  # QueryBuilder — Fluent SQL query builder.
data/lib/tina4/queue.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
  require "json"
3
9
  require "securerandom"
4
10
  require "uri"
@@ -1,4 +1,12 @@
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
+
8
+
9
+ require_relative "../parse_json"
2
10
 
3
11
  module Tina4
4
12
  module QueueBackends
@@ -120,7 +128,7 @@ module Tina4
120
128
  end
121
129
  return nil unless msg
122
130
 
123
- data = JSON.parse(msg.payload)
131
+ data = Tina4.parse_json(msg.payload)
124
132
  @last_message = msg
125
133
 
126
134
  # attempts and error MUST be carried back. Rebuilding from
@@ -243,7 +251,7 @@ module Tina4
243
251
  loop do
244
252
  msg = reader.poll(500)
245
253
  if msg
246
- data = JSON.parse(msg.payload)
254
+ data = Tina4.parse_json(msg.payload)
247
255
  data["status"] = "dead"
248
256
  out << data
249
257
  next
@@ -259,6 +267,14 @@ module Tina4
259
267
  out
260
268
  end
261
269
 
270
+ # Reject a job permanently — dead-letter it NOW, no retry (ADR-0023).
271
+ def reject(job, reason = "")
272
+ job.attempts = [job.attempts + 1, @max_retries].max
273
+ job.error = reason
274
+ dead_letter(job)
275
+ acknowledge(job)
276
+ end
277
+
262
278
  def failed(_topic, max_retries: 3)
263
279
  raise NotImplementedError,
264
280
  "The kafka queue backend cannot answer failed(): a job that failed " \
@@ -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
  require "fileutils"
4
10
  require "time"
@@ -171,6 +177,18 @@ module Tina4
171
177
  # Aligns with retry_job(id) which had always unlinked the dead-letter
172
178
  # file — two spellings of the same intent that previously diverged.
173
179
  # Increments attempts, clears the error.
180
+ # Reject a job permanently — dead-letter it NOW, no retry (ADR-0023).
181
+ # Unlike fail(), which requeues until max_retries is spent, reject() moves
182
+ # a known-poison job straight to the dead-letter store. attempts is floored
183
+ # at max_retries so dead_letters (attempts >= limit) returns it and
184
+ # dead_letter_count agrees.
185
+ def reject(job, reason = "")
186
+ clear_reservation(job.topic, job.id)
187
+ job.attempts = [job.attempts + 1, @max_retries].max
188
+ job.error = reason
189
+ move_to_dead_letter(job, reason)
190
+ end
191
+
174
192
  def retry(job, delay_seconds: 0)
175
193
  clear_reservation(job.topic, job.id)
176
194
  # Drop any dead-letter file for this id BEFORE the re-queue: if this
@@ -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 QueueBackends
@@ -278,6 +284,18 @@ module Tina4
278
284
  # the normal path. This method did not exist at all, and Queue#failed
279
285
  # silently returned [] for it — indistinguishable from "nothing has
280
286
  # failed" (ADR-0022 decision 7).
287
+ # Reject a job permanently — dead-letter it NOW, no retry (ADR-0023).
288
+ def reject(job, reason = "")
289
+ job.attempts = [job.attempts + 1, @max_retries].max
290
+ job.error = reason
291
+ collection.find_one_and_update(
292
+ { _id: job.id },
293
+ { "$set" => { status: "dead", topic: "#{job.topic}.dead_letter",
294
+ attempts: job.attempts, error: reason, reserved_at: nil } },
295
+ upsert: true
296
+ )
297
+ end
298
+
281
299
  def failed(topic, max_retries: 3)
282
300
  collection.find(
283
301
  topic: topic,
@@ -1,4 +1,12 @@
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
+
8
+
9
+ require_relative "../parse_json"
2
10
 
3
11
  module Tina4
4
12
  module QueueBackends
@@ -62,7 +70,7 @@ module Tina4
62
70
  delivery_info, _properties, payload = queue.pop(manual_ack: true)
63
71
  return nil unless payload
64
72
 
65
- data = JSON.parse(payload)
73
+ data = Tina4.parse_json(payload)
66
74
  # attempts and error MUST be carried back. Rebuilding the Job from
67
75
  # topic/payload/id alone reset attempts to 0 on every redelivery, so
68
76
  # fail()'s attempts >= max_retries check could never trip and a poison
@@ -169,6 +177,14 @@ module Tina4
169
177
  end
170
178
  end
171
179
 
180
+ # Reject a job permanently — dead-letter it NOW, no retry (ADR-0023).
181
+ def reject(job, reason = "")
182
+ job.attempts = [job.attempts + 1, @max_retries].max
183
+ job.error = reason
184
+ dead_letter(job)
185
+ complete(job)
186
+ end
187
+
172
188
  def failed(_topic, max_retries: 3)
173
189
  raise NotImplementedError,
174
190
  "The rabbitmq queue backend cannot answer failed(): a job that " \
@@ -282,7 +298,7 @@ module Tina4
282
298
  _info, _props, payload = queue.pop(manual_ack: false)
283
299
  break unless payload
284
300
 
285
- out << JSON.parse(payload)
301
+ out << Tina4.parse_json(payload)
286
302
  end
287
303
  out.each { |data| publish_to(name, data) }
288
304
  out
@@ -1,9 +1,16 @@
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
  require "securerandom"
4
10
  require "time"
5
11
  require "uri"
6
12
  require "cgi"
13
+ require_relative "parse_json"
7
14
 
8
15
  module Tina4
9
16
  # Middleware wrapper that tags requests arriving on the AI dev port.
@@ -503,6 +510,9 @@ module Tina4
503
510
 
504
511
  # Skip underscore-prefixed segments — private files even within pages/.
505
512
  return nil if clean_path.split("/").any? { |seg| seg.start_with?("_") }
513
+ # ADR-0082: a "." / ".." segment (or a backslash) could walk out of
514
+ # pages/ - the only directory that auto-routes. Refuse it outright.
515
+ return nil if clean_path.include?("\\") || clean_path.split("/", -1).any? { |seg| ["", ".", ".."].include?(seg) }
506
516
 
507
517
  is_dev = %w[true 1 yes].include?(ENV.fetch("TINA4_DEBUG", "false").downcase)
508
518
 
@@ -1179,68 +1189,46 @@ module Tina4
1179
1189
  def self.enforce_route_auth(env, route)
1180
1190
  return nil unless route.auth_required
1181
1191
 
1182
- token = nil
1183
- token_source = nil # :header, :body, :session
1192
+ unauthorized = [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
1184
1193
 
1185
- # Priority 1: Authorization Bearer header
1194
+ # Token slots in priority order: Authorization Bearer header, then the
1195
+ # body formToken (frond.js puts the auth token it received as a
1196
+ # FreshToken there), then the session. The first slot holding a token
1197
+ # decides, EXCEPT that a form token ("type" => "form") proves where a
1198
+ # write came from, not who sent it: it is skipped and the next slot is
1199
+ # tried (ADR-0079 s1). So a logged-in user posting a rendered form (form
1200
+ # token in the body, auth token in the session) is still authenticated.
1201
+ candidates = []
1186
1202
  auth_header = env["HTTP_AUTHORIZATION"] || ""
1187
- if auth_header =~ /\ABearer\s+(.+)\z/i
1188
- token = Regexp.last_match(1)
1189
- token_source = :header
1190
- end
1203
+ candidates << [Regexp.last_match(1), :header] if auth_header =~ /\ABearer\s+(.+)\z/i
1204
+ form_token = _extract_form_token(_read_rack_body(env), env)
1205
+ candidates << [form_token, :body] if form_token && !form_token.empty?
1191
1206
 
1192
- # Priority 2: formToken from request body (for frond.js saveForm with {{ form_token() }})
1193
- if token.nil?
1194
- body_str = _read_rack_body(env)
1195
- form_token = _extract_form_token(body_str, env)
1196
- if form_token && !form_token.empty?
1197
- token = form_token
1198
- token_source = :body
1199
- end
1207
+ verdict = candidates.each do |token, source|
1208
+ outcome = _authenticate_token(env, token, source)
1209
+ break outcome unless outcome == :skip
1200
1210
  end
1211
+ return unauthorized if verdict == :denied
1201
1212
 
1202
- # Priority 3: Session token (for secured GET routes after login)
1203
- if token.nil?
1213
+ unless env.key?("tina4.auth_payload")
1204
1214
  # Request path, so the same log-loud-then-degrade policy as
1205
1215
  # Request#session (ADR-0021): an unreachable session store must not turn
1206
1216
  # the auth gate into a 500. It degrades to an empty session, which means
1207
1217
  # no token, which means the ordinary 401 below - a SERVED request.
1208
1218
  session = Tina4::Session.new(env, degrade_on_backend_failure: true)
1209
- sso = session.get("_tina4_sso")
1210
- identity = sso.is_a?(Hash) ? sso["identity"] : nil
1211
- if identity.is_a?(Hash) && identity["issuer"] && identity["subject"]
1219
+ # A provider-verified OIDC identity counts only while it is live
1220
+ # (ADR-0079 s5).
1221
+ identity = Tina4::Sso.live_session_identity(session.get("_tina4_sso"))
1222
+ if identity
1212
1223
  env["tina4.auth_payload"] = identity
1213
- deny = rbac_forbidden(route, identity)
1214
- return deny if deny
1215
-
1216
- return nil
1217
- end
1218
- session_token = session.get("token")
1219
- if session_token && !session_token.empty?
1220
- token = session_token
1221
- token_source = :session
1222
- end
1223
- end
1224
-
1225
- # API_KEY bypass — routed through the timing-safe Tina4::Auth.validate_api_key
1226
- # (OpenSSL.fixed_length_secure_compare), matching tina4_python's _check_auth.
1227
- # It used to be a plain `token == api_key`, which returns as soon as two
1228
- # bytes differ — so response timing leaks the key prefix and the key can be
1229
- # recovered a character at a time. validate_api_key also covers the unset /
1230
- # blank / wrong-length cases the old guard spelled out by hand.
1231
- if Tina4::Auth.validate_api_key(token)
1232
- env["tina4.auth_payload"] = { "_auth" => "api_key" }
1233
- elsif token
1234
- unless Tina4::Auth.valid_token(token)
1235
- return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
1224
+ else
1225
+ session_token = session.get("token")
1226
+ if session_token.is_a?(String) && !session_token.empty?
1227
+ return unauthorized unless _authenticate_token(env, session_token, :session) == :accepted
1228
+ end
1236
1229
  end
1237
- env["tina4.auth_payload"] = Tina4::Auth.get_payload(token)
1238
-
1239
- # When body formToken validates, store a refreshed token for the FreshToken response header
1240
- env["tina4.fresh_token"] = Tina4::Auth.refresh_token(token) if token_source == :body
1241
- else
1242
- return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
1243
1230
  end
1231
+ return unauthorized unless env.key?("tina4.auth_payload")
1244
1232
 
1245
1233
  # ── RBAC guards (Feature 138): authorization AFTER authentication ──
1246
1234
  # Auth has passed (401 ruled out above). If the route carries role/
@@ -1251,6 +1239,30 @@ module Tina4
1251
1239
  nil
1252
1240
  end
1253
1241
 
1242
+ # Judge one token from one slot: :accepted (env["tina4.auth_payload"] set),
1243
+ # :skip (a valid form token - not an identity, try the next slot) or
1244
+ # :denied (anything else).
1245
+ #
1246
+ # API_KEY bypass - routed through the timing-safe Tina4::Auth.validate_api_key
1247
+ # (OpenSSL.fixed_length_secure_compare), matching tina4_python's _check_auth.
1248
+ # A plain `token == api_key` returns as soon as two bytes differ, so response
1249
+ # timing would leak the key a character at a time.
1250
+ def self._authenticate_token(env, token, source)
1251
+ if Tina4::Auth.validate_api_key(token)
1252
+ env["tina4.auth_payload"] = { "_auth" => "api_key" }
1253
+ return :accepted
1254
+ end
1255
+ payload = Tina4::Auth.valid_token(token)
1256
+ return :denied unless payload
1257
+ return :skip unless Tina4::Auth.identity_payload?(payload)
1258
+
1259
+ env["tina4.auth_payload"] = payload
1260
+ # When a body token authenticates, store a refreshed token for the
1261
+ # FreshToken response header (identity tokens only).
1262
+ env["tina4.fresh_token"] = Tina4::Auth.refresh_token(token) if source == :body
1263
+ :accepted
1264
+ end
1265
+
1254
1266
  # Build a 403 Rack tuple when a route's RBAC guards are not satisfied by the
1255
1267
  # verified payload, else nil. AND across guard groups, OR within a group.
1256
1268
  # Roles read the `roles` claim (legacy singular `role` coerced); permissions
@@ -1316,7 +1328,7 @@ module Tina4
1316
1328
 
1317
1329
  if content_type.include?("application/json")
1318
1330
  begin
1319
- parsed = JSON.parse(body_str)
1331
+ parsed = Tina4.parse_json(body_str)
1320
1332
  return parsed["formToken"] if parsed.is_a?(Hash) && parsed["formToken"]
1321
1333
  rescue JSON::ParserError
1322
1334
  # Not valid JSON — fall through
@@ -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
  class RateLimiter
@@ -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 Realtime
@@ -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 Realtime
@@ -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 Realtime
@@ -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 "fileutils"
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
  module Tina4
4
10
  module Realtime
@@ -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 Realtime
@@ -9,7 +15,15 @@ module Tina4
9
15
  class S3Storage < StorageBackend
10
16
  def initialize(endpoint: nil, key: nil, secret: nil, bucket: nil, region: nil)
11
17
  super()
12
- require "aws-sdk-s3" # optional dependency, loaded lazily
18
+ begin
19
+ require "aws-sdk-s3" # optional dependency, loaded lazily
20
+ rescue LoadError
21
+ # Storage.select rescues this and falls back to local; its warning
22
+ # carries this message, so the remedy reaches the operator.
23
+ raise LoadError,
24
+ "The 'aws-sdk-s3' gem is required for S3Storage. Install it with: " \
25
+ "bundle add aws-sdk-s3 (or add gem \"aws-sdk-s3\" to your Gemfile)"
26
+ end
13
27
 
14
28
  @bucket = bucket || ENV["TINA4_STORAGE_BUCKET"]
15
29
  raise ArgumentError, "S3Storage requires TINA4_STORAGE_BUCKET" if @bucket.nil? || @bucket.empty?
@@ -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 "securerandom"
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
  module Tina4
4
10
  module Realtime
@@ -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 Realtime
@@ -1,9 +1,15 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require "json"
4
10
  require "time"
5
11
  require "openssl"
6
- require "base64"
12
+ require_relative "base64"
7
13
 
8
14
  require_relative "realtime/workspace"
9
15
  require_relative "realtime/channel"
@@ -14,6 +20,7 @@ require_relative "realtime/storage_backend"
14
20
  require_relative "realtime/local_storage"
15
21
  require_relative "realtime/s3_storage"
16
22
  require_relative "realtime/storage"
23
+ require_relative "parse_json"
17
24
 
18
25
  module Tina4
19
26
  # Real-time collaboration mount for Tina4 (Ruby), parity with the Python
@@ -57,7 +64,7 @@ module Tina4
57
64
  if turn_url && !turn_url.empty? && secret && !secret.empty?
58
65
  ttl = (ENV["TINA4_RTC_TURN_TTL"] || "3600").to_i
59
66
  username = (Time.now.to_i + ttl).to_s
60
- credential = Base64.strict_encode64(OpenSSL::HMAC.digest("SHA1", secret, username))
67
+ credential = Tina4::Base64.strict_encode64(OpenSSL::HMAC.digest("SHA1", secret, username))
61
68
  servers << { "urls" => split_urls(turn_url), "username" => username, "credential" => credential }
62
69
  end
63
70
  servers
@@ -312,7 +319,7 @@ module Tina4
312
319
  def parse_json(data)
313
320
  return data if data.is_a?(Hash)
314
321
 
315
- JSON.parse(data.to_s)
322
+ Tina4.parse_json(data.to_s)
316
323
  rescue JSON::ParserError, TypeError
317
324
  nil
318
325
  end