tina4ruby 3.13.94 → 3.13.96

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +883 -0
  3. data/README.md +1 -1
  4. data/lib/tina4/auth.rb +166 -87
  5. data/lib/tina4/auto_crud.rb +29 -32
  6. data/lib/tina4/cache_backends/base_backend.rb +19 -0
  7. data/lib/tina4/cache_backends/database_backend.rb +29 -0
  8. data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
  9. data/lib/tina4/cache_backends/memory_backend.rb +15 -0
  10. data/lib/tina4/cache_backends/redis_backend.rb +173 -52
  11. data/lib/tina4/cache_backends.rb +10 -1
  12. data/lib/tina4/cli.rb +23 -39
  13. data/lib/tina4/cors.rb +186 -30
  14. data/lib/tina4/database/sqlite3_adapter.rb +4 -1
  15. data/lib/tina4/database.rb +322 -22
  16. data/lib/tina4/database_adapter.rb +178 -0
  17. data/lib/tina4/database_result.rb +63 -17
  18. data/lib/tina4/database_url.rb +363 -0
  19. data/lib/tina4/dev.rb +0 -1
  20. data/lib/tina4/dev_admin.rb +118 -20
  21. data/lib/tina4/dispatch_pipeline.rb +605 -0
  22. data/lib/tina4/docstore.rb +274 -60
  23. data/lib/tina4/drivers/firebird_driver.rb +118 -4
  24. data/lib/tina4/drivers/mongodb_driver.rb +19 -4
  25. data/lib/tina4/drivers/mssql_driver.rb +73 -10
  26. data/lib/tina4/drivers/mysql_driver.rb +71 -4
  27. data/lib/tina4/drivers/odbc_driver.rb +40 -4
  28. data/lib/tina4/drivers/postgres_driver.rb +97 -10
  29. data/lib/tina4/drivers/sqlite_driver.rb +21 -2
  30. data/lib/tina4/env.rb +176 -34
  31. data/lib/tina4/field_types.rb +12 -0
  32. data/lib/tina4/health.rb +30 -14
  33. data/lib/tina4/job.rb +15 -5
  34. data/lib/tina4/log.rb +236 -32
  35. data/lib/tina4/mcp.rb +11 -5
  36. data/lib/tina4/messenger.rb +248 -36
  37. data/lib/tina4/metrics.rb +179 -891
  38. data/lib/tina4/middleware.rb +191 -56
  39. data/lib/tina4/migration.rb +17 -1
  40. data/lib/tina4/orm.rb +114 -17
  41. data/lib/tina4/public/css/tina4.min.css +1 -1
  42. data/lib/tina4/queue.rb +154 -9
  43. data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
  44. data/lib/tina4/queue_backends/lite_backend.rb +121 -25
  45. data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
  46. data/lib/tina4/queue_backends/rabbitmq_backend.rb +194 -1
  47. data/lib/tina4/rack_app.rb +94 -316
  48. data/lib/tina4/request.rb +48 -8
  49. data/lib/tina4/response.rb +42 -1
  50. data/lib/tina4/response_cache.rb +142 -24
  51. data/lib/tina4/router.rb +141 -12
  52. data/lib/tina4/session.rb +243 -29
  53. data/lib/tina4/session_handlers/database_handler.rb +185 -20
  54. data/lib/tina4/session_handlers/file_handler.rb +113 -21
  55. data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
  56. data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
  57. data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
  58. data/lib/tina4/session_handlers/redis_handler.rb +20 -6
  59. data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
  60. data/lib/tina4/shutdown.rb +180 -30
  61. data/lib/tina4/sql_translator.rb +110 -0
  62. data/lib/tina4/swagger.rb +50 -18
  63. data/lib/tina4/version.rb +1 -1
  64. data/lib/tina4/webserver.rb +28 -6
  65. data/lib/tina4.rb +289 -37
  66. metadata +35 -17
  67. data/lib/tina4/scss_compiler.rb +0 -349
data/lib/tina4/cors.rb CHANGED
@@ -1,6 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
+ # CORS policy — reads config from env, computes the response headers.
5
+ #
6
+ # DENY BY DEFAULT (ADR-0018). With TINA4_CORS_ORIGINS unset, NO
7
+ # Access-Control-Allow-Origin is emitted and the browser's own CORS check
8
+ # blocks the cross-origin request. "*" still works, it just has to be asked
9
+ # for. Breaking change from the old permissive default.
10
+ #
11
+ # CREDENTIALS AND THE WILDCARD ARE MUTUALLY EXCLUSIVE. The Fetch Standard's
12
+ # CORS check treats "*" as a literal (not a wildcard) once the request's
13
+ # credentials mode is "include", so Access-Control-Allow-Origin: * together
14
+ # with Access-Control-Allow-Credentials: true is rejected by every browser.
15
+ # Ruby emitted exactly that pair before 2026-07-31 (measured through the real
16
+ # Rack app): the credentials header was written unconditionally from config,
17
+ # with no wildcard guard, while Python, PHP and Node all guarded it.
18
+ #
19
+ # VARY: ORIGIN whenever the ACAO value is COMPUTED from the request's Origin,
20
+ # i.e. whenever an allow-list is configured, on a MISS as well as a match.
21
+ # RFC 9110 s12.5.5: a Vary field name list tells cache recipients they "MUST
22
+ # NOT use this response to satisfy a later request unless the later request
23
+ # has the same values for the listed header fields as the original request".
24
+ # A constant "*" genuinely does not vary and gets no Vary, which would only
25
+ # fragment a CDN's cache per-origin for a response identical to everyone.
26
+ # Access-Control-Allow-Methods / -Allow-Headers are static configured lists
27
+ # here, never derived from the request's Access-Control-Request-* headers, so
28
+ # those field names do NOT belong in Vary.
4
29
  module CorsMiddleware
5
30
  class << self
6
31
  def config
@@ -9,48 +34,128 @@ module Tina4
9
34
 
10
35
  def reset!
11
36
  @config = nil
37
+ @warned = nil
12
38
  end
13
39
 
14
- # Handle OPTIONS preflight request, returns a Rack response array
15
- def preflight_response(env = {})
40
+ # The configured origins, split and emptied of blanks.
41
+ def allowed_origins
42
+ config[:origins].split(",").map(&:strip).reject(&:empty?)
43
+ end
44
+
45
+ # Whether an operator has actually declared a CORS policy.
46
+ def configured?
47
+ !allowed_origins.empty?
48
+ end
49
+
50
+ # The CORS policy headers for a request environment.
51
+ #
52
+ # This is the ONE place the policy is computed. preflight_response and
53
+ # apply_headers both call it, so the preflight path and the normal
54
+ # response path can never drift apart the way two implementations do.
55
+ def policy_headers(env = {})
56
+ allowed = allowed_origins
57
+ request_origin = env["HTTP_ORIGIN"]
58
+
59
+ if allowed.empty?
60
+ warn_once(:unconfigured, request_origin) if present?(request_origin)
61
+ return {}
62
+ end
63
+
64
+ headers = {}
65
+ wildcard = allowed.include?("*")
66
+ # An allow-list decision reads the request Origin, so the response
67
+ # varies by it — on a MISS too, or a shared cache can serve this
68
+ # no-ACAO response to an origin that should have been allowed.
69
+ headers["vary"] = "Origin" unless wildcard
70
+
16
71
  origin = resolve_origin(env)
17
- [
18
- 204,
19
- {
20
- "access-control-allow-origin" => origin,
21
- "access-control-allow-methods" => config[:methods],
22
- "access-control-allow-headers" => config[:headers],
23
- "access-control-max-age" => config[:max_age],
24
- "access-control-allow-credentials" => config[:credentials]
25
- },
26
- [""]
27
- ]
72
+ if origin.nil?
73
+ warn_once(:denied, request_origin) if present?(request_origin)
74
+ return headers
75
+ end
76
+
77
+ headers["access-control-allow-origin"] = origin
78
+ headers["access-control-allow-methods"] = config[:methods]
79
+ headers["access-control-allow-headers"] = config[:headers]
80
+ headers["access-control-max-age"] = config[:max_age]
81
+
82
+ if credentials?
83
+ if origin == "*"
84
+ warn_once(:wildcard_credentials, nil)
85
+ else
86
+ headers["access-control-allow-credentials"] = "true"
87
+ end
88
+ end
89
+
90
+ headers
91
+ end
92
+
93
+ # Handle a CORS preflight request, returns a Rack response array.
94
+ #
95
+ # `allow` is the resource's REAL method set, and it is emitted as the
96
+ # `Allow` header alongside the CORS headers. RFC 9110 s9.3.7 says a
97
+ # successful OPTIONS response SHOULD carry Allow, and a preflight is an
98
+ # OPTIONS response - dropping it means a bare OPTIONS and a preflight to
99
+ # the same path answer two different questions.
100
+ #
101
+ # This is CONFORMANCE, not a deviation. The frameworks' own OPTIONS
102
+ # handlers already do it - Django's View.options() sets Allow from
103
+ # _allowed_methods(), Express's router auto-answers OPTIONS with Allow.
104
+ # The add-on CORS libraries (cors npm, django-cors-headers, rack-cors,
105
+ # stack-cors, ASP.NET CORS) omit it, but that is a LAYERING artifact:
106
+ # each sits ahead of the framework, so short-circuiting the preflight
107
+ # also skips the framework's OPTIONS handler and the header it would
108
+ # have produced. Tina4 owns both paths, so it costs one header to answer
109
+ # both questions at once. See ADR-0013.
110
+ #
111
+ # Note Allow and Access-Control-Allow-Methods are NOT the same thing and
112
+ # are not interchangeable: Allow is what the resource supports, ACAM is
113
+ # what the CORS policy permits cross-origin. A policy allowing DELETE on
114
+ # a GET-only route is still a 405.
115
+ #
116
+ # The status is 204 whether the origin was allowed or denied — the
117
+ # browser does the blocking, and inventing a 403 here would be a second
118
+ # behaviour change for no gain.
119
+ def preflight_response(env = {}, allow: nil)
120
+ headers = policy_headers(env)
121
+ # An unknown path yields "" - the same shape the bare-OPTIONS branch
122
+ # uses - so a client can tell "nothing here" from "not told".
123
+ headers["allow"] = Array(allow).join(", ") unless allow.nil?
124
+ [204, headers, [""]]
28
125
  end
29
126
 
30
- # Apply CORS headers to a response headers hash
127
+ # Apply CORS headers to a response headers hash, merging Vary rather than
128
+ # clobbering a value another layer already set.
31
129
  def apply_headers(response_headers, env = {})
32
- origin = resolve_origin(env)
33
- response_headers["access-control-allow-origin"] = origin
34
- response_headers["access-control-allow-methods"] = config[:methods]
35
- response_headers["access-control-allow-headers"] = config[:headers]
36
- response_headers["access-control-max-age"] = config[:max_age]
37
- response_headers["access-control-allow-credentials"] = config[:credentials] if config[:credentials] == "true"
130
+ policy_headers(env).each do |name, value|
131
+ response_headers[name] = if name == "vary"
132
+ merge_vary(response_headers["vary"], value)
133
+ else
134
+ value
135
+ end
136
+ end
38
137
  response_headers
39
138
  end
40
139
 
41
- # Check if a given origin is allowed
140
+ # Check if a given origin is allowed by the configured policy.
42
141
  def origin_allowed?(origin)
43
- return true if config[:origins] == "*"
142
+ allowed = allowed_origins
143
+ return false if allowed.empty?
144
+ return true if allowed.include?("*")
44
145
 
45
- allowed = config[:origins].split(",").map(&:strip)
46
146
  allowed.include?(origin)
47
147
  end
48
148
 
149
+ def credentials?
150
+ %w[true 1 yes].include?(config[:credentials].to_s.downcase)
151
+ end
152
+
49
153
  private
50
154
 
51
155
  def load_config
52
156
  {
53
- origins: ENV["TINA4_CORS_ORIGINS"] || "*",
157
+ # Default is EMPTY, not "*" — deny by default (ADR-0018).
158
+ origins: ENV["TINA4_CORS_ORIGINS"] || "",
54
159
  methods: ENV["TINA4_CORS_METHODS"] || "GET, POST, PUT, PATCH, DELETE, OPTIONS",
55
160
  headers: ENV["TINA4_CORS_HEADERS"] || "Content-Type,Authorization,X-Request-ID",
56
161
  max_age: ENV["TINA4_CORS_MAX_AGE"] || "86400",
@@ -58,15 +163,66 @@ module Tina4
58
163
  }.freeze
59
164
  end
60
165
 
166
+ # Resolve the origin to send, or nil for none.
167
+ #
168
+ # Reads the Origin header ONLY. The Referer used to be a fallback, which
169
+ # was wrong twice over: a Referer is a full URL, not an origin, and the
170
+ # CORS protocol is defined entirely in terms of the Origin header. A
171
+ # plain same-site navigation carries a Referer and no Origin, and used to
172
+ # get CORS headers stamped on it for no reason.
61
173
  def resolve_origin(env)
62
- request_origin = env["HTTP_ORIGIN"] || env["HTTP_REFERER"]
174
+ allowed = allowed_origins
175
+ return nil if allowed.empty?
176
+ return "*" if allowed.include?("*")
177
+
178
+ request_origin = env["HTTP_ORIGIN"]
179
+ return nil unless present?(request_origin)
180
+
181
+ clean = request_origin.chomp("/")
182
+ allowed.include?(clean) ? clean : nil
183
+ end
184
+
185
+ def present?(value)
186
+ !value.nil? && !value.to_s.empty?
187
+ end
188
+
189
+ def merge_vary(current, field_name)
190
+ parts = current.to_s.split(",").map(&:strip).reject(&:empty?)
191
+ return parts.join(", ") if parts.any? { |p| p.casecmp?(field_name) }
192
+
193
+ (parts + [field_name]).join(", ")
194
+ end
195
+
196
+ # Log an actionable warning at most once per reason per process.
197
+ #
198
+ # A rejected cross-origin request is otherwise invisible: the browser
199
+ # reports a generic CORS failure and the server log says nothing, so the
200
+ # operator has to read the framework source to find the env var to set.
201
+ def warn_once(reason, request_origin)
202
+ @warned ||= {}
203
+ key = reason == :denied ? "denied:#{request_origin}" : reason
204
+ return if @warned[key]
205
+
206
+ @warned[key] = true
207
+ Tina4::Log.warning(warning_message(reason, request_origin))
208
+ rescue StandardError
209
+ nil # logging must never break a request
210
+ end
63
211
 
64
- if config[:origins] == "*"
65
- "*"
66
- elsif request_origin && origin_allowed?(request_origin.chomp("/"))
67
- request_origin.chomp("/")
212
+ def warning_message(reason, request_origin)
213
+ case reason
214
+ when :unconfigured
215
+ "CORS: refused cross-origin request from #{request_origin} - no policy is configured. " \
216
+ "Set TINA4_CORS_ORIGINS to the origins you want to allow, e.g. " \
217
+ "TINA4_CORS_ORIGINS=https://app.example.com (or '*' to allow any origin)."
218
+ when :denied
219
+ "CORS: origin #{request_origin} is not in TINA4_CORS_ORIGINS (#{config[:origins]}) " \
220
+ "- the browser will block this response."
68
221
  else
69
- ""
222
+ "CORS: TINA4_CORS_CREDENTIALS is true but TINA4_CORS_ORIGINS is '*'. The Fetch Standard " \
223
+ "forbids Access-Control-Allow-Origin: * with credentials, so credentials are NOT being " \
224
+ "sent. Credentialed CORS requires an explicit origin list, e.g. " \
225
+ "TINA4_CORS_ORIGINS=https://app.example.com."
70
226
  end
71
227
  end
72
228
  end
@@ -149,8 +149,11 @@ module Tina4
149
149
  (["?"] * count).join(", ")
150
150
  end
151
151
 
152
+ # NEW LINE, not a space — appended inline the clause lands inside a
153
+ # trailing `-- comment` and the engine ignores it (see the note on
154
+ # Drivers::SqliteDriver#apply_limit).
152
155
  def apply_limit(sql, limit, offset = 0)
153
- "#{sql} LIMIT #{limit} OFFSET #{offset}"
156
+ "#{sql}\nLIMIT #{limit} OFFSET #{offset}"
154
157
  end
155
158
 
156
159
  private