tina4ruby 3.13.93 → 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 (69) 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 +35 -43
  13. data/lib/tina4/cors.rb +186 -30
  14. data/lib/tina4/database/sqlite3_adapter.rb +4 -1
  15. data/lib/tina4/database.rb +458 -48
  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/dev_mailbox.rb +5 -1
  22. data/lib/tina4/dispatch_pipeline.rb +605 -0
  23. data/lib/tina4/docstore.rb +274 -60
  24. data/lib/tina4/drivers/firebird_driver.rb +118 -4
  25. data/lib/tina4/drivers/mongodb_driver.rb +19 -4
  26. data/lib/tina4/drivers/mssql_driver.rb +73 -10
  27. data/lib/tina4/drivers/mysql_driver.rb +71 -4
  28. data/lib/tina4/drivers/odbc_driver.rb +40 -4
  29. data/lib/tina4/drivers/postgres_driver.rb +97 -10
  30. data/lib/tina4/drivers/sqlite_driver.rb +25 -3
  31. data/lib/tina4/env.rb +176 -34
  32. data/lib/tina4/field_types.rb +12 -0
  33. data/lib/tina4/frond.rb +102 -10
  34. data/lib/tina4/health.rb +30 -14
  35. data/lib/tina4/job.rb +15 -5
  36. data/lib/tina4/log.rb +236 -32
  37. data/lib/tina4/mcp.rb +11 -5
  38. data/lib/tina4/messenger.rb +317 -82
  39. data/lib/tina4/metrics.rb +179 -891
  40. data/lib/tina4/middleware.rb +191 -56
  41. data/lib/tina4/migration.rb +17 -1
  42. data/lib/tina4/orm.rb +114 -17
  43. data/lib/tina4/public/css/tina4.min.css +1 -1
  44. data/lib/tina4/queue.rb +154 -9
  45. data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
  46. data/lib/tina4/queue_backends/lite_backend.rb +121 -25
  47. data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
  48. data/lib/tina4/queue_backends/rabbitmq_backend.rb +194 -1
  49. data/lib/tina4/rack_app.rb +94 -316
  50. data/lib/tina4/request.rb +48 -8
  51. data/lib/tina4/response.rb +42 -1
  52. data/lib/tina4/response_cache.rb +142 -24
  53. data/lib/tina4/router.rb +141 -12
  54. data/lib/tina4/session.rb +243 -29
  55. data/lib/tina4/session_handlers/database_handler.rb +185 -20
  56. data/lib/tina4/session_handlers/file_handler.rb +113 -21
  57. data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
  58. data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
  59. data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
  60. data/lib/tina4/session_handlers/redis_handler.rb +20 -6
  61. data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
  62. data/lib/tina4/shutdown.rb +180 -30
  63. data/lib/tina4/sql_translator.rb +110 -0
  64. data/lib/tina4/swagger.rb +50 -18
  65. data/lib/tina4/version.rb +1 -1
  66. data/lib/tina4/webserver.rb +28 -6
  67. data/lib/tina4.rb +301 -38
  68. metadata +35 -17
  69. data/lib/tina4/scss_compiler.rb +0 -349
data/lib/tina4/env.rb CHANGED
@@ -92,32 +92,38 @@ module Tina4
92
92
  "TINA4_LOG_LEVEL" => "[TINA4_LOG_ALL]"
93
93
  }.freeze
94
94
 
95
- # Typed env-var coercion parity with tina4_python's Env class,
96
- # tina4-php's Tina4\Env, and tina4-nodejs's Env. Truthy values
97
- # (case-insensitive after strip): "1", "true", "on", "yes", "y", "t".
98
- # Falsy: "0", "false", "off", "no", "n", "f", empty string. Anything
99
- # else falls through to default.
100
- TRUTHY = %w[1 true on yes y t].freeze
101
- FALSY = %w[0 false off no n f].freeze
95
+ # The ONE env truthiness table. Every env boolean in every Tina4 framework
96
+ # answers from this set (case-insensitive after strip):
97
+ #
98
+ # truthy: "true", "1", "yes", "on" falsy: everything else
99
+ #
100
+ # BREAKING (parity): "y", "t", "n" and "f" were accepted here and NOWHERE
101
+ # ELSE not by Ruby's own Log/Mcp checks, and not by Python, PHP or Node.
102
+ # So TINA4_LOG_FUNC=y switched function logging ON while TINA4_DEBUG=y left
103
+ # debug OFF, in the same process, from the same .env. Single letters are
104
+ # dropped rather than spread: systemd's boolean set is 1/yes/true/on with no
105
+ # letters, and YAML 1.2 removed y/n precisely because a bare letter reads as
106
+ # a value (the Norway problem). Use "true"/"false".
107
+ # There is deliberately NO falsy table. Falsy is "not in TRUTHY", so there
108
+ # is exactly one list to keep correct. A second table is a second thing
109
+ # that can drift, and it is what let `bool` and `is_truthy` disagree.
110
+ TRUTHY = %w[true 1 yes on].freeze
102
111
 
103
112
  # Check if a value is truthy for env boolean checks.
104
113
  #
105
114
  # Accepts: "true", "True", "TRUE", "1", "yes", "Yes", "YES", "on", "On", "ON".
106
115
  # Everything else is falsy (including empty string, nil, not set).
107
116
  def self.is_truthy(val)
108
- %w[true 1 yes on].include?(val.to_s.strip.downcase)
117
+ TRUTHY.include?(val.to_s.strip.downcase)
109
118
  end
110
119
 
111
- # Read an env var and coerce to Boolean. Returns +default+ when the
112
- # var is unset or holds a value outside the TRUTHY/FALSY tables.
113
- # Never raises bad input falls through to default.
120
+ # Read an env var and coerce to Boolean. Returns +default+ only when the
121
+ # var is UNSET a value that IS set is answered by the one truthiness
122
+ # table, never quietly replaced by the default. Never raises.
114
123
  def self.bool(name, default: false)
115
124
  raw = ENV[name.to_s]
116
125
  return default if raw.nil?
117
- token = raw.strip.downcase
118
- return true if TRUTHY.include?(token)
119
- return false if FALSY.include?(token) || token.empty?
120
- default
126
+ is_truthy(raw)
121
127
  end
122
128
 
123
129
  # Read an env var and coerce to Integer. Logs a warning via Tina4::Log
@@ -174,8 +180,11 @@ module Tina4
174
180
  # gitignored .env.local (e.g. a stale auto-generated dev secret) must
175
181
  # not override an explicitly-set real TINA4_SECRET. The ensure-dev-secret
176
182
  # bootstrap runs AFTER this (only mints a secret if still unset in dev).
177
- load_local_env(root_dir)
178
- parse_env_file(env_file)
183
+ local = load_local_env(root_dir)
184
+ # .env.local WINS on a duplicate key, so it merges last. Both hashes
185
+ # already report the value in effect, so a real env var that beat both
186
+ # is what a caller reads back either way.
187
+ parse_env_file(env_file).merge(local)
179
188
  end
180
189
 
181
190
  # Load .env.local with first-wins semantics (override=false). A real
@@ -184,7 +193,8 @@ module Tina4
184
193
  # > .env). No-op when the file is absent (common for fresh checkouts).
185
194
  def load_local_env(root_dir = Dir.pwd)
186
195
  local_file = File.join(root_dir, ".env.local")
187
- parse_env_file(local_file) if File.exist?(local_file)
196
+ return {} unless File.exist?(local_file)
197
+ parse_env_file(local_file)
188
198
  end
189
199
 
190
200
  # Get an env var value, with optional default
@@ -203,11 +213,28 @@ module Tina4
203
213
  end
204
214
 
205
215
  # Raise if any of the given keys are missing from ENV
206
- def require_env!(*keys)
207
- missing = keys.map(&:to_s).reject { |k| ENV.key?(k) }
216
+ # Validate that required env vars exist, and return them.
217
+ #
218
+ # RENAMED from require_env! on 2026-07-31. The bang was Ruby-idiomatic for
219
+ # "raises", but the concept is named require_env in the other three, and
220
+ # the surface-table rule is one name per concept with idiomatic CASING
221
+ # only. An alias would paper over the mismatch instead of fixing it, so
222
+ # the primary is renamed. Breaking for anyone calling require_env!.
223
+ #
224
+ # Returns a hash of every requested key to its value, matching Python,
225
+ # PHP and Node - it used to return nothing, so a caller could validate but
226
+ # not read in one step.
227
+ #
228
+ # Reports every missing name in one raise rather than the first: an
229
+ # operator fixing a deployment wants the whole list, not one name per
230
+ # restart.
231
+ def require_env(*keys)
232
+ names = keys.flatten.map(&:to_s)
233
+ missing = names.reject { |k| ENV.key?(k) }
208
234
  unless missing.empty?
209
- raise KeyError, "Missing required env vars: #{missing.join(', ')}"
235
+ raise KeyError, "Missing required environment variables: #{missing.join(', ')}"
210
236
  end
237
+ names.to_h { |k| [k, ENV[k]] }
211
238
  end
212
239
 
213
240
  # Reset: clear all env vars that were loaded (restore to process defaults)
@@ -250,23 +277,138 @@ module Tina4
250
277
  # override=true: `ENV[key] = value` — unconditional set. NOT used by the
251
278
  # boot load sequence; kept only as an explicit opt-in for callers that
252
279
  # genuinely need to force values.
280
+ # Returns a hash of the keys this file declared, mapped to the value that
281
+ # WON - not the value the file declared. The two differ exactly when the
282
+ # real environment beat the file, which is the case an operator most needs
283
+ # to see: reporting the file's value there would return "from_local" while
284
+ # the process is actually running on the real env var. A map that
285
+ # disagrees with ENV is worse than no map, because it looks authoritative.
253
286
  def parse_env_file(path, override: false)
254
- return unless File.exist?(path)
255
- File.readlines(path).each do |line|
256
- line = line.strip
257
- next if line.empty? || line.start_with?("#")
258
- if (match = line.match(/\A([A-Za-z_][A-Za-z0-9_]*)=["']?(.*)["']?\z/))
259
- key = match[1]
260
- value = match[2].gsub(/["']\z/, "")
261
- if override
262
- ENV[key] = value
263
- else
264
- ENV[key] ||= value
287
+ effective = {}
288
+ return effective unless File.exist?(path)
289
+ warned_refs = {}
290
+ File.readlines(path).each_with_index do |raw, index|
291
+ key, value = parse_env_line(raw, path, index + 1, warned_refs)
292
+ next if key.nil?
293
+ if override
294
+ ENV[key] = value
295
+ else
296
+ ENV[key] ||= value
297
+ end
298
+ @loaded_keys ||= []
299
+ @loaded_keys << key
300
+ effective[key] = ENV[key]
301
+ end
302
+ effective
303
+ end
304
+
305
+ # Parse ONE dotenv line into [key, value], or [nil, nil] for a line that
306
+ # sets nothing (blank, comment, or malformed).
307
+ #
308
+ # The rules are the cross-framework behaviour table (feature 1 of the
309
+ # feature audit): identical in Python, PHP, Ruby and Node, driven off one
310
+ # committed fixture. Ruby used to differ on two of them, silently.
311
+ def parse_env_line(raw, path, line_no, warned_refs)
312
+ line = raw.strip
313
+ return [nil, nil] if line.empty? || line.start_with?("#")
314
+
315
+ # Rule 2: a shell-style `export FOO=bar` is valid input, not an error.
316
+ # Ruby used to fall straight through this line, leaving the variable
317
+ # UNSET with no warning -- so a .env copied out of a shell profile lost
318
+ # keys, and the failure surfaced somewhere unrelated (a blank
319
+ # TINA4_SECRET, a missing database URL).
320
+ line = line.sub(/\Aexport\s+/, "")
321
+
322
+ # Rule 3: split on the FIRST `=` only. A line with no `=` is skipped
323
+ # with a warning naming the line -- never in silence.
324
+ eq = line.index("=")
325
+ if eq.nil?
326
+ warn_env("#{path}:#{line_no}: no '=' in \"#{line}\", line skipped")
327
+ return [nil, nil]
328
+ end
329
+
330
+ key = line[0...eq].strip
331
+ # Rule 4: reject a key that is not a valid identifier, by name and line.
332
+ unless key.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
333
+ warn_env("#{path}:#{line_no}: invalid key #{key.inspect}, line skipped")
334
+ return [nil, nil]
335
+ end
336
+
337
+ [key, parse_env_value(line[(eq + 1)..].to_s.strip, path, line_no, warned_refs)]
338
+ end
339
+
340
+ # Rule 5 + 6: quoting decides escapes AND interpolation, in that order.
341
+ def parse_env_value(value, path, line_no, warned_refs)
342
+ # A QUOTED value ends at its CLOSING QUOTE, and anything after it is a
343
+ # comment. Testing the LAST character instead was wrong: a trailing
344
+ # comment makes the last character non-quote, so `PW="s3cret" # note`
345
+ # fell through to the unquoted branch below, which strips only the ` #`
346
+ # and left the QUOTE CHARACTERS in the value -- a credential handed to a
347
+ # driver as "\"s3cret\"". PHP already scanned for the terminator; this is
348
+ # that mechanism, and the scan SKIPS a quote preceded by a backslash so
349
+ # an escaped \" cannot end the value early.
350
+ if !value.empty? && (value.start_with?("'") || value.start_with?('"'))
351
+ quote = value[0]
352
+ i = 1
353
+ while i < value.length
354
+ if value[i] == "\\" && quote == '"' && i + 1 < value.length
355
+ i += 2
356
+ next
265
357
  end
266
- @loaded_keys ||= []
267
- @loaded_keys << key
358
+ break if value[i] == quote
359
+ i += 1
360
+ end
361
+
362
+ if i < value.length
363
+ inner = value[1...i]
364
+ # Single quotes are verbatim: no escapes, no interpolation. Shell
365
+ # semantics, and the documented way to keep a literal ${...}.
366
+ return inner if quote == "'"
367
+
368
+ inner = inner.gsub("\\n", "\n")
369
+ .gsub("\\r", "\r")
370
+ .gsub("\\t", "\t")
371
+ .gsub('\\"', '"')
372
+ .gsub("\\\\", "\\")
373
+ return interpolate_env(inner, path, line_no, warned_refs)
268
374
  end
269
375
  end
376
+
377
+ # Rule 5: an unquoted value is truncated at the first SPACE-HASH, then
378
+ # trimmed. Ruby used to keep the whole line, so `FOO=value # note`
379
+ # became "value # note" -- a wrong value rather than an absent one,
380
+ # which is the harder kind to notice.
381
+ hash = value.index(" #")
382
+ value = value[0...hash] if hash
383
+ interpolate_env(value.rstrip, path, line_no, warned_refs)
384
+ end
385
+
386
+ # Rule 6: expand ${VAR} against already-loaded keys plus the real
387
+ # environment. An unresolved name is left LITERAL and warned about once
388
+ # per name, so a typo is visible without breaking the load.
389
+ def interpolate_env(value, path, line_no, warned_refs)
390
+ value.gsub(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/) do
391
+ name = Regexp.last_match(1)
392
+ if ENV.key?(name)
393
+ ENV[name]
394
+ else
395
+ unless warned_refs[name]
396
+ warned_refs[name] = true
397
+ warn_env("#{path}:#{line_no}: ${#{name}} is not set, left as-is")
398
+ end
399
+ "${#{name}}"
400
+ end
401
+ end
402
+ end
403
+
404
+ # One place to emit a parse warning. Routed through Tina4::Log when it is
405
+ # loaded (env.rb is required before the logger during boot), else $stderr.
406
+ def warn_env(message)
407
+ if defined?(Tina4::Log) && Tina4::Log.respond_to?(:warning)
408
+ Tina4::Log.warning(message)
409
+ else
410
+ $stderr.puts("[tina4] #{message}")
411
+ end
270
412
  end
271
413
  end
272
414
  end
@@ -15,6 +15,18 @@ module Tina4
15
15
  @primary_key_field
16
16
  end
17
17
 
18
+ # EVERY primary-key field name, in declaration order.
19
+ #
20
+ # A key may span several columns. +primary_key_field+ returns only ONE and
21
+ # is kept for the auto-increment paths, which are single-column by
22
+ # definition. Anything that ADDRESSES a row must use this: keying on one
23
+ # column of a composite key matches every row sharing that value, which is
24
+ # the data-loss shape feature 4 removed from the raw write path below.
25
+ def primary_key_fields
26
+ keys = field_definitions.select { |_n, o| o[:primary_key] }.keys
27
+ keys.empty? ? [:id] : keys
28
+ end
29
+
18
30
  def table_name(name = nil)
19
31
  if name
20
32
  @table_name = name
data/lib/tina4/frond.rb CHANGED
@@ -163,6 +163,29 @@ module Tina4
163
163
  elif else elseif endautoescape endblock endcache endfor endif endlive
164
164
  endmacro endraw endset endspaceless
165
165
  ].freeze
166
+
167
+ # Author-written tags the sandbox allow-list governs. Mirrors Python's
168
+ # _GATEABLE_TAGS and PHP's GATEABLE_TAGS. A tag absent from this list is
169
+ # structural, not an author capability, and is never gated -- `block` and
170
+ # `extends` are template inheritance, and `raw` is consumed by the tokenizer.
171
+ # Both spellings of set ({% set x = 1 %} and {% set x %}...{% endset %})
172
+ # dispatch under "set", so one entry covers the pair.
173
+ GATEABLE_TAGS = %w[
174
+ autoescape cache for from if import include live macro set spaceless
175
+ ].freeze
176
+
177
+ # Gateable tags that OWN A BODY, mapped to the terminator closing it. A
178
+ # denied tag has to consume its body -- see skip_block.
179
+ BLOCK_TAG_ENDS = {
180
+ "autoescape" => "endautoescape",
181
+ "cache" => "endcache",
182
+ "for" => "endfor",
183
+ "if" => "endif",
184
+ "live" => "endlive",
185
+ "macro" => "endmacro",
186
+ "set" => "endset",
187
+ "spaceless" => "endspaceless"
188
+ }.freeze
166
189
  AUTOESCAPE_RE = /\Aautoescape\s+(false|true)/
167
190
  STRIPTAGS_RE = /<[^>]+>/
168
191
  THOUSANDS_RE = /(\d)(?=(\d{3})+(?!\d))/
@@ -444,6 +467,28 @@ module Tina4
444
467
  self
445
468
  end
446
469
 
470
+ # May this filter RUN under the current sandbox?
471
+ #
472
+ # The escaping decision has to ask this rather than read the filter name out
473
+ # of the source: a denied `raw` that still marked its value safe made the
474
+ # allow-list entry governing XSS escaping inert.
475
+ def filter_permitted?(name)
476
+ return true unless @sandbox && @allowed_filters
477
+
478
+ @allowed_filters.include?(name.to_s)
479
+ end
480
+
481
+ # May this tag run under the current sandbox?
482
+ #
483
+ # One gate for every tag, so the allow-list governs the whole tag vocabulary
484
+ # instead of whichever names somebody remembered to check individually.
485
+ def tag_permitted?(tag)
486
+ return true unless @sandbox && @allowed_tags
487
+ return true unless GATEABLE_TAGS.include?(tag)
488
+
489
+ @allowed_tags.include?(tag)
490
+ end
491
+
447
492
  # Utility: HTML escape
448
493
  def self.escape_html(str)
449
494
  str.to_s.gsub(HTML_ESCAPE_RE, HTML_ESCAPE_MAP)
@@ -692,6 +737,43 @@ module Tina4
692
737
  # Token renderer
693
738
  # -----------------------------------------------------------------------
694
739
 
740
+ # Consume a denied tag WITHOUT running it, returning the index past its body.
741
+ #
742
+ # Ruby walks a token stream, so a denied tag cannot simply be dropped the way
743
+ # an AST node can. Advancing by one token would leave the body's tokens to
744
+ # render at the TOP level, leaking exactly the content the sandbox denied.
745
+ # Running the handler and discarding its output is no better: handle_set binds
746
+ # its variable before it returns. Consuming the block gives all three
747
+ # properties at once -- no output, no side effects, correct index.
748
+ def skip_block(tokens, i, tag, content)
749
+ terminator = BLOCK_TAG_ENDS[tag]
750
+ # {% set x = 1 %} is an assignment and owns no body; {% set x %}...{% endset %}
751
+ # captures one. Same exact-"=" test the dispatch uses.
752
+ terminator = nil if tag == "set" && content.include?("=")
753
+ return i + 1 if terminator.nil?
754
+
755
+ depth = 1
756
+ j = i + 1
757
+ while j < tokens.length
758
+ if tokens[j][0] == BLOCK
759
+ inner = strip_tag(tokens[j][1])[0]
760
+ case inner.split[0]
761
+ when tag
762
+ # A nested assignment-form set opens no body, so it must not nest.
763
+ depth += 1 unless tag == "set" && inner.include?("=")
764
+ when terminator
765
+ depth -= 1
766
+ return j + 1 if depth.zero?
767
+ end
768
+ end
769
+ j += 1
770
+ end
771
+
772
+ # Unterminated block: consume to the end. Nothing renders, which is the
773
+ # safe answer -- the alternative leaks the body of a denied tag.
774
+ tokens.length
775
+ end
776
+
695
777
  def render_tokens(tokens, context)
696
778
  output = []
697
779
  i = 0
@@ -725,6 +807,18 @@ module Tina4
725
807
 
726
808
  tag = content.split[0] || ""
727
809
 
810
+ # ONE sandbox gate for the whole tag vocabulary. Previously only
811
+ # `include` was checked, so every other tag ignored the allow-list --
812
+ # {% autoescape false %} could switch escaping off from inside a
813
+ # sandbox whose tags were restricted to something else entirely.
814
+ unless tag_permitted?(tag)
815
+ i = skip_block(tokens, i, tag, content)
816
+ if strip_a && i < tokens.length && tokens[i][0] == TEXT
817
+ tokens[i] = [TEXT, tokens[i][1].lstrip]
818
+ end
819
+ next
820
+ end
821
+
728
822
  case tag
729
823
  when "if"
730
824
  result, i = handle_if(tokens, i, context)
@@ -745,12 +839,8 @@ module Tina4
745
839
  i = handle_set_block(tokens, i, context)
746
840
  end
747
841
  when "include"
748
- if @sandbox && @allowed_tags && !@allowed_tags.include?("include")
749
- i += 1
750
- else
751
- output << handle_include(content, context)
752
- i += 1
753
- end
842
+ output << handle_include(content, context)
843
+ i += 1
754
844
  when "macro"
755
845
  i = handle_macro(tokens, i, context)
756
846
  when "import"
@@ -913,14 +1003,16 @@ module Tina4
913
1003
  is_safe = false
914
1004
  filters.each do |fname, args|
915
1005
  if fname == "raw" || fname == "safe"
916
- is_safe = true
1006
+ # Decide from what was permitted to RUN, not from what the source
1007
+ # asked for. Marking the value safe here regardless meant a DENIED
1008
+ # raw produced byte-identical output to an allowed one -- the
1009
+ # allow-list entry that governs XSS escaping did nothing at all.
1010
+ is_safe = true if filter_permitted?(fname)
917
1011
  next
918
1012
  end
919
1013
 
920
1014
  # Sandbox: check filter access
921
- if @sandbox && @allowed_filters && !@allowed_filters.include?(fname)
922
- next
923
- end
1015
+ next if @sandbox && @allowed_filters && !@allowed_filters.include?(fname)
924
1016
 
925
1017
  # Filter + property-access chain: `first.groupSummary` — apply
926
1018
  # the filter, then traverse the path on the result. Done BEFORE
data/lib/tina4/health.rb CHANGED
@@ -6,29 +6,45 @@ module Tina4
6
6
  module Health
7
7
  START_TIME = Process.clock_gettime(Process::CLOCK_MONOTONIC)
8
8
 
9
+ # The path used when TINA4_HEALTH_PATH is unset. Identical in all four
10
+ # frameworks; the under-prefix keeps it clear of an application route that
11
+ # happens to be called /health.
12
+ DEFAULT_PATH = "/__health"
13
+
14
+ # Always registered as well, whatever the configured path, so a probe
15
+ # written before TINA4_HEALTH_PATH existed keeps working. Setting the env
16
+ # var adds a path, it never takes one away.
17
+ LEGACY_PATH = "/health"
18
+
9
19
  class << self
10
20
  # Return the configured health endpoint path.
11
- # TINA4_HEALTH_PATH overrides the default "/__health" — kept consistent across all 4 frameworks.
21
+ # TINA4_HEALTH_PATH overrides the default, kept consistent across all 4 frameworks.
12
22
  def path
13
23
  configured = ENV["TINA4_HEALTH_PATH"]
14
- return "/__health" if configured.nil? || configured.empty?
24
+ return DEFAULT_PATH if configured.nil? || configured.empty?
15
25
  configured.start_with?("/") ? configured : "/#{configured}"
16
26
  end
17
27
 
18
28
  def register!
19
- # Idempotent by ASKING THE ROUTER, not by remembering. run! and the CLI
20
- # both call this, and it can run more than once per process. A boolean
21
- # flag looked equivalent and was not: Router.clear! (every spec, and
22
- # any re-init) wipes the routes while the flag stays true, so /health
23
- # silently vanishes for the rest of the run. Querying the router is
24
- # self-healing -- cleared routes re-register, present ones don't
25
- # duplicate.
26
- return if Tina4::Router.find_route("/health", "GET")
27
-
28
- # Register at the configured path. The legacy "/health" path stays
29
- # registered for backward-compat.
29
+ # Unconditional, and idempotent for free: Router.add REPLACES a repeated
30
+ # (method, path) in place rather than appending, so calling this twice
31
+ # cannot duplicate and a Router.clear! is self-healing on the next call.
32
+ #
33
+ # There used to be a guard here -- `return if find_route("/health",
34
+ # "GET")` -- whose arguments were swapped: the signature is
35
+ # find_route(METHOD, PATH). It usually matched nothing and did nothing,
36
+ # but an ANY route matches any path, so an app with an ordinary
37
+ # catch-all made the guard fire and health was never registered at all.
38
+ # A probe then received the application's catch-all response instead of
39
+ # the health payload. Correcting the order would not have fixed it,
40
+ # because find_route("GET", path) matches that catch-all too; asking the
41
+ # router "does something answer here" can never distinguish "health is
42
+ # registered" from "something else happens to match".
43
+ #
44
+ # The legacy "/health" is always registered alongside the configured
45
+ # path so an existing probe keeps working when TINA4_HEALTH_PATH is set.
30
46
  Tina4::Router.add("GET", path, method(:handle))
31
- Tina4::Router.add("GET", "/health", method(:handle)) unless path == "/health"
47
+ Tina4::Router.add("GET", LEGACY_PATH, method(:handle)) unless path == LEGACY_PATH
32
48
  end
33
49
 
34
50
 
data/lib/tina4/job.rb CHANGED
@@ -86,14 +86,24 @@ module Tina4
86
86
  # queue.dead_letters returns it. No manual retry_failed is required.
87
87
  def fail(reason = "")
88
88
  @error = reason
89
- if @queue && @queue.backend.respond_to?(:fail)
90
- @queue.backend.fail(self, reason)
91
- @status = @attempts >= @queue.max_retries ? :dead : :pending
92
- else
93
- # No backend reference — degrade to in-memory bookkeeping only.
89
+ # A job with no queue is a detached value object (built by hand, or read
90
+ # out of a serialised payload) — in-memory bookkeeping is all there is.
91
+ unless @queue
94
92
  @attempts += 1
95
93
  @status = :failed
94
+ return self
96
95
  end
96
+
97
+ # NO respond_to?(:fail) GUARD. It used to fall through to the same
98
+ # in-memory branch when the backend had no fail method, which is exactly
99
+ # what rabbitmq and kafka were: job.fail() bumped a counter in this
100
+ # process and the BROKER WAS NEVER TOLD. No dead letter was written, the
101
+ # delivery stayed unacked, and failed()/dead_letters() both reported
102
+ # nothing — the job was lost as far as the application could see. A
103
+ # backend that cannot record a failure must say so (invariant 6), and a
104
+ # backend missing the method must fail loudly here rather than pretend.
105
+ @queue.backend.fail(self, reason)
106
+ @status = @attempts >= @queue.max_retries ? :dead : :pending
97
107
  self
98
108
  end
99
109